aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-14 22:21:25 -0700
committerAdam Soltys <[email protected]>2026-10-14 22:21:25 -0700
commitebc59e50cf86e8edfab21a5be9063e5d5eeab07b (patch)
treeefc4dc4b463fd93dab42c68e10232d3b8a162c60
parent0355c21254d6d452fd4afbec28db3e124bc1d61a (diff)
downloadcoinosv3-ebc59e50cf86e8edfab21a5be9063e5d5eeab07b.tar.gz
coinosv3-ebc59e50cf86e8edfab21a5be9063e5d5eeab07b.zip
Media pager: a spin of the wheel flies past the posts and lands snapped on one. A notch was a step, and a breath between steps meant a flick of the wheel moved two or three pictures at most (Adam). Now each notch adds a post to go — the third and later notches of a quick run (within 400 ms) add two, so a flick is a few posts and a spin a dozen — and the pager glides through them with the pictures passing (pagerFly: a longer glide, 100 ms + 110 per screen, eased out), landing snapped on the last. The posts passed are not arrived at: the slide observer stands down while flying (no clip starts, nothing marked seen, no counts asked), and the landing arrives (pager.arrive, the observer's old body). A notch back against a flight catches it on the post it is passing and steps back from there; a touch or a key catches it too (pagerGlide now takes a duration and an onDone that pagerHold fires on interruption, with the snap restored). A notch from rest is the single quick step it was; sideways notches and trackpad runs step as before. tools/pager-wheel-test.js: a spin of five flies eight posts on and lands snapped (gliding mid-way), the spin back, a notch against the flight. feed-video-pager-test's two failures are on HEAD before this.
Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01J1Kon8bxJoHRpJdSzshCkp
-rw-r--r--src/features/messages.js98
-rw-r--r--tools/pager-wheel-test.js31
2 files changed, 99 insertions, 30 deletions
diff --git a/src/features/messages.js b/src/features/messages.js
index 7760654..ae801cd 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -12444,13 +12444,16 @@ export function messagesFeature(ctx) {
// The pager's own glide from slide to slide: the browser's smooth scroll
// dawdles (half a second and more on Android). Quick and eased out, faster
// still after a fast flick; snapping stays off until it lands.
- function pagerGlide(box, axis, to, vel = 0) {
+ // A longer glide (a flight past several posts) says how long it takes;
+ // onDone hears the landing, or the interruption (true) that caught it.
+ function pagerGlide(box, axis, to, vel = 0, { ms: want = 0, onDone = null } = {}) {
+ pagerHold(box); // a glide under way is caught where it is
const y = axis === 'y', from = y ? box.scrollTop : box.scrollLeft, dist = to - from;
- cancelAnimationFrame(box._glide);
box.style.scrollSnapType = 'none';
- const done = () => { box._glide = 0; box.style.scrollSnapType = ''; };
+ box._onDone = onDone;
+ const done = (cut) => { box._glide = 0; box.style.scrollSnapType = ''; const f = box._onDone; box._onDone = null; if (f) f(!!cut); };
if (Math.abs(dist) < 1) { done(); return; }
- const ms = Math.max(60, Math.min(130, Math.abs(dist) / Math.max(5, Math.abs(vel) * 1.5)));
+ const ms = want || Math.max(60, Math.min(130, Math.abs(dist) / Math.max(5, Math.abs(vel) * 1.5)));
// timed from its first frame: a frame's time can be a little before now,
// and a glide timed from now began by stepping back
let t0 = 0;
@@ -12469,7 +12472,11 @@ export function messagesFeature(ctx) {
const b = box.getBoundingClientRect(), r = el.getBoundingClientRect();
return y ? box.scrollTop + r.top - b.top : box.scrollLeft + r.left - b.left;
};
- const pagerHold = (box) => { if (box && box._glide) { cancelAnimationFrame(box._glide); box._glide = 0; } };
+ const pagerHold = (box) => {
+ if (!box || !box._glide) return;
+ cancelAnimationFrame(box._glide); box._glide = 0; box.style.scrollSnapType = '';
+ const f = box._onDone; box._onDone = null; if (f) f(true);
+ };
// the slide on screen — named, not "a screen further", which from a
// scroll still settling landed short
const pagerHere = () => pager && (pagerSlides().find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.left) < r.width / 2 && Math.abs(r.top) < r.height / 2; }) || pager.cur);
@@ -12491,6 +12498,41 @@ export function messagesFeature(ctx) {
}
return false;
}
+ // A spin of the wheel flies. Each notch adds a post to go — the third and
+ // later notches of a quick run add two, so a flick of the wheel is a few
+ // posts and a spin a dozen — and the pager glides through them, the
+ // pictures passing, to land snapped on the last. The posts passed are not
+ // arrived at (no clip starts, nothing is marked seen): only the landing
+ // is (pagerArrive). A notch back against a flight catches it on the post
+ // it is passing and steps back from there; a touch or a key catches it
+ // too (pagerHold → onDone). A notch from rest is the single step it was.
+ let fly = null; // { to: the post flown to, n: notches in this run, at, dir }
+ function pagerFly(dir, now) {
+ if (!pager) return;
+ const stops = [...pager.scroller.children]; // the posts, then the tail
+ const live = fly && now - fly.at < 400 && pager.scroller._glide;
+ let from, n = 1;
+ if (live && fly.dir === dir) { from = fly.to; n = fly.n + 1; }
+ else if (live) { // against the flight: the post it is passing
+ const y = pager.scroller.scrollTop;
+ from = stops.reduce((b, g) => (Math.abs(g.offsetTop - y) < Math.abs(b.offsetTop - y) ? g : b));
+ } else {
+ const here = pagerHere();
+ from = pager.onTail || !here ? pager.tail : here.closest('.mp-group');
+ }
+ const i = Math.max(0, Math.min(stops.length - 1, stops.indexOf(from) + dir * (n <= 2 ? 1 : 2)));
+ const to = stops[i];
+ fly = { to, n, at: now, dir };
+ if (live) pager.scroller._onDone = null; // the flight goes on: not a landing
+ const dest = pagerAt(pager.scroller, to, true);
+ const screens = Math.abs(dest - pager.scroller.scrollTop) / Math.max(1, pager.scroller.clientHeight);
+ pager.flying = true;
+ pagerGlide(pager.scroller, 'y', dest, 0, {
+ ms: screens > 1.05 ? Math.min(1100, 100 + 110 * screens) : 0,
+ onDone: () => { if (!pager) return; pager.flying = false; const s = pagerHere(); if (s && s !== pager.cur) pagerArrive(s); },
+ });
+ }
+ const pagerArrive = (slide) => { if (pager && pager.arrive) pager.arrive(slide); };
// the sideways arrows show only where the post has a picture that way
function pagerArrows(slide) {
if (!pager || !slide) return;
@@ -12515,10 +12557,11 @@ export function messagesFeature(ctx) {
h('button', { class: 'mp-arrow ' + cls, type: 'button', 'aria-label': t(label), title: t(label), onClick: (e) => { e.stopPropagation(); pagerStep(v, hz); } },
h('span', { style: 'display:flex', html: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="' + d + '"/></svg>' })))));
// A mouse wheel pages: a notch down is the next post, a notch up the one
- // before, a breath between steps so a spin of the wheel doesn't skip
- // five. A trackpad's run of small deltas is one step per run (its first
- // 40px; the rest of the run, until a pause, is spent), and a sideways
- // run — or shift+wheel — walks along the post's pictures. Ctrl+wheel (a
+ // before, and a spin of notches is a flight past that many (pagerFly),
+ // landing snapped on a post. A trackpad's run of small deltas is one
+ // step per run (its first 40px; the rest of the run, until a pause, is
+ // spent), and a sideways run — or shift+wheel — walks along the post's
+ // pictures, a breath between steps. Ctrl+wheel (a
// trackpad's pinch comes the same way) zooms the picture and the wheel
// pans it while zoomed: attachZoom takes those first (defaultPrevented).
// Left to the browser, a notch scrolled the snap container a hundred
@@ -12535,6 +12578,7 @@ export function messagesFeature(ctx) {
if (!d) return;
const notch = e.deltaMode === 1 || (hz ? e.wheelDeltaX && e.wheelDeltaX % 120 === 0 : e.wheelDeltaY && e.wheelDeltaY % 120 === 0);
const now = e.timeStamp;
+ if (notch && !hz) { pagerFly(Math.sign(d), now); return; } // up and down the posts: a notch, or a spin, flies
if (notch) {
if (now - wheelStepAt < 220) return;
} else {
@@ -12692,24 +12736,30 @@ export function messagesFeature(ctx) {
if (pager.kind === 'images') for (const x of e.target.closest('.mp-group').querySelectorAll('.mp-slide')) pagerAttach(x);
}
}, { root: scroller, rootMargin: '150% 0px' });
+ // a slide has come to rest on screen (or a flight has landed on it)
+ pager.arrive = (slide) => {
+ if (!pager) return;
+ if (pager.cur && pager.cur !== slide) {
+ const v = pager.cur.querySelector('video.mp-media'); if (v) v.pause();
+ // how long a clip held the reader (the head start's measure of them)
+ if (v && pager.curAt) { const secs = (Date.now() - pager.curAt) / 1000; dwell = dwell ? dwell * 0.7 + secs * 0.3 : secs; }
+ if (pager.fromFeed && video) el.classList.remove('show');
+ }
+ pager.cur = slide;
+ pager.curAt = Date.now();
+ pagerArrows(slide);
+ if (pager.fromFeed) { const row = feedNow().notes.find((x) => x.id === slide._rowId); markSeen(row || slide._ev); }
+ playSlide(slide);
+ pagerCounts(slide);
+ // near the end: ask the feed for more, the pager picks them up
+ if (pager.fromFeed) pagerFeedAhead();
+ else { const gs = pagerGroups(); if (gs.indexOf(slide.closest('.mp-group')) >= gs.length - 3) crawlMedia().catch(() => {}); }
+ };
pager.io = new IntersectionObserver((entries) => {
for (const e of entries) {
if (!e.isIntersecting || e.intersectionRatio < 0.6 || !pager) continue;
- if (pager.cur && pager.cur !== e.target) {
- const v = pager.cur.querySelector('video.mp-media'); if (v) v.pause();
- // how long a clip held the reader (the head start's measure of them)
- if (v && pager.curAt) { const secs = (Date.now() - pager.curAt) / 1000; dwell = dwell ? dwell * 0.7 + secs * 0.3 : secs; }
- if (pager.fromFeed && video) el.classList.remove('show');
- }
- pager.cur = e.target;
- pager.curAt = Date.now();
- pagerArrows(e.target);
- if (pager.fromFeed) { const row = feedNow().notes.find((x) => x.id === e.target._rowId); markSeen(row || e.target._ev); }
- playSlide(e.target);
- pagerCounts(e.target);
- // near the end: ask the feed for more, the pager picks them up
- if (pager.fromFeed) pagerFeedAhead();
- else { const gs = pagerGroups(); if (gs.indexOf(e.target.closest('.mp-group')) >= gs.length - 3) crawlMedia().catch(() => {}); }
+ if (pager.flying) continue; // passing, not arriving: the landing is pagerFly's
+ pager.arrive(e.target);
}
}, { root: scroller, threshold: [0.6] });
growPager();
diff --git a/tools/pager-wheel-test.js b/tools/pager-wheel-test.js
index b73171b..1828f77 100644
--- a/tools/pager-wheel-test.js
+++ b/tools/pager-wheel-test.js
@@ -13,7 +13,7 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
let ok = true;
const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
const png = (size) => Bun.spawnSync(['ffmpeg', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=' + size, '-frames:v', '1', '-f', 'image2', '-c:v', 'png', 'pipe:1']).stdout;
-const PICS = { a1: png('400x300'), a2: png('300x400'), b: png('320x320'), d: png('360x240') };
+const PICS = { a1: png('400x300'), a2: png('300x400'), b: png('320x320'), d: png('360x240'), e: png('240x180') };
const SK = generateSecretKey(), AUTHOR = getPublicKey(SK);
const now = Math.floor(Date.now() / 1000);
@@ -22,7 +22,7 @@ const html = await buildHtml({ minify: true, pwa: false });
const server = Bun.serve({ port: 0, fetch: (req) => {
const path = new URL(req.url).pathname;
if (path.startsWith('/punks')) return new Response(Bun.file('dist' + path));
- const m = /^\/(a1|a2|b|d)\.png$/.exec(path);
+ const m = /^\/(a1|a2|b|d|e)\d*\.png$/.exec(path);
if (m) return new Response(PICS[m[1]], { headers: { 'content-type': 'image/png' } });
return new Response(html, { headers: { 'content-type': 'text/html' } });
} });
@@ -32,7 +32,7 @@ const seeded = [
post('one picture ' + U('b'), 200),
post('only words here, nothing to look at', 300),
post('a last picture ' + U('d'), 400),
- ...Array.from({ length: 12 }, (_, i) => post('filler ' + i + ' ' + 'lorem ipsum dolor sit amet. '.repeat(5), 500 + i * 60)),
+ ...Array.from({ length: 12 }, (_, i) => post('more ' + i + ' ' + U('e' + i), 500 + i * 60)), // a dozen more, one picture each, for a flight
];
const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--blink-settings=primaryHoverType=2,availableHoverTypes=2,primaryPointerType=4,availablePointerTypes=4'] /* a pointer that hovers: headless has none */ });
@@ -113,10 +113,29 @@ try {
check('shift+wheel at the first: stays', (await curPic()) === 'a1.png', await curPic());
await wheel(100, { mods: 8 }); await resting('a2.png');
await sleep(300);
+ console.log('\n[a spin flies, and lands snapped]');
+ // posts with pictures, in order: a (two), b, d, e0 … e11 — a spin of five
+ // notches is 1 + 1 + 2 + 2 + 2 = 8 posts on from a: e5
for (let i = 0; i < 5; i++) { await wheel(100); await sleep(10); }
- await sleep(700);
- check('a spin of five notches in a blink is a step or two, not five', ['b.png', 'd.png'].includes(await curPic()), await curPic());
- await wheel(-100); await resting('b.png'); await wheel(-100); await resting('a2.png'); await wheel(100); await resting('b.png');
+ await sleep(250);
+ const mid = await pos();
+ check('mid-flight: between posts, gliding', /glide:true/.test(mid) && (await curPic()) === 'between', mid);
+ check('a spin of five notches flies eight posts on and lands snapped', await resting('e5.png'), await curPic() + ' ' + await pos());
+ check('the landing is arrived at: the sideways arrows know the post', await page.evaluate(() => !document.querySelector('.media-pager').classList.contains('has-right')));
+ for (let i = 0; i < 5; i++) { await wheel(-100); await sleep(10); }
+ check('a spin back flies back the same way', await resting('a2.png'), await curPic() + ' ' + await pos());
+ // a notch against a flight catches it on the post it is passing
+ for (let i = 0; i < 5; i++) { await wheel(100); await sleep(10); }
+ await sleep(200);
+ await wheel(-100);
+ await sleep(900);
+ const caught = await curPic();
+ check('a notch back mid-flight catches it, snapped on a post short of the target', caught !== 'between' && !['e5.png', 'a1.png', 'a2.png'].includes(caught), caught + ' ' + await pos());
+ await sleep(300);
+ await wheel(-100); await resting(caught === 'b.png' ? 'a2.png' : caught === 'd.png' ? 'b.png' : caught === 'e0.png' ? 'd.png' : 'e' + (Number(caught[1]) - 1) + '.png');
+ // back to b for what follows
+ while ((await curPic()) !== 'b.png') { await wheel((await curPic()) === 'a2.png' || (await curPic()) === 'a1.png' ? 100 : -100); await sleep(500); }
+ await sleep(300);
console.log('\n[a trackpad\'s run is one step]');
await sleep(300);