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; });