diff --git a/CHANGELOG.md b/CHANGELOG.md index ebf5a2a..0446d3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,9 @@ The long form, with what was wrong before and how it was found, is in Classic and Modern (the existing dark and light). Each that comes both ways has its own Light, Dark or Auto setting; Classic and Paper come one way only, so that setting is hidden for them. A theme chosen before this carries over. +- Touch gestures: 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 and right for the one before, or back + to the list from the first. ### Changed diff --git a/tests/ui/app.spec.js b/tests/ui/app.spec.js index 226bc33..fb640f1 100644 --- a/tests/ui/app.spec.js +++ b/tests/ui/app.spec.js @@ -1129,3 +1129,51 @@ test('a feed error is marked in the same column as the folder triangles', async expect(new Set(xs).size, JSON.stringify(xs)).toBe(1); await page.reload(); // put the real list back }); + +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) { + 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: [] }); + } + + test('a swipe moves between items, and right from the first goes back to the list', async ({ page }) => { + await page.locator('#burger').click(); + await page.locator('.feed', { hasText: 'Test Show' }).click(); + await page.locator('.tabs button', { hasText: 'All' }).first().click(); + await expect(page.locator('.ep').nth(1)).toBeVisible({ timeout: 20_000 }); + const titles = await page.locator('.ep .t').allTextContents(); + await page.locator('.ep').first().click(); + const shown = page.locator('#detail .dt'); + 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 + await expect(shown).toHaveText(titles[1]); + await drag(page, { x: 80, y: 400 }, { x: 300, y: 410 }); // right: the one before + await expect(shown).toHaveText(titles[0]); + await drag(page, { x: 200, y: 300 }, { x: 210, y: 600 }); // down: a scroll, not a swipe + await expect(shown).toHaveText(titles[0]); + await drag(page, { x: 80, y: 400 }, { x: 300, y: 410 }); // right on the first: the list + await expect(page.locator('body')).not.toHaveClass(/reading/); + }); + + test('pulling the list down from its top checks the feed for new items', async ({ page }) => { + await page.locator('#burger').click(); + await page.locator('.feed', { hasText: 'Test Show' }).click(); + await expect(page.locator('.ep').first()).toBeVisible({ timeout: 20_000 }); + const box = await page.locator('#list').boundingBox(); + const fetch = page.waitForRequest(r => r.url().endsWith('/api/fetch') && r.method() === 'POST'); + await drag(page, { x: 200, y: box.y + 20 }, { x: 200, y: box.y + 220 }); + expect((await fetch).postDataJSON()).toEqual({ feed: 'test-show', force: true }); + await expect(page.locator('#pulltip')).toHaveCount(0); // the note goes on letting go + }); +}); diff --git a/web/build.mjs b/web/build.mjs index 6d9fc82..6b94dc7 100644 --- a/web/build.mjs +++ b/web/build.mjs @@ -21,7 +21,7 @@ const here = path.dirname(fileURLToPath(import.meta.url)); // The files are one script, concatenated in this order, not modules: they share one top-level // scope, as the single inline script did, and code that runs at load needs what came before it. const PAGES = { - 'index.html': { script: 'app.js', src: ['util', 'theme', 'feeds', 'feedpage', 'items', 'player', 'dialogs', 'events'] }, + 'index.html': { script: 'app.js', src: ['util', 'theme', 'feeds', 'feedpage', 'items', 'player', 'dialogs', 'gestures', 'events'] }, 'login.html': { script: 'login.js', src: ['login'] }, }; diff --git a/web/index.html b/web/index.html index 629627c..28e670b 100644 --- a/web/index.html +++ b/web/index.html @@ -467,7 +467,11 @@ input:focus,select:focus{outline:0;border-color:var(--accent)} grid-template-rows:minmax(90px,var(--listh,60%)) 7px 1fr; grid-template-areas:"list files" "grab grab" "detail detail"; } -#list{grid-area:list;overflow:auto;padding:0 14px 10px} +#list{grid-area:list;overflow:auto;padding:0 14px 10px;overscroll-behavior-y:contain} +/* Pull-to-refresh's note (gestures.ts). overscroll-behavior keeps the browser's own pull, which + reloads the page, from starting on top of it. */ +#pulltip{display:flex;align-items:flex-end;justify-content:center;padding-bottom:6px;overflow:hidden; + font-size:12px;color:var(--faint)} /* The selected item's files, beside the list, as the original's Files pane was. */ #files{grid-area:files;overflow-y:auto;padding:8px 12px;border-left:1px solid var(--line);background:var(--panel)} /* Nothing selected, or an item with no files: the list takes the width. */ diff --git a/web/src/gestures.ts b/web/src/gestures.ts new file mode 100644 index 0000000..5a21bd0 --- /dev/null +++ b/web/src/gestures.ts @@ -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; });