diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bc335e..0b6b07c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ The long form, with what was wrong before and how it was found, is in ### Added +- Keyboard shortcuts after Feedly's: j and k through items, Shift-J and Shift-K through feeds, + g and a letter to go to a place, o to play, s to keep, and more. Press ? for the whole list. - Directory can be filtered to Podcasts or Blogs, and by each show's own iTunes category as a row of chips, the narrower one where a show gives two (Games, not Leisure). The two combine, and both filter in place. diff --git a/tests/page-smoke.js b/tests/page-smoke.js index 69edab3..d2a2a60 100644 --- a/tests/page-smoke.js +++ b/tests/page-smoke.js @@ -91,6 +91,7 @@ const drive = [ ['selectFeed (currently listening)', () => ctx.selectFeed(':listening')], ['selectFeed (all subscriptions)', () => ctx.selectFeed(':all')], ['logsModal', () => ctx.logsModal()], + ['keysModal', () => ctx.keysModal()], // `const S` is not reachable from here: top-level const/let do not become properties // of a vm context the way var and function declarations do. ['renderGroup', () => ctx.renderGroup(feed, [{ ...feed, id: 'child', group: 'f', orphaned: true }])], diff --git a/tests/ui/app.spec.js b/tests/ui/app.spec.js index 630a980..c2c43f4 100644 --- a/tests/ui/app.spec.js +++ b/tests/ui/app.spec.js @@ -918,3 +918,35 @@ test('someone the proxy signs in never sees the password page, and signs out thr await expect(proxied).toHaveURL(/\/signed-out-by-the-proxy$/); await ctx.close(); }); + +test('keys move through items and places, after Feedly', async ({ page }) => { + // Last in the file: selecting an item marks it read, which would change what later tests see. + await page.locator('.feed', { hasText: 'Test Show' }).click(); + const rows = page.locator('#eps .ep'); + await expect(rows.nth(1)).toBeVisible({ timeout: 20_000 }); + const sel = page.locator('#eps .ep.sel'); + const guid = i => rows.nth(i).getAttribute('data-guid'); + await page.keyboard.press('j'); + await expect(sel).toHaveAttribute('data-guid', await guid(0)); + await page.keyboard.press('j'); + await expect(sel).toHaveAttribute('data-guid', await guid(1)); + await page.keyboard.press('k'); + await expect(sel).toHaveAttribute('data-guid', await guid(0)); + + // g and a letter go somewhere; typed into a box, the same letters are only text. + await page.keyboard.press('g'); + await page.keyboard.press('d'); + await expect(page.locator('#count')).toContainText('Directory'); + await page.locator('#epSearch').focus(); + await page.keyboard.type('ga'); + await expect(page.locator('#count')).toContainText('Directory'); + await page.locator('#epSearch').fill(''); + await page.locator('#epSearch').blur(); + + await page.keyboard.press('?'); + await expect(page.locator('#modalCard')).toContainText('Keyboard shortcuts'); + await page.keyboard.press('Escape'); + await page.keyboard.press('g'); + await page.keyboard.press('a'); + await expect(page.locator('#count')).toContainText('All Subscriptions'); +}); diff --git a/web/index.html b/web/index.html index a284491..044c4ec 100644 --- a/web/index.html +++ b/web/index.html @@ -115,6 +115,11 @@ a{color:var(--accent)} /* ---------- shell ---------- */ #shell{display:grid;grid-template-columns:290px 1fr;min-height:0;min-width:0;overflow:hidden} +/* [ hides the feed list. A phone slides it over the page instead, so this is for wider screens. */ +@media (min-width:821px){ + body.nosb #shell{grid-template-columns:minmax(0,1fr)} + body.nosb #sidebar{display:none} +} #sidebar{ background:var(--panel);border-right:1px solid var(--line); display:flex;flex-direction:column;min-height:0; @@ -302,6 +307,12 @@ a.btn{text-decoration:none;color:inherit} .btn.ico{padding:4px 9px;min-width:32px;font-size:14px;line-height:1.25;text-align:center} /* Inline in a sentence, next to a plain-word error explanation. */ .btn.tiny{padding:2px 7px;font-size:11.5px;border-radius:6px;margin-left:2px} +/* The keys ? lists. */ +.keys{border-collapse:collapse;width:100%;font-size:13px} +.keys th{text-align:left;font-weight:600;padding:12px 0 4px;color:var(--fg)} +.keys td{padding:3px 0;color:var(--dim)} +.keys td:first-child{width:10em;white-space:nowrap} +kbd{font:inherit;font-size:12px;color:var(--fg);background:var(--panel2);border:1px solid var(--line);border-radius:4px;padding:0 5px} /* An icon (Font Awesome, embedded as SVG) in the button's own colour. */ .i{display:inline-block;width:16px;height:16px;vertical-align:-3px;flex:none;fill:currentColor} /* A file's type as an icon, in place of the old DOWNLOADED / PENDING / audio chips: green once @@ -1535,8 +1546,73 @@ document.addEventListener('keydown',ev=>{ else if(ev.key==='ArrowLeft'&&player.guid){audio.currentTime-=15} else if(ev.key==='ArrowRight'&&player.guid){audio.currentTime+=30} else if(ev.key==='/'){ev.preventDefault();$('#epSearch')?.focus()} + else if(!ev.ctrlKey&&!ev.metaKey&&!ev.altKey&&!$('#modal').classList.contains('on')) typed(ev); }); +// Feedly's keys, vim's j and k among them: a letter to move through items or feeds, g and a +// letter to go somewhere, ? to list them. None fire with Ctrl, Alt or Cmd held, so the browser's +// own shortcuts still work, or while a dialog is open. +const GO={a:':all',d:':directory',p:':popular',l:':listening'}; +let gAt=0; +function stepEntry(by){ + if(VIEWS[S.feed]?.url||!S.entries.length) return; + const i=S.entries.findIndex(x=>x.guid===S.sel); + const e=S.entries[i<0?0:Math.min(S.entries.length-1,Math.max(0,i+by))]; + selectEntry(e); + $(`#eps .ep[data-guid="${CSS.escape(e.guid)}"]`)?.scrollIntoView({block:'nearest'}); +} +function stepFeed(by){ + const rows=$$('#feedlist [data-id]'), i=rows.findIndex(r=>r.dataset.id===S.feed), id=rows[i+by]?.dataset.id; + if(!id) return; + selectFeed(id); + $(`#feedlist [data-id="${CSS.escape(id)}"]`)?.scrollIntoView({block:'nearest'}); +} +const KEYS={ + j:()=>stepEntry(1), n:()=>stepEntry(1), k:()=>stepEntry(-1), p:()=>stepEntry(-1), + J:()=>stepFeed(1), K:()=>stepFeed(-1), + // The toolbar's own buttons, so a key does exactly what the click does, and nothing while + // they are disabled. + o:()=>$('#tbPlay').click(), m:()=>$('#tbRead').click(), s:()=>$('#tbFlag').click(), + v:()=>{ const e=cur(); if(e&&e.link) window.open(e.link,'_blank','noopener'); }, + A:()=>$('#content .fhead [data-a="read"], #content .fhead [data-a="readall"]')?.click(), + r:async()=>{ await loadFeeds(true); if(S.feed){ renderFeed(); loadEntries(); } }, + '[':()=>matchMedia('(max-width:820px)').matches + ? nav(!$('#sidebar').classList.contains('open')) : document.body.classList.toggle('nosb'), + '?':()=>keysModal(), + g:()=>{ gAt=Date.now(); }, +}; +function typed(ev){ + // The second key of a g pair counts only if it follows within a second and a half. + const pair=Date.now()-gAt<1500; gAt=0; + const fn=pair ? (GO[ev.key]&&(()=>selectFeed(GO[ev.key])))||(ev.key==='s'&&prefsModal) : KEYS[ev.key]; + if(!fn) return; + ev.preventDefault(); fn(); +} +/// What ? shows: every key, grouped as Feedly's own list is. +function keysModal(){ + const k=s=>`${esc(s)}`, g=c=>k('g')+' '+k(c); + const rows=[ + ['Go to'], + [g('a'),'All Subscriptions'],[g('d'),'Directory'],[g('p'),'Popular'], + [g('l'),'Currently Listening'],[g('s'),'Settings'], + [k('Shift')+' '+k('J'),'Next feed'],[k('Shift')+' '+k('K'),'Previous feed'], + [k('/'),'Search items'],[k('r'),'Refresh'],[k('['),'Show or hide the feed list'], + ['Items'], + [k('j')+' or '+k('n'),'Next item'],[k('k')+' or '+k('p'),'Previous item'], + [k('Shift')+' '+k('A'),'Mark all read'], + ['The selected item'], + [k('o'),'Play it'],[k('m'),'Mark it read or unread'],[k('s'),'Keep it, or stop keeping it'], + [k('v'),'Open the original in a new tab'], + ['The player'], + [k('Space'),'Play or pause'],[k('←')+' '+k('→'),'Back 15 seconds, forward 30'], + ['Anywhere'], + [k('?'),'This list'],[k('Esc'),'Close a dialog'], + ]; + openModal(`
| ${a} | |
|---|---|
| ${a} | ${b} |