feat(shortcuts): tell the key story once, and give the arrows back
Decision 1 keeps the unmodified single-key bindings, and Settings was the only place they were written down — three of the four categories of them, because config-page listed the categories by hand, so the autotag keys were written down nowhere at all. `?` now opens an overlay from anywhere the app owns the keyboard, and both surfaces read one table (services/shortcut-meta.ts, moved out of config-page's private static). The other half is the same explanation from the other side. Phase 1 gave the arrow keys to the grid, correctly — but all six of them, and no list in this app moves horizontally: track-list's own handler and utils/roving-rows both take Up/Down/Home/End and ignore Left/Right. So seeking stopped working from a focused row and nothing gained the keys. Reproduced in the running app: two ArrowRights on a focused track row, zero Player.Seek calls, against one per press from the body. A shifted character no longer reports Shift, so the binding is `?` and not `Shift+?` — the character already carries the shift, and a layout where it does not is a layout where "Shift+?" is wrong anyway.
This commit is contained in:
@@ -30,6 +30,14 @@ const KEY_ALIASES: Record<string, string> = {
|
||||
' ': 'Space',
|
||||
};
|
||||
|
||||
/** Whether a resolved key is a printable character that a Shift press
|
||||
* produced, rather than a key Shift was held alongside. Letters are
|
||||
* excluded: `A` and `Shift+A` are the same character, so Shift stays
|
||||
* meaningful there. */
|
||||
function isShiftedCharacter(key: string): boolean {
|
||||
return key.length === 1 && !/[A-Z0-9]/.test(key);
|
||||
}
|
||||
|
||||
/** Keys that are modifier-only presses and should be ignored. */
|
||||
const MODIFIER_KEYS = new Set([
|
||||
'Control',
|
||||
@@ -52,11 +60,6 @@ export function buildKeyString(e: KeyboardEvent): string {
|
||||
|
||||
const parts: string[] = [];
|
||||
|
||||
// Modifiers in fixed order. Treat Meta (Cmd on Mac) as Ctrl.
|
||||
if (e.ctrlKey || e.metaKey) parts.push('Ctrl');
|
||||
if (e.altKey) parts.push('Alt');
|
||||
if (e.shiftKey) parts.push('Shift');
|
||||
|
||||
// Normalize the key name.
|
||||
let key = KEY_ALIASES[e.key] ?? e.key;
|
||||
|
||||
@@ -65,6 +68,16 @@ export function buildKeyString(e: KeyboardEvent): string {
|
||||
key = key.toUpperCase();
|
||||
}
|
||||
|
||||
// Modifiers in fixed order. Treat Meta (Cmd on Mac) as Ctrl.
|
||||
if (e.ctrlKey || e.metaKey) parts.push('Ctrl');
|
||||
if (e.altKey) parts.push('Alt');
|
||||
|
||||
// Shift is only a modifier when it did not *produce* the key.
|
||||
// `?` is Shift+/ on a US layout and something else elsewhere, and
|
||||
// "Shift+?" is a binding nobody would write down; the character
|
||||
// already carries the shift.
|
||||
if (e.shiftKey && !isShiftedCharacter(key)) parts.push('Shift');
|
||||
|
||||
parts.push(key);
|
||||
|
||||
return parts.join('+');
|
||||
@@ -143,6 +156,7 @@ type ShortcutScope =
|
||||
*/
|
||||
const ACTIVATION_KEYS = new Set(['Space', 'Enter']);
|
||||
const ARROW_KEYS = new Set(['Up', 'Down', 'Left', 'Right', 'Home', 'End']);
|
||||
const VERTICAL_KEYS = new Set(['Up', 'Down', 'Home', 'End']);
|
||||
const LIST_KEYS = new Set([...ARROW_KEYS, ...ACTIVATION_KEYS]);
|
||||
const SLIDER_KEYS = new Set([...ARROW_KEYS, 'PageUp', 'PageDown']);
|
||||
|
||||
@@ -160,7 +174,14 @@ function keysOwnedBy(el: Element): ReadonlySet<string> | null {
|
||||
}
|
||||
|
||||
// A grid row or a listbox option moves with the arrow keys, which is
|
||||
// what makes a track list navigable without a mouse.
|
||||
// what makes a track list navigable without a mouse — but only the
|
||||
// *vertical* ones. Every list in this app (`track-list`'s own
|
||||
// handler, `utils/roving-rows.ts`, the card grids that use it) moves
|
||||
// on Up/Down/Home/End and does nothing with Left/Right, so granting
|
||||
// those took keyboard seeking away from a focused row and gave it to
|
||||
// nobody: two ArrowRights on a focused track row produced zero
|
||||
// `Player.Seek` calls, against one per press with focus on the body.
|
||||
// Grant them back here if a list ever moves horizontally.
|
||||
if (
|
||||
role === 'row' ||
|
||||
role === 'gridcell' ||
|
||||
@@ -168,7 +189,7 @@ function keysOwnedBy(el: Element): ReadonlySet<string> | null {
|
||||
role === 'option' ||
|
||||
role === 'treeitem'
|
||||
) {
|
||||
return ARROW_KEYS;
|
||||
return VERTICAL_KEYS;
|
||||
}
|
||||
|
||||
if (tag === 'INPUT') {
|
||||
@@ -398,6 +419,12 @@ async function dispatch(action: string): Promise<void> {
|
||||
}
|
||||
|
||||
// App actions
|
||||
case 'app.shortcuts':
|
||||
document.dispatchEvent(
|
||||
new CustomEvent('shortcut:app-shortcuts'),
|
||||
);
|
||||
break;
|
||||
|
||||
case 'app.selectAll':
|
||||
document.dispatchEvent(
|
||||
new CustomEvent('shortcut:select-all'),
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* What each shortcut action is called, where it applies, and what it is
|
||||
* bound to out of the box.
|
||||
*
|
||||
* This lived as a private static in `config-page`, which meant Settings
|
||||
* was the only place in the app the key bindings were written down —
|
||||
* and Settings renders three of the four categories, so the autotag
|
||||
* keys were written down nowhere at all. The `?` overlay and the
|
||||
* Settings editor now read one table.
|
||||
*/
|
||||
|
||||
export interface ShortcutMeta {
|
||||
label: string;
|
||||
category: string;
|
||||
scope: string;
|
||||
defaultKey: string;
|
||||
}
|
||||
|
||||
/** The categories, in the order both surfaces show them. */
|
||||
export const SHORTCUT_CATEGORIES = [
|
||||
'Player',
|
||||
'Navigation',
|
||||
'App',
|
||||
'Autotag',
|
||||
] as const;
|
||||
|
||||
export const SHORTCUT_META: Record<string, ShortcutMeta> = {
|
||||
'player.playPause': {
|
||||
label: 'Play / Pause',
|
||||
category: 'Player',
|
||||
scope: 'global',
|
||||
defaultKey: 'Space',
|
||||
},
|
||||
'player.next': {
|
||||
label: 'Next Track',
|
||||
category: 'Player',
|
||||
scope: 'global',
|
||||
defaultKey: 'N',
|
||||
},
|
||||
'player.previous': {
|
||||
label: 'Previous Track',
|
||||
category: 'Player',
|
||||
scope: 'global',
|
||||
defaultKey: 'P',
|
||||
},
|
||||
'player.volumeUp': {
|
||||
label: 'Volume Up',
|
||||
category: 'Player',
|
||||
scope: 'global',
|
||||
defaultKey: 'Up',
|
||||
},
|
||||
'player.volumeDown': {
|
||||
label: 'Volume Down',
|
||||
category: 'Player',
|
||||
scope: 'global',
|
||||
defaultKey: 'Down',
|
||||
},
|
||||
'player.seekForward': {
|
||||
label: 'Seek Forward',
|
||||
category: 'Player',
|
||||
scope: 'global',
|
||||
defaultKey: 'Right',
|
||||
},
|
||||
'player.seekBack': {
|
||||
label: 'Seek Back',
|
||||
category: 'Player',
|
||||
scope: 'global',
|
||||
defaultKey: 'Left',
|
||||
},
|
||||
'player.shuffle': {
|
||||
label: 'Toggle Shuffle',
|
||||
category: 'Player',
|
||||
scope: 'global',
|
||||
defaultKey: 'S',
|
||||
},
|
||||
'player.repeat': {
|
||||
label: 'Cycle Repeat',
|
||||
category: 'Player',
|
||||
scope: 'global',
|
||||
defaultKey: 'R',
|
||||
},
|
||||
'player.mute': {
|
||||
label: 'Toggle Mute',
|
||||
category: 'Player',
|
||||
scope: 'global',
|
||||
defaultKey: 'M',
|
||||
},
|
||||
'nav.search': {
|
||||
label: 'Focus Search',
|
||||
category: 'Navigation',
|
||||
scope: 'global',
|
||||
defaultKey: '/',
|
||||
},
|
||||
'nav.searchAlt': {
|
||||
label: 'Focus Search (Alt)',
|
||||
category: 'Navigation',
|
||||
scope: 'global',
|
||||
defaultKey: 'Ctrl+F',
|
||||
},
|
||||
'nav.queue': {
|
||||
label: 'Toggle Queue',
|
||||
category: 'Navigation',
|
||||
scope: 'global',
|
||||
defaultKey: 'Q',
|
||||
},
|
||||
'app.shortcuts': {
|
||||
label: 'Keyboard Shortcuts',
|
||||
category: 'App',
|
||||
scope: 'global',
|
||||
defaultKey: '?',
|
||||
},
|
||||
'app.selectAll': {
|
||||
label: 'Select All',
|
||||
category: 'App',
|
||||
scope: 'global',
|
||||
defaultKey: 'Ctrl+A',
|
||||
},
|
||||
'tracklist.play': {
|
||||
label: 'Play Selected',
|
||||
category: 'Navigation',
|
||||
scope: 'panel:track-list',
|
||||
defaultKey: 'Enter',
|
||||
},
|
||||
'autotag.apply': {
|
||||
label: 'Apply Match',
|
||||
category: 'Autotag',
|
||||
scope: 'panel:autotag',
|
||||
defaultKey: 'A',
|
||||
},
|
||||
'autotag.skip': {
|
||||
label: 'Skip Folder',
|
||||
category: 'Autotag',
|
||||
scope: 'panel:autotag',
|
||||
defaultKey: 'S',
|
||||
},
|
||||
'autotag.leave': {
|
||||
label: 'Leave As Is',
|
||||
category: 'Autotag',
|
||||
scope: 'panel:autotag',
|
||||
defaultKey: 'L',
|
||||
},
|
||||
'autotag.paste': {
|
||||
label: 'Paste Release URL',
|
||||
category: 'Autotag',
|
||||
scope: 'panel:autotag',
|
||||
defaultKey: 'U',
|
||||
},
|
||||
'autotag.search': {
|
||||
label: 'Search Candidates',
|
||||
category: 'Autotag',
|
||||
scope: 'panel:autotag',
|
||||
defaultKey: 'F',
|
||||
},
|
||||
'autotag.next': {
|
||||
label: 'Next Folder',
|
||||
category: 'Autotag',
|
||||
scope: 'panel:autotag',
|
||||
defaultKey: 'Down',
|
||||
},
|
||||
'autotag.previous': {
|
||||
label: 'Previous Folder',
|
||||
category: 'Autotag',
|
||||
scope: 'panel:autotag',
|
||||
defaultKey: 'Up',
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user