diff options
| author | Adam Soltys <[email protected]> | 2026-10-14 22:21:25 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-14 22:21:25 -0700 |
| commit | ebc59e50cf86e8edfab21a5be9063e5d5eeab07b (patch) | |
| tree | efc4dc4b463fd93dab42c68e10232d3b8a162c60 /src/features/messages.js | |
| parent | 0355c21254d6d452fd4afbec28db3e124bc1d61a (diff) | |
| download | coinosv3-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
Diffstat (limited to 'src/features/messages.js')
| -rw-r--r-- | src/features/messages.js | 98 |
1 files changed, 74 insertions, 24 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(); |
