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:
@@ -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.
|
||||
|
||||
@@ -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 }])],
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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=>`<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 ---------------- */
|
||||
function openModal(html,wide){
|
||||
$('#modalCard').innerHTML=html;
|
||||
|
||||
Reference in New Issue
Block a user