feat(transcribe): add transcribeShortcuts keyboard action (#327)

Single-owner window keydown action for the Transcribe panel: j/k region
nav, e mode toggle, n draw (edit only), t training mark, Delete, ? cheat-
sheet, and the Esc precedence ladder (cheatsheet → editable no-op → close
panel). Pure input-to-callback translator with a focus guard that exempts
only "?"; removes its listener on destroy. 20 unit tests cover every key,
the panel/focus guards, the Esc matrix, and teardown.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-06-04 16:42:25 +02:00
committed by marcel
parent 8353e71eed
commit 1b9707c6cd
2 changed files with 350 additions and 0 deletions

View File

@@ -0,0 +1,96 @@
/**
* Keyboard shortcuts for the Transcribe panel power path (issue #327).
*
* A pure input-to-callback translator: it owns no state and has no
* save/persistence responsibility. Panel state and every command are passed in
* as callbacks backed by the page's existing context setters, so the listener
* never closes over stale `$state`. It is the single owner of the panel's
* global `keydown` — including `Esc` (decision B1).
*/
export type TranscribeShortcutOptions = {
isPanelOpen: () => boolean; // reads transcribeMode ($state)
isCheatsheetOpen: () => boolean; // Esc ladder rung 1 + "?" open-only guard
panelMode: () => 'read' | 'edit'; // for the n-only-in-edit guard
goToNextRegion: () => void; // j
goToPrevRegion: () => void; // k
toggleMode: () => void; // e
closePanel: () => void; // Esc ladder rung 3
startDrawMode: () => void; // n (edit mode only)
toggleTrainingMark: () => void; // t (no-op when no active block)
deleteCurrentRegion: () => void; // Delete (confirm modal)
openCheatsheet: () => void; // ?
};
function isEditableTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
const tag = target.tagName;
return tag === 'INPUT' || tag === 'TEXTAREA' || target.isContentEditable;
}
export function transcribeShortcuts(_node: HTMLElement, initial: TranscribeShortcutOptions) {
let options = initial;
function handleKeydown(event: KeyboardEvent) {
// "?" is focus-independent: it fires regardless of the focus guard, but
// only with no Ctrl/Alt/Meta (Shift is allowed — "?" is Shift+ß on QWERTZ).
if (event.key === '?' && !event.ctrlKey && !event.altKey && !event.metaKey) {
if (!options.isPanelOpen()) return;
event.preventDefault();
if (!options.isCheatsheetOpen()) options.openCheatsheet();
return;
}
if (!options.isPanelOpen()) return;
// Esc precedence ladder (decision B1) — top rung wins.
if (event.key === 'Escape') {
if (options.isCheatsheetOpen()) return; // rung 1: the <dialog> closes itself
if (isEditableTarget(event.target)) return; // rung 2: let TipTap handle its Esc
event.preventDefault(); // rung 3: close the panel
options.closePanel();
return;
}
// Every remaining shortcut is inactive while focus is inside an editable.
if (isEditableTarget(event.target)) return;
switch (event.key) {
case 'j':
event.preventDefault();
options.goToNextRegion();
break;
case 'k':
event.preventDefault();
options.goToPrevRegion();
break;
case 'e':
event.preventDefault();
options.toggleMode();
break;
case 'n':
if (options.panelMode() !== 'edit') return; // silent no-op in read mode
event.preventDefault();
options.startDrawMode();
break;
case 't':
event.preventDefault();
options.toggleTrainingMark();
break;
case 'Delete':
event.preventDefault();
options.deleteCurrentRegion();
break;
}
}
window.addEventListener('keydown', handleKeydown);
return {
update(next: TranscribeShortcutOptions) {
options = next;
},
destroy() {
window.removeEventListener('keydown', handleKeydown);
}
};
}