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 <noreply@anthropic.com>
This commit is contained in:
2026-09-25 13:04:30 +00:00
parent 2930be37ad
commit 868dd673f3
4 changed files with 43 additions and 7 deletions

View File

@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [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 ## [0.8.4] - 2026-09-19
### Added ### Added

View File

@@ -1231,6 +1231,9 @@ test.describe('touch gestures on a phone', () => {
const shown = page.locator('#detail .dt'); const shown = page.locator('#detail .dt');
await expect(shown).toHaveText(titles[0]); 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 drag(page, { x: 300, y: 400 }, { x: 80, y: 410 }); // left: the next item
await expect(shown).toHaveText(titles[1]); await expect(shown).toHaveText(titles[1]);
await drag(page, { x: 300, y: 400 }, { x: 80, y: 410 }); // left on the last: stays await drag(page, { x: 300, y: 400 }, { x: 80, y: 410 }); // left on the last: stays

View File

@@ -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. */ /* Three panes, as the original had: feeds beside, items above, the item below. */
#split{ #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-columns:minmax(0,1fr) 270px;
grid-template-rows:minmax(90px,var(--listh,60%)) 7px 1fr; grid-template-rows:minmax(90px,var(--listh,60%)) 7px 1fr;
grid-template-areas:"list files" "grab grab" "detail detail"; grid-template-areas:"list files" "grab grab" "detail detail";

View File

@@ -5,7 +5,9 @@
// the document because the panes are rebuilt every time a feed renders. // 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 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; 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); pullShow(touch.dy);
if(ev.cancelable) ev.preventDefault(); // or the list rubber-bands under the finger as well 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)){ }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 // 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}); }, {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', () => { document.addEventListener('touchend', () => {
const g = touch; touch = null; const g = touch; touch = null;
if(!g) return; if(!g) return;
@@ -74,9 +99,11 @@ document.addEventListener('touchend', () => {
return; return;
} }
// Across, mostly sideways, and not a slow drag while selecting text. // 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); const i = S.entries.findIndex(x => x.guid === S.sel);
if(g.dx < 0){ if(i < S.entries.length - 1) stepEntry(1); return; } if(Math.abs(g.dx) < swipeMin() || Math.abs(g.dx) < 2 * Math.abs(g.dy) || Date.now() - g.t > 800
if(i > 0) stepEntry(-1); else showDetail(null); || (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; });