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:
@@ -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,
|
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.
|
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.
|
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
|
### Changed
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
expect(new Set(xs).size, JSON.stringify(xs)).toBe(1);
|
||||||
await page.reload(); // put the real list back
|
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
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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
|
// 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.
|
// scope, as the single inline script did, and code that runs at load needs what came before it.
|
||||||
const PAGES = {
|
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'] },
|
'login.html': { script: 'login.js', src: ['login'] },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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-rows:minmax(90px,var(--listh,60%)) 7px 1fr;
|
||||||
grid-template-areas:"list files" "grab grab" "detail detail";
|
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. */
|
/* 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)}
|
#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. */
|
/* Nothing selected, or an item with no files: the list takes the width. */
|
||||||
|
|||||||
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