diff options
| author | Adam Soltys <[email protected]> | 2026-10-09 00:30:22 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-09 00:30:22 -0700 |
| commit | 62edcc721bf762cddad73ade54d8b580913051c7 (patch) | |
| tree | bda08449dfc488724b6736b49c85180c0835ce16 /src | |
| parent | b6819d91422adc10ea71ea5174c8e3e2badd1340 (diff) | |
| download | coinosv3-62edcc721bf762cddad73ade54d8b580913051c7.tar.gz coinosv3-62edcc721bf762cddad73ade54d8b580913051c7.zip | |
Desktop feed scrolls like a phone: a mouse-wheel notch coasts and slows (quick turns stack, a reverse turn brakes, trackpads left alone), and a mouse can grab the feed and flick it (a sideways drag still selects text, a drag doesn't click what it started on)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Diffstat (limited to 'src')
| -rw-r--r-- | src/app.js | 97 | ||||
| -rw-r--r-- | src/style.css | 2 |
2 files changed, 99 insertions, 0 deletions
@@ -6378,6 +6378,103 @@ if (INTENT_URI) { document.addEventListener('pointercancel', () => { if (drag) drag.el.classList.remove('dragging'); drag = null; }); })(); +// The feed scrolls like a phone on a desktop. A mouse wheel's notch gives +// it a push that coasts and slows, so a few turns travel a few posts and +// quick turns stack up; a trackpad (which already coasts) is left alone. +// And a mouse can grab the feed and flick it: drag up or down, let go while +// moving and it keeps going. A drag that starts sideways is still a text +// selection, and a drag that moved isn't a click on what it started on. +(() => { + if (typeof window === 'undefined' || matchMedia('(prefers-reduced-motion: reduce)').matches) return; + const SCOPE = '.feed-page, .notes-feed'; + const TAU = 325; // ms: how quickly a coast dies down (the phone's feel) + const VMAX = 9; // px/ms + const inScope = (t) => { + const el = t && t.closest ? t : t && t.parentElement; + if (!el || !el.closest(SCOPE)) return false; + if (el.closest('input, textarea, select, video, audio, [contenteditable], .feed-chips, .lightbox, .media-pager')) return false; + // something inside that scrolls on its own (a caption, a list) keeps its wheel + for (let n = el; n && n !== document.body; n = n.parentElement) { + if (n.scrollHeight > n.clientHeight + 1 && /(auto|scroll)/.test(getComputedStyle(n).overflowY)) return false; + } + return true; + }; + let v = 0, acc = 0, raf = 0, last = 0; + const stop = () => { v = 0; acc = 0; if (raf) cancelAnimationFrame(raf); raf = 0; }; + const tick = (now) => { + const dt = Math.min(40, now - last); last = now; + acc += v * dt; + const step = Math.trunc(acc); + if (step) { + const y = window.scrollY; + window.scrollBy({ top: step, behavior: 'instant' }); + acc -= step; + if (window.scrollY === y) return stop(); // an end of the page + } + v *= Math.exp(-dt / TAU); + if (Math.abs(v) < 0.02) return stop(); + raf = requestAnimationFrame(tick); + }; + const coast = (nv) => { + v = Math.max(-VMAX, Math.min(VMAX, nv)); + if (!raf && v) { last = performance.now(); raf = requestAnimationFrame(tick); } + }; + // ---- the wheel + let smoothUntil = 0; // a trackpad's events were just seen: hands off + window.addEventListener('wheel', (e) => { + if (e.ctrlKey || e.shiftKey || e.defaultPrevented) return; + const notch = e.deltaMode === 1 || (e.wheelDeltaY && e.wheelDeltaY % 120 === 0 && !e.deltaX); + if (!notch) { smoothUntil = e.timeStamp + 400; stop(); return; } + if (e.timeStamp < smoothUntil || matchMedia('(pointer: coarse)').matches || !inScope(e.target)) return; + e.preventDefault(); + const px = e.deltaMode === 1 ? e.deltaY * 40 : e.deltaY; + // reverse turns brake first; same-way turns add to the coast already going + const keep = Math.sign(v) === Math.sign(px) ? v : 0; + coast(keep + (px * 2.4) / TAU); + }, { passive: false }); + // anything else that moves the page takes over from a coast + window.addEventListener('keydown', (e) => { if (/^(Arrow|Page|Home|End| )/.test(e.key)) stop(); }); + window.addEventListener('touchstart', stop, { passive: true }); + // ---- grab and flick + let drag = null; + document.addEventListener('pointerdown', (e) => { + if (raf) stop(); // a click catches a coasting page, like a finger does + if (e.pointerType !== 'mouse' || e.button !== 0 || !inScope(e.target)) return; + drag = { x: e.clientX, y: e.clientY, ly: e.clientY, on: false, pts: [] }; + }); + document.addEventListener('dragstart', (e) => { if (drag) e.preventDefault(); }, true); // images/links: scroll, don't drag them out + document.addEventListener('pointermove', (e) => { + if (!drag) return; + if (!(e.buttons & 1)) { drag = null; return; } + if (!drag.on) { + const dx = Math.abs(e.clientX - drag.x), dy = Math.abs(e.clientY - drag.y); + if (dx < 6 && dy < 6) return; + if (dx > dy) { drag = null; return; } // sideways: selecting text + drag.on = true; + document.documentElement.classList.add('grab-scrolling'); + try { getSelection().removeAllRanges(); } catch {} + } + window.scrollBy({ top: drag.ly - e.clientY, behavior: 'instant' }); + drag.ly = e.clientY; + drag.pts.push([e.timeStamp, e.clientY]); + while (drag.pts.length > 2 && e.timeStamp - drag.pts[0][0] > 100) drag.pts.shift(); + }); + const end = (e) => { + if (!drag) return; + const d = drag; drag = null; + if (!d.on) return; + document.documentElement.classList.remove('grab-scrolling'); + const p = d.pts, a = p[0], b = p[p.length - 1]; + // released while moving: throw it; held still first: just let go + if (a && b && b[0] > a[0] && e.timeStamp - b[0] < 60) coast((a[1] - b[1]) / (b[0] - a[0]) * 1.2); + const eat = (ev) => { ev.stopPropagation(); ev.preventDefault(); }; + document.addEventListener('click', eat, { capture: true, once: true }); + setTimeout(() => document.removeEventListener('click', eat, { capture: true }), 0); + }; + document.addEventListener('pointerup', end); + document.addEventListener('pointercancel', () => { if (drag?.on) document.documentElement.classList.remove('grab-scrolling'); drag = null; }); +})(); + window.addEventListener('load', () => { if (!pendingMigratedName()) return; const started = Date.now(); diff --git a/src/style.css b/src/style.css index 422e51a..e3f55c8 100644 --- a/src/style.css +++ b/src/style.css @@ -712,6 +712,8 @@ html:has(#app .chat-card), body:has(#app .chat-card) { overflow: hidden; } .feed-chips { gap: 6px; flex-wrap: nowrap; overflow-x: auto; margin: 0 -14px; padding: 2px 14px; scrollbar-width: none; -webkit-overflow-scrolling: touch; } .feed-chips::-webkit-scrollbar { display: none; } /* with a mouse: a thin bar says the strip scrolls, and it drags (app.js) */ +/* grabbing the feed to scroll it (app.js) */ +.grab-scrolling, .grab-scrolling * { cursor: grabbing !important; user-select: none !important; } @media (hover: hover) and (pointer: fine) { .feed-chips { scrollbar-width: thin; scrollbar-color: var(--line-strong) transparent; padding-bottom: 6px; cursor: grab; } .feed-chips::-webkit-scrollbar { display: block; height: 6px; } |
