Review follow-up (Leonie, Felix, Markus): bump cheatsheet key caps to text-sm
for the 60+ audience, add a focus-visible ring to the close button, simplify
the draw-hint guard to {#if drawArmed} (the $effect already clears it outside
edit mode), and document why the transcribeShortcuts action ignores its node
and binds to window.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
101 lines
3.4 KiB
TypeScript
101 lines
3.4 KiB
TypeScript
/**
|
|
* 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 <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);
|
|
}
|
|
};
|
|
}
|