On a phone the reader is fixed over the item list, and the swipe from #49 moved it alone and faded it to 40%, so the list showed through it and in the strip it uncovered, and again as the next item slid in from the far side. Now a layer beside the reader, #dpeek, holds what is really there: the next or previous item, drawn by the same detailHtml the reader uses, with 16px of the page's background between them; "No more items" past the last; or, swiping right from the first, a dimmer over the list that lifts as the reader, shadowed along its edge, is drawn off. On release the swipe carries on from where the finger left it, over 120-250ms by how far is left, and the neighbour becomes the reader in place; with reduced motion it switches at once. A touch starting within 14px of the left edge is kept from Safari, which otherwise takes it as Back and leaves the page mid-swipe. 14px because the back button starts at 16. Offsets are rounded to whole pixels: at a fractional offset the seam between the reader and its neighbour drew a stray light line. The design had the list shift a third of the way across as it is uncovered, as iOS does. #detail lives inside #shell, and a transform there makes it the containing block for the fixed reader, so that part is left out; the dimmer and shadow carry it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
159 lines
8.3 KiB
TypeScript
159 lines
8.3 KiB
TypeScript
/* ---------------- 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
|
|
// 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);
|
|
const GAP = 16; // px of page between two items side by side, so they read as two sheets
|
|
const narrow = () => !!window.matchMedia?.('(max-width:820px)')?.matches;
|
|
const still = () => !!window.matchMedia?.('(prefers-reduced-motion:reduce)')?.matches;
|
|
|
|
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;
|
|
// 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};
|
|
// Safari takes a swipe from the screen's left edge as Back, which leaves the page halfway
|
|
// through going back to the list. Kept narrower than the reader's padding, so a tap on the
|
|
// back button is never swallowed with it.
|
|
if(t.clientX < 14 && narrow() && ev.cancelable) ev.preventDefault();
|
|
}
|
|
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: false});
|
|
|
|
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)){
|
|
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 / 3 : touch.dx, 0);
|
|
}
|
|
}, {passive: false});
|
|
|
|
/// On a phone, what lies beside the reader while it is dragged by dx: the item it will move on
|
|
/// to, a note that there are no more, or, going back to the list, a dimmer over the list. The
|
|
/// reader sits over the list there, and moving it alone showed the list through the gap it left
|
|
/// (issue #52). Kept outside #content, which every feed render rebuilds, and inert, since its
|
|
/// copy of the reader's markup repeats the reader's ids.
|
|
function beside(dx: number){
|
|
const i = S.entries.findIndex(x => x.guid === S.sel), e = S.entries[i + (dx < 0 ? 1 : -1)];
|
|
const kind = dx < 0 ? (e ? 'next' : 'end') : (e ? 'prev' : 'dim');
|
|
let p = $('#dpeek');
|
|
if(!p){
|
|
p = document.createElement('div'); p.id = 'dpeek'; p.inert = true; p.setAttribute('aria-hidden', 'true');
|
|
document.body.append(p);
|
|
}
|
|
if(p.dataset.key !== kind + (e?.guid || '')){
|
|
p.dataset.key = kind + (e?.guid || ''); p.className = kind;
|
|
p.innerHTML = e ? `<div>${detailHtml(e)}</div>` : kind === 'end' ? '<p>No more items</p>' : '';
|
|
}
|
|
return p;
|
|
}
|
|
|
|
/// Moves the reader across by dx over ms, or at once (when following a finger), and what lies
|
|
/// beside it with it.
|
|
function slide(dx: number, ms = 160){
|
|
const d = $('#detail'); if(!d) return;
|
|
dx = Math.round(dx); // whole pixels, or the seam between the reader and its neighbour blurs
|
|
const w = d.clientWidth || 1, tr = ms ? `transform ${ms}ms ease-out, opacity ${ms}ms ease-out` : 'none';
|
|
d.style.transition = tr;
|
|
d.style.transform = dx ? `translateX(${dx}px)` : '';
|
|
// A wider screen has the reader as a pane beside the list, with nothing under it to show.
|
|
if(!narrow()){ d.style.opacity = dx ? String(Math.max(.4, 1 - Math.abs(dx) / w)) : ''; return; }
|
|
const p = dx ? beside(dx) : $('#dpeek');
|
|
d.classList.toggle('popping', !!dx && p?.className === 'dim');
|
|
if(!p) return;
|
|
p.style.transition = tr;
|
|
if(p.className === 'dim') p.style.opacity = String(.35 * (1 - Math.abs(dx) / w));
|
|
else p.style.transform = `translateX(${dx + (p.className === 'prev' ? -w - GAP : w)}px)`;
|
|
// Gone once the reader is back, unless another drag has already begun.
|
|
if(!dx){ if(ms) setTimeout(() => { if(!touch) $('#dpeek')?.remove(); }, ms); else p.remove(); }
|
|
}
|
|
|
|
/// The reader carries on the way the finger went, from wherever it was let go, and the item
|
|
/// beside it becomes the reader in its place. Timers, not transitionend, because with reduced
|
|
/// motion the stylesheet turns transitions off and transitionend never comes.
|
|
function slideOn(dx: number, go: () => void){
|
|
const d = $('#detail'), w = d?.clientWidth || 0, dir = Math.sign(dx);
|
|
if(!narrow()){
|
|
// A pane beside the list: off one side, and the next item in from the other.
|
|
slide(dir * w);
|
|
setTimeout(() => { go(); slide(-dir * w, 0); requestAnimationFrame(() => requestAnimationFrame(() => slide(0))); }, 160);
|
|
return;
|
|
}
|
|
const to = dir * (w + (beside(dx).className === 'dim' ? 0 : GAP));
|
|
const ms = still() ? 0 : Math.round(Math.max(120, 250 * (1 - Math.abs(dx) / (w || 1))));
|
|
slide(to, ms);
|
|
setTimeout(() => { go(); slide(0, 0); }, ms);
|
|
}
|
|
|
|
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.
|
|
const i = S.entries.findIndex(x => x.guid === S.sel);
|
|
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(g.dx, () => stepEntry(1));
|
|
if(i > 0) return slideOn(g.dx, () => stepEntry(-1));
|
|
slideOn(g.dx, () => showDetail(null));
|
|
});
|
|
document.addEventListener('touchcancel', () => { if(touch?.pane === 'list') pullShow(0); else slide(0); touch = null; });
|