diff --git a/CHANGELOG.md b/CHANGELOG.md index 725ea43..30c7ad4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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. +- Swiping between items on a phone no longer shows the item list through the reader. The next + or previous item sits beside the one you are reading and comes across with it, the end of the + list says so, and swiping right from the first item draws the reader off the list, dimmed + until it is uncovered. A swipe from the screen's left edge stays with the page, rather than + Safari taking it as Back. ## [0.8.4] - 2026-09-19 diff --git a/tests/ui/app.spec.js b/tests/ui/app.spec.js index 5d1b722..f8dc334 100644 --- a/tests/ui/app.spec.js +++ b/tests/ui/app.spec.js @@ -1211,14 +1211,15 @@ test.describe('touch gestures on a phone', () => { test.use({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true }); // Playwright's touchscreen only taps; a drag goes through the DevTools protocol. - async function drag(page, from, to) { + async function drag(page, from, to, lift = true) { const cdp = await page.context().newCDPSession(page); const steps = 8; await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [from] }); for (let i = 1; i <= steps; i++) await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x: from.x + (to.x - from.x) * i / steps, y: from.y + (to.y - from.y) * i / steps }] }); - await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }); + if (lift) await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }); + return cdp; } test('a swipe moves between items, and right from the first goes back to the list', async ({ page }) => { @@ -1234,6 +1235,14 @@ test.describe('touch gestures on a phone', () => { 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 + // Held partway: the next item is beside the reader, not the list under it (#52), and + // let go short of a swipe, it goes again. + const held = await drag(page, { x: 300, y: 400 }, { x: 230, y: 405 }, false); + await expect(page.locator('#dpeek.next .dt')).toHaveText(titles[1]); + await expect(page.locator('#detail')).toHaveCSS('opacity', '1'); + await held.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }); + await expect(page.locator('#dpeek')).toHaveCount(0); + await expect(shown).toHaveText(titles[0]); await drag(page, { x: 300, y: 400 }, { x: 80, y: 410 }); // left: the next item await expect(shown).toHaveText(titles[1]); await drag(page, { x: 300, y: 400 }, { x: 80, y: 410 }); // left on the last: stays diff --git a/web/app.css b/web/app.css index a34f413..f5849c7 100644 --- a/web/app.css +++ b/web/app.css @@ -993,6 +993,18 @@ body:has(#player.on) #status{padding-bottom:3px} #detail{position:fixed;inset:0;z-index:38;display:none;border-top:0;padding:12px 16px 90px} body.reading #detail{display:block} #dback{display:inline-block;margin-bottom:10px} + /* Beside the reader while it is swiped (gestures.ts): the item next to it, with a strip of the + page's own background between them, so the list under the reader never shows (#52). */ + #dpeek{position:fixed;top:0;bottom:0;left:0;width:calc(100% + 16px);z-index:38;background:var(--bg);pointer-events:none} + #dpeek>div{width:calc(100% - 16px);height:100%;overflow:hidden;padding:12px 16px 90px;background:var(--panel)} + #dpeek.next>div{margin-left:16px} + #dpeek .encbox{margin:0 0 10px} + #dpeek.end{display:flex;align-items:center;padding-left:28px;color:var(--faint);font-size:13px} + #dpeek.end p{max-width:4.5em;margin:0} + /* Going back to the list from the first item: the list waits under a dimmer that lifts as the + reader, shadowed along its edge, is drawn off it. */ + #dpeek.dim{width:100%;z-index:37;background:#000} + #detail.popping{box-shadow:-12px 0 32px rgba(0,0,0,.28)} #content>.fhead,#content>.toolbar{padding-left:14px;padding-right:14px} #content>.fhead{padding-top:12px} @@ -1104,6 +1116,7 @@ body:has(#player.on) #status{padding-bottom:3px} :root[data-theme="glass"] #status, :root[data-theme="glass"] #files, :root[data-theme="glass"] #detail, +:root[data-theme="glass"] #dpeek>div, :root[data-theme="glass"] .card, :root[data-theme="glass"] .toast{ /* A sheen from the top-left corner, where the light comes from. It is a background layer rather @@ -1151,6 +1164,7 @@ body:has(#player.on) #status{padding-bottom:3px} :root[data-theme="glass"] #status, :root[data-theme="glass"] #files, :root[data-theme="glass"] #detail, + :root[data-theme="glass"] #dpeek>div, :root[data-theme="glass"] .card, :root[data-theme="glass"] .toast, :root[data-theme="glass"] .tgroup, diff --git a/web/src/gestures.ts b/web/src/gestures.ts index 3cd1150..a53fab3 100644 --- a/web/src/gestures.ts +++ b/web/src/gestures.ts @@ -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 ? `
${detailHtml(e)}
` : kind === 'end' ? '

No more items

' : ''; + } + 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; }); diff --git a/web/src/items.ts b/web/src/items.ts index ec6bb6b..c6ed23d 100644 --- a/web/src/items.ts +++ b/web/src/items.ts @@ -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?'':`
Files
${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 `

${esc(e.title||'(untitled)')}

@@ -254,12 +268,6 @@ function showDetail(e){
${narrow?encs:''}
${(e.description&&e.description.trim())||'No show notes.'}
`; - if(files) files.innerHTML=narrow?'':`
Files
${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.