From 868dd673f392f3a48d3798562d6ffbd1c9f71218 Mon Sep 17 00:00:00 2001 From: rays Date: Fri, 25 Sep 2026 13:04:30 +0000 Subject: [PATCH] Swipes take a longer drag and slide the reader across (#49) At a flat 60px, a thumb scrolling slightly on the diagonal moved on to the next item by accident. A swipe now has to cover a quarter of the reader's width, and never less than 100px, and the reader follows the finger while it is down, so it is plain before letting go whether it will move on. It slides off on a swipe and the next item slides in from the other side; a short one springs back. The CHANGELOG also gets back the [0.8.3] link a stray edit had broken. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 ++++++ tests/ui/app.spec.js | 3 +++ web/app.css | 2 +- web/src/gestures.ts | 39 +++++++++++++++++++++++++++++++++------ 4 files changed, 43 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d166fd5..46e7518 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Swiping to the next or previous item takes a longer swipe, a quarter of the screen, so a + diagonal scroll no longer jumps ahead by accident. The item follows your finger and slides + across as it changes. + ## [0.8.4] - 2026-09-19 ### Added diff --git a/tests/ui/app.spec.js b/tests/ui/app.spec.js index 6f9618b..3d905f3 100644 --- a/tests/ui/app.spec.js +++ b/tests/ui/app.spec.js @@ -1231,6 +1231,9 @@ test.describe('touch gestures on a phone', () => { const shown = page.locator('#detail .dt'); await expect(shown).toHaveText(titles[0]); + await drag(page, { x: 300, y: 400 }, { x: 230, y: 410 }); // too short: stays (#49) + await expect(shown).toHaveText(titles[0]); + await expect(page.locator('#detail')).not.toHaveAttribute('style', /translate/); // springs back await drag(page, { x: 300, y: 400 }, { x: 80, y: 410 }); // left: the next item await expect(shown).toHaveText(titles[1]); await drag(page, { x: 300, y: 400 }, { x: 80, y: 410 }); // left on the last: stays diff --git a/web/app.css b/web/app.css index c782386..b88e460 100644 --- a/web/app.css +++ b/web/app.css @@ -674,7 +674,7 @@ input:focus,select:focus{outline:0;border-color:var(--accent)} /* Three panes, as the original had: feeds beside, items above, the item below. */ #split{ - display:grid;flex:1;min-height:0; + display:grid;flex:1;min-height:0;overflow:hidden; /* the reader sliding on a swipe */ grid-template-columns:minmax(0,1fr) 270px; grid-template-rows:minmax(90px,var(--listh,60%)) 7px 1fr; grid-template-areas:"list files" "grab grab" "detail detail"; diff --git a/web/src/gestures.ts b/web/src/gestures.ts index bece9ec..3cd1150 100644 --- a/web/src/gestures.ts +++ b/web/src/gestures.ts @@ -5,7 +5,9 @@ // the document because the panes are rebuilt every time a feed renders. const PULL = 70; // px down, from the list's top, that counts as a pull -const SWIPE = 60; // px across that counts as a swipe +// A quarter of the reader's width across, and never less than 100px: at a flat 60px a thumb +// scrolling slightly on the diagonal jumped to the next item by accident (issue #49). +const swipeMin = () => Math.max(100, ($('#detail')?.clientWidth || 0) / 4); let touch: {x: number, y: number, t: number, pane: 'list' | 'detail', dx: number, dy: number} | null = null; @@ -60,11 +62,34 @@ document.addEventListener('touchmove', ev => { pullShow(touch.dy); if(ev.cancelable) ev.preventDefault(); // or the list rubber-bands under the finger as well }else if(Math.abs(touch.dy) > 10 && Math.abs(touch.dy) > Math.abs(touch.dx)){ - touch = null; // scrolling the text, not swiping; the first few px + slide(0); touch = null; // scrolling the text, not swiping; the first few px // decide nothing, being mostly jitter + }else if(Math.abs(touch.dx) > 10){ + // The reader follows the finger, so it is plain before letting go whether this will move on; + // with nothing that way it only gives a little. + const i = S.entries.findIndex(x => x.guid === S.sel); + const end = touch.dx < 0 && i >= S.entries.length - 1; + slide(end ? touch.dx / 4 : touch.dx, false); } }, {passive: false}); +/// Moves the reader across by dx, sliding there unless told not to (when following a finger). +function slide(dx: number, animate = true){ + const d = $('#detail'); if(!d) return; + d.style.transition = animate ? 'transform .16s ease-out, opacity .16s ease-out' : 'none'; + d.style.transform = dx ? `translateX(${dx}px)` : ''; + d.style.opacity = dx ? String(Math.max(.4, 1 - Math.abs(dx) / (d.clientWidth || 1))) : ''; +} + +/// The reader slides off the way the finger went, the next item comes in from the other side. +/// Timers, not transitionend, because with reduced motion the stylesheet turns transitions off +/// and transitionend never comes. +function slideOn(dir: number, go: () => void){ + const w = $('#detail')?.clientWidth || 0; + slide(dir * w); + setTimeout(() => { go(); slide(-dir * w, false); requestAnimationFrame(() => requestAnimationFrame(() => slide(0))); }, 160); +} + document.addEventListener('touchend', () => { const g = touch; touch = null; if(!g) return; @@ -74,9 +99,11 @@ document.addEventListener('touchend', () => { return; } // Across, mostly sideways, and not a slow drag while selecting text. - if(Math.abs(g.dx) < SWIPE || Math.abs(g.dx) < 2 * Math.abs(g.dy) || Date.now() - g.t > 800) return; const i = S.entries.findIndex(x => x.guid === S.sel); - if(g.dx < 0){ if(i < S.entries.length - 1) stepEntry(1); return; } - if(i > 0) stepEntry(-1); else showDetail(null); + if(Math.abs(g.dx) < swipeMin() || Math.abs(g.dx) < 2 * Math.abs(g.dy) || Date.now() - g.t > 800 + || (g.dx < 0 && i >= S.entries.length - 1)) return slide(0); + if(g.dx < 0) return slideOn(-1, () => stepEntry(1)); + if(i > 0) return slideOn(1, () => stepEntry(-1)); + slide(0); showDetail(null); }); -document.addEventListener('touchcancel', () => { if(touch?.pane === 'list') pullShow(0); touch = null; }); +document.addEventListener('touchcancel', () => { if(touch?.pane === 'list') pullShow(0); else slide(0); touch = null; });