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:
83
web/src/gestures.ts
Normal file
83
web/src/gestures.ts
Normal 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; });
|
||||
Reference in New Issue
Block a user