diff --git a/frontend/src/lib/shared/actions/transcribeShortcuts.svelte.spec.ts b/frontend/src/lib/shared/actions/transcribeShortcuts.svelte.spec.ts new file mode 100644 index 00000000..77bf3fb5 --- /dev/null +++ b/frontend/src/lib/shared/actions/transcribeShortcuts.svelte.spec.ts @@ -0,0 +1,254 @@ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import type { TranscribeShortcutOptions } from './transcribeShortcuts'; + +const { transcribeShortcuts } = await import('./transcribeShortcuts'); + +function makeOptions( + overrides: Partial = {} +): TranscribeShortcutOptions { + return { + isPanelOpen: () => true, + isCheatsheetOpen: () => false, + panelMode: () => 'edit', + goToNextRegion: vi.fn(), + goToPrevRegion: vi.fn(), + toggleMode: vi.fn(), + closePanel: vi.fn(), + startDrawMode: vi.fn(), + toggleTrainingMark: vi.fn(), + deleteCurrentRegion: vi.fn(), + openCheatsheet: vi.fn(), + ...overrides + }; +} + +describe('transcribeShortcuts action', () => { + const nodes: HTMLElement[] = []; + const teardowns: Array<() => void> = []; + + function makeNode(): HTMLElement { + const node = document.createElement('div'); + document.body.appendChild(node); + nodes.push(node); + return node; + } + + function attach(options: TranscribeShortcutOptions) { + const node = makeNode(); + const action = transcribeShortcuts(node, options); + teardowns.push(() => action.destroy()); + return action; + } + + function press( + key: string, + opts: { target?: EventTarget; ctrlKey?: boolean; altKey?: boolean; metaKey?: boolean } = {} + ): KeyboardEvent { + const event = new KeyboardEvent('keydown', { + key, + bubbles: true, + cancelable: true, + ctrlKey: opts.ctrlKey ?? false, + altKey: opts.altKey ?? false, + metaKey: opts.metaKey ?? false + }); + (opts.target ?? document).dispatchEvent(event); + return event; + } + + function makeEditable(tag: 'input' | 'textarea' | 'div'): HTMLElement { + const el = document.createElement(tag); + if (tag === 'div') el.setAttribute('contenteditable', 'true'); + document.body.appendChild(el); + nodes.push(el); + return el; + } + + afterEach(() => { + teardowns.forEach((t) => t()); + teardowns.length = 0; + nodes.forEach((n) => n.remove()); + nodes.length = 0; + }); + + describe('navigation and mode keys', () => { + it('fires goToNextRegion on "j" and prevents default', () => { + const options = makeOptions(); + attach(options); + const event = press('j'); + expect(options.goToNextRegion).toHaveBeenCalledOnce(); + expect(event.defaultPrevented).toBe(true); + }); + + it('fires goToPrevRegion on "k"', () => { + const options = makeOptions(); + attach(options); + press('k'); + expect(options.goToPrevRegion).toHaveBeenCalledOnce(); + }); + + it('fires toggleMode on "e"', () => { + const options = makeOptions(); + attach(options); + press('e'); + expect(options.toggleMode).toHaveBeenCalledOnce(); + }); + }); + + describe('panel-open guard', () => { + it('does not fire when the panel is closed', () => { + const options = makeOptions({ isPanelOpen: () => false }); + attach(options); + press('j'); + press('e'); + expect(options.goToNextRegion).not.toHaveBeenCalled(); + expect(options.toggleMode).not.toHaveBeenCalled(); + }); + }); + + describe('focus guard', () => { + it('does not fire "j" when focus is inside an ', () => { + const options = makeOptions(); + attach(options); + press('j', { target: makeEditable('input') }); + expect(options.goToNextRegion).not.toHaveBeenCalled(); + }); + + it('does not fire "j"/"e"/"n"/"t" when focus is inside the TipTap contenteditable', () => { + const options = makeOptions(); + attach(options); + const editor = makeEditable('div'); + press('j', { target: editor }); + press('e', { target: editor }); + press('n', { target: editor }); + press('t', { target: editor }); + expect(options.goToNextRegion).not.toHaveBeenCalled(); + expect(options.toggleMode).not.toHaveBeenCalled(); + expect(options.startDrawMode).not.toHaveBeenCalled(); + expect(options.toggleTrainingMark).not.toHaveBeenCalled(); + }); + }); + + describe('"?" cheatsheet (focus-independent)', () => { + it('opens the cheatsheet on "?"', () => { + const options = makeOptions(); + attach(options); + const event = press('?'); + expect(options.openCheatsheet).toHaveBeenCalledOnce(); + expect(event.defaultPrevented).toBe(true); + }); + + it('opens the cheatsheet even when focus is inside the editor', () => { + const options = makeOptions(); + attach(options); + press('?', { target: makeEditable('div') }); + expect(options.openCheatsheet).toHaveBeenCalledOnce(); + }); + + it('does nothing when a Ctrl/Alt/Meta modifier is held', () => { + const options = makeOptions(); + attach(options); + press('?', { ctrlKey: true }); + press('?', { altKey: true }); + press('?', { metaKey: true }); + expect(options.openCheatsheet).not.toHaveBeenCalled(); + }); + + it('is a no-op when the cheatsheet is already open (open-only)', () => { + const options = makeOptions({ isCheatsheetOpen: () => true }); + attach(options); + press('?'); + expect(options.openCheatsheet).not.toHaveBeenCalled(); + }); + + it('does not open the cheatsheet when the panel is closed', () => { + const options = makeOptions({ isPanelOpen: () => false }); + attach(options); + press('?'); + expect(options.openCheatsheet).not.toHaveBeenCalled(); + }); + }); + + describe('"n" draw mode (edit only)', () => { + it('is a no-op in read mode', () => { + const options = makeOptions({ panelMode: () => 'read' }); + attach(options); + press('n'); + expect(options.startDrawMode).not.toHaveBeenCalled(); + }); + + it('fires startDrawMode in edit mode', () => { + const options = makeOptions({ panelMode: () => 'edit' }); + attach(options); + press('n'); + expect(options.startDrawMode).toHaveBeenCalledOnce(); + }); + }); + + describe('"t" training mark', () => { + it('fires toggleTrainingMark', () => { + const options = makeOptions(); + attach(options); + press('t'); + expect(options.toggleTrainingMark).toHaveBeenCalledOnce(); + }); + }); + + describe('"Delete" current region — single owner', () => { + it('fires deleteCurrentRegion exactly once when a focused annotation is the target', () => { + const options = makeOptions(); + attach(options); + const annotation = document.createElement('div'); + annotation.setAttribute('data-annotation', ''); + annotation.setAttribute('tabindex', '0'); + document.body.appendChild(annotation); + nodes.push(annotation); + press('Delete', { target: annotation }); + expect(options.deleteCurrentRegion).toHaveBeenCalledOnce(); + }); + }); + + describe('Esc precedence ladder (decision B1)', () => { + it('rung 1 — cheatsheet open: closePanel is NOT called (dialog handles Esc)', () => { + const options = makeOptions({ isCheatsheetOpen: () => true }); + attach(options); + press('Escape'); + expect(options.closePanel).not.toHaveBeenCalled(); + }); + + it('rung 2 — focus inside editable: no callback fires', () => { + const options = makeOptions(); + attach(options); + press('Escape', { target: makeEditable('div') }); + expect(options.closePanel).not.toHaveBeenCalled(); + }); + + it('rung 3 — otherwise: closePanel is called (pins panel-close)', () => { + const options = makeOptions(); + attach(options); + const event = press('Escape'); + expect(options.closePanel).toHaveBeenCalledOnce(); + expect(event.defaultPrevented).toBe(true); + }); + }); + + describe('lifecycle', () => { + it('removes the listener on destroy (no leak)', () => { + const options = makeOptions(); + const action = attach(options); + action.destroy(); + press('j'); + expect(options.goToNextRegion).not.toHaveBeenCalled(); + }); + + it('update() swaps callbacks so the listener never closes over stale state', () => { + const first = makeOptions(); + const action = attach(first); + const second = makeOptions(); + action.update(second); + press('j'); + expect(first.goToNextRegion).not.toHaveBeenCalled(); + expect(second.goToNextRegion).toHaveBeenCalledOnce(); + }); + }); +}); diff --git a/frontend/src/lib/shared/actions/transcribeShortcuts.ts b/frontend/src/lib/shared/actions/transcribeShortcuts.ts new file mode 100644 index 00000000..90806daf --- /dev/null +++ b/frontend/src/lib/shared/actions/transcribeShortcuts.ts @@ -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 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); + } + }; +}