From ebc59e50cf86e8edfab21a5be9063e5d5eeab07b Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Wed, 14 Oct 2026 22:21:25 -0700 Subject: 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. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01J1Kon8bxJoHRpJdSzshCkp --- src/features/messages.js | 98 +++++++++++++++++++++++++++++++++++------------ tools/pager-wheel-test.js | 31 ++++++++++++--- 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: '' }))))); // 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); -- cgit v1.2.3