aboutsummaryrefslogtreecommitdiffstats
path: root/src/features/messages.js
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 /src/features/messages.js
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
Diffstat (limited to 'src/features/messages.js')
-rw-r--r--src/features/messages.js98
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();