Swipe between items like turning pages (#52)

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>
This commit is contained in:
2026-09-26 23:46:07 +00:00
parent a573a07ed5
commit f2fde8cc75
5 changed files with 113 additions and 28 deletions

View File

@@ -8,6 +8,9 @@ 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;
@@ -48,9 +51,15 @@ document.addEventListener('touchstart', ev => {
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};
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: true});
}, {passive: false});
document.addEventListener('touchmove', ev => {
if(!touch) return;
@@ -69,25 +78,65 @@ document.addEventListener('touchmove', ev => {
// 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);
slide(end ? touch.dx / 3 : touch.dx, 0);
}
}, {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))) : '';
/// 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;
}
/// 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);
/// 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', () => {
@@ -102,8 +151,8 @@ document.addEventListener('touchend', () => {
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(-1, () => stepEntry(1));
if(i > 0) return slideOn(1, () => stepEntry(-1));
slide(0); showDetail(null);
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; });

View File

@@ -230,6 +230,19 @@ function showDetail(e){
}
// Nothing when there are no files: the pane that would say so is hidden above, and on a phone,
// where the files sit over the text, a box saying "No files" only pushed the text down.
const encs=e.enclosures.map(encBox).join('');
const narrow=!!window.matchMedia?.('(max-width:820px)')?.matches;
box.innerHTML=detailHtml(e);
if(files) files.innerHTML=narrow?'':`<div class="fhd">Files</div>${encs}`;
$('#dback').onclick=()=>showDetail(null);
for(const root of [box,files]) if(root) $$('button[data-a]',root).forEach(b=>
b.onclick=()=>epAction(b.dataset.a,e,null,b.dataset.enc?Number(b.dataset.enc):null));
syncPlayButtons();
}
/// What the reader shows for an item. Its own function because a phone also draws the next or
/// previous item beside the reader while it is swiped (gestures.ts).
function detailHtml(e){
const encs=e.enclosures.map(encBox).join('');
// A phone has no room for the files pane, so the files go above the text there instead.
// Below it, a long set of show notes pushed play and delete screens down, and on an iPhone
@@ -237,7 +250,8 @@ function showDetail(e){
const narrow=!!window.matchMedia?.('(max-width:820px)')?.matches;
const f=S.feeds.find(x=>x.id===e.feed_id);
const num=[e.season?`S${e.season}`:'',e.episode?`E${e.episode}`:''].filter(Boolean).join('');
box.innerHTML=`
// description was sanitized server-side with ammonia before it ever reached here
return `
<button class="btn ico" id="dback" title="Back to the items" aria-label="Back to the items">${ICON.left}</button>
<h3 class="dt">${esc(e.title||'(untitled)')}</h3>
<div class="dmeta">
@@ -254,12 +268,6 @@ function showDetail(e){
</div>
${narrow?encs:''}
<div class="dbody">${(e.description&&e.description.trim())||'<em>No show notes.</em>'}</div>`;
if(files) files.innerHTML=narrow?'':`<div class="fhd">Files</div>${encs}`;
// description was sanitized server-side with ammonia before it ever reached here
$('#dback').onclick=()=>showDetail(null);
for(const root of [box,files]) if(root) $$('button[data-a]',root).forEach(b=>
b.onclick=()=>epAction(b.dataset.a,e,null,b.dataset.enc?Number(b.dataset.enc):null));
syncPlayButtons();
}
/// One enclosure: a play button when the file is here, otherwise what it is and a way to get it.