/** * 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; } // `node` is unused: the listener is global (window) so a shortcut fires no // matter where focus sits on the page. It is still authored as a Svelte action // (`use:transcribeShortcuts`) so its lifecycle is tied to the host element's // mount/unmount and `destroy()` reliably removes the listener. 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 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); } }; }