Touch gestures: pull to check for new items, swipe between items

- Pull the item list down from its top: checks the feed (or every feed, on All
  Subscriptions) for new items, which arrive as they do from the scan button.
  overscroll-behavior keeps the browser's own pull-to-reload out of it.
- Swipe the item you are reading left for the next, right for the one before,
  or back to the list from the first. A vertical move is a scroll; something
  that scrolls sideways, or takes typing, keeps its own swipe.

Touch events only, so a mouse never sets them off.

Closes #22.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-18 14:13:28 +00:00
parent fc09e8a6b7
commit 3b00e2721a
5 changed files with 140 additions and 2 deletions

83
web/src/gestures.ts Normal file
View File

@@ -0,0 +1,83 @@
/* ---------------- touch gestures ---------------- */
// On a touch screen: pull the item list down from its top to check the feed for new items, and
// swipe the item you are reading left for the next one, right for the one before, or back to
// the list from the first. Touch events only, so a mouse never sets these off. Listened for on
// 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
let touch: {x: number, y: number, t: number, pane: 'list' | 'detail', dx: number, dy: number} | null = null;
/// Something that scrolls sideways, or takes typing, keeps its own gestures: a wide code block
/// or table in a post, the player's seek bar, a text box.
function ownsSwipe(el: Element | null){
for(let n = el; n && n.id !== 'detail'; n = n.parentElement){
if(/^(INPUT|TEXTAREA|SELECT|AUDIO|VIDEO)$/.test(n.tagName)) return true;
if(n.scrollWidth > n.clientWidth + 1 && /(auto|scroll)/.test(getComputedStyle(n).overflowX)) return true;
}
return false;
}
/// How far the list has been pulled: a note at its top, growing with the pull and pushing the
/// items down, that says what letting go will do.
function pullShow(dy: number){
const list = $('#list'); if(!list) return;
const d = Math.round(Math.min(dy, PULL * 1.6) / 2);
let tip = $('#pulltip');
if(!d){ tip?.remove(); return; }
if(!tip){ tip = document.createElement('div'); tip.id = 'pulltip'; list.prepend(tip); }
tip.style.height = d + 'px';
tip.textContent = dy >= PULL ? 'Release to check for new items' : 'Pull to check for new items';
}
function refreshFeed(){
const f = S.feeds.find(x => x.id === S.feed);
if(!f && S.feed !== ':all') return;
toast(f ? `Checking ${f.title || f.id} for new items…` : 'Checking every feed for new items…');
// New items arrive by the event stream when the scan finishes, as they do for a button press.
api('/api/fetch', {method: 'POST', body: JSON.stringify(f ? {feed: f.id, force: true} : {force: true})})
.catch(e => toast(e.message, true));
loadEntries();
}
document.addEventListener('touchstart', ev => {
touch = null;
if(ev.touches.length !== 1 || $('#modal')?.classList.contains('on')) return;
const t = ev.touches[0], target = ev.target as Element;
const detail = target.closest?.('#detail'), list = target.closest?.('#list');
// Reading means an item is open; the reader is its own screen on a phone, a pane otherwise.
if(detail && S.sel && !ownsSwipe(target)) touch = {x: t.clientX, y: t.clientY, t: Date.now(), pane: 'detail', dx: 0, dy: 0};
else if(list && list.scrollTop <= 0 && !VIEWS[S.feed]?.url) touch = {x: t.clientX, y: t.clientY, t: Date.now(), pane: 'list', dx: 0, dy: 0};
}, {passive: true});
document.addEventListener('touchmove', ev => {
if(!touch) return;
const t = ev.touches[0];
touch.dx = t.clientX - touch.x; touch.dy = t.clientY - touch.y;
if(touch.pane === 'list'){
// Only a pull that starts at the top and goes down; anything else is an ordinary scroll.
if(touch.dy < 0 || $('#list').scrollTop > 0){ pullShow(0); touch = null; return; }
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
// decide nothing, being mostly jitter
}
}, {passive: false});
document.addEventListener('touchend', () => {
const g = touch; touch = null;
if(!g) return;
if(g.pane === 'list'){
pullShow(0);
if(g.dy >= PULL) refreshFeed();
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);
});
document.addEventListener('touchcancel', () => { if(touch?.pane === 'list') pullShow(0); touch = null; });