Keyboard shortcuts, after Feedly's

j/n and k/p step through the items, Shift-J and Shift-K through the feed
list, and g with a letter goes to a place: All Subscriptions, Directory,
Popular, Currently Listening, Settings. o plays the selected item, m marks
it read or unread and s keeps it, each by pressing the toolbar's own
button; v opens the original, Shift-A marks all read, r refreshes, [ hides
the feed list, and ? lists them all. None fire while typing, with a
modifier held, or with a dialog open.

Closes #8.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-15 17:08:54 +00:00
parent 4314b11237
commit 420f9eb9a0
4 changed files with 111 additions and 0 deletions

View File

@@ -12,6 +12,8 @@ The long form, with what was wrong before and how it was found, is in
### Added ### 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 - 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 of chips, the narrower one where a show gives two (Games, not Leisure). The two combine, and
both filter in place. both filter in place.

View File

@@ -91,6 +91,7 @@ const drive = [
['selectFeed (currently listening)', () => ctx.selectFeed(':listening')], ['selectFeed (currently listening)', () => ctx.selectFeed(':listening')],
['selectFeed (all subscriptions)', () => ctx.selectFeed(':all')], ['selectFeed (all subscriptions)', () => ctx.selectFeed(':all')],
['logsModal', () => ctx.logsModal()], ['logsModal', () => ctx.logsModal()],
['keysModal', () => ctx.keysModal()],
// `const S` is not reachable from here: top-level const/let do not become properties // `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. // of a vm context the way var and function declarations do.
['renderGroup', () => ctx.renderGroup(feed, [{ ...feed, id: 'child', group: 'f', orphaned: true }])], ['renderGroup', () => ctx.renderGroup(feed, [{ ...feed, id: 'child', group: 'f', orphaned: true }])],

View File

@@ -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 expect(proxied).toHaveURL(/\/signed-out-by-the-proxy$/);
await ctx.close(); 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');
});

View File

@@ -115,6 +115,11 @@ a{color:var(--accent)}
/* ---------- shell ---------- */ /* ---------- shell ---------- */
#shell{display:grid;grid-template-columns:290px 1fr;min-height:0;min-width:0;overflow:hidden} #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{ #sidebar{
background:var(--panel);border-right:1px solid var(--line); background:var(--panel);border-right:1px solid var(--line);
display:flex;flex-direction:column;min-height:0; 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} .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. */ /* 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} .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. */ /* 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} .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 /* 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==='ArrowLeft'&&player.guid){audio.currentTime-=15}
else if(ev.key==='ArrowRight'&&player.guid){audio.currentTime+=30} else if(ev.key==='ArrowRight'&&player.guid){audio.currentTime+=30}
else if(ev.key==='/'){ev.preventDefault();$('#epSearch')?.focus()} 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=>`<kbd>${esc(s)}</kbd>`, 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(`<h3>Keyboard shortcuts</h3><table class="keys">${rows.map(([a,b])=>b===undefined
?`<tr><th colspan="2">${a}</th></tr>`:`<tr><td>${a}</td><td>${b}</td></tr>`).join('')}</table>
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Close" aria-label="Close">${ICON.close}</button></div>`);
}
/* ---------------- modals ---------------- */ /* ---------------- modals ---------------- */
function openModal(html,wide){ function openModal(html,wide){
$('#modalCard').innerHTML=html; $('#modalCard').innerHTML=html;