From 1e3f76cb445f642bf8c3f28c3d167a9e81e273de Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 4 Jun 2026 16:39:13 +0200 Subject: [PATCH 01/12] feat(transcribe): add i18n keys for shortcut cheatsheet (#327) Adds de/en/es Paraglide keys for the keyboard-shortcut cheatsheet, coach hint, draw-armed hint, and the discoverable annotation Delete aria-label. Co-Authored-By: Claude Sonnet 4.6 --- frontend/messages/de.json | 14 ++++++++++++++ frontend/messages/en.json | 14 ++++++++++++++ frontend/messages/es.json | 14 ++++++++++++++ 3 files changed, 42 insertions(+) diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 7601d996..9e862c3c 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -927,6 +927,20 @@ "transcribe_coach_step_3_title": "Speichert automatisch.", "transcribe_coach_footer_kurrent": "Hilfe zu Kurrent ↗", "transcribe_coach_footer_richtlinien": "Transkriptions-Richtlinien ↗", + "transcribe_coach_shortcut_hint_before": "Tipp: Drücken Sie", + "transcribe_coach_shortcut_hint_after": "für eine Übersicht aller Tastenkürzel.", + "shortcut_next_region": "Nächster Bereich", + "shortcut_prev_region": "Vorheriger Bereich", + "shortcut_toggle_mode": "Lese-/Bearbeitungsmodus wechseln", + "shortcut_new_region": "Neuen Bereich zeichnen", + "shortcut_toggle_training": "Für Training markieren", + "shortcut_delete_region": "Aktuellen Bereich löschen", + "shortcut_help": "Tastaturkürzel anzeigen", + "shortcut_draw_hint": "Ziehen Sie mit der Maus einen Bereich auf.", + "cheatsheet_title": "Tastaturkürzel", + "cheatsheet_close": "Kürzelübersicht schließen", + "cheatsheet_autosave_hint": "Änderungen werden automatisch gespeichert.", + "annotation_label_with_delete": "Block anzeigen, Entf zum Löschen.", "transcription_mode_help_label": "Lese- und Bearbeitungsmodus", "transcription_mode_help_body": "Lesen zeigt die Transkription als fließenden Text. Bearbeiten öffnet die Textfelder für jede Passage.", "richtlinien_title": "Transkriptions-Richtlinien", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 722bac6b..10001f03 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -927,6 +927,20 @@ "transcribe_coach_step_3_title": "Saves automatically.", "transcribe_coach_footer_kurrent": "Kurrent help ↗", "transcribe_coach_footer_richtlinien": "Transcription guidelines ↗", + "transcribe_coach_shortcut_hint_before": "Tip: press", + "transcribe_coach_shortcut_hint_after": "for an overview of all keyboard shortcuts.", + "shortcut_next_region": "Next region", + "shortcut_prev_region": "Previous region", + "shortcut_toggle_mode": "Toggle read/edit mode", + "shortcut_new_region": "Draw a new region", + "shortcut_toggle_training": "Mark for training", + "shortcut_delete_region": "Delete current region", + "shortcut_help": "Show keyboard shortcuts", + "shortcut_draw_hint": "Drag a region with your mouse.", + "cheatsheet_title": "Keyboard shortcuts", + "cheatsheet_close": "Close shortcut overview", + "cheatsheet_autosave_hint": "Changes are saved automatically.", + "annotation_label_with_delete": "Show block, press Delete to remove.", "transcription_mode_help_label": "Read and edit mode", "transcription_mode_help_body": "Read shows the transcription as flowing text. Edit opens the text fields for each passage.", "richtlinien_title": "Transcription Guidelines", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 746f4fc7..5deaa9c8 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -927,6 +927,20 @@ "transcribe_coach_step_3_title": "Se guarda automáticamente.", "transcribe_coach_footer_kurrent": "Ayuda sobre Kurrent ↗", "transcribe_coach_footer_richtlinien": "Normas de transcripción ↗", + "transcribe_coach_shortcut_hint_before": "Consejo: pulse", + "transcribe_coach_shortcut_hint_after": "para ver todos los atajos de teclado.", + "shortcut_next_region": "Región siguiente", + "shortcut_prev_region": "Región anterior", + "shortcut_toggle_mode": "Cambiar modo lectura/edición", + "shortcut_new_region": "Dibujar una nueva región", + "shortcut_toggle_training": "Marcar para entrenamiento", + "shortcut_delete_region": "Eliminar la región actual", + "shortcut_help": "Mostrar atajos de teclado", + "shortcut_draw_hint": "Arrastre una región con el ratón.", + "cheatsheet_title": "Atajos de teclado", + "cheatsheet_close": "Cerrar el resumen de atajos", + "cheatsheet_autosave_hint": "Los cambios se guardan automáticamente.", + "annotation_label_with_delete": "Mostrar bloque, pulse Supr para eliminar.", "transcription_mode_help_label": "Modo lectura y edición", "transcription_mode_help_body": "Lectura muestra la transcripción como texto continuo. Edición abre los campos de texto para cada pasaje.", "richtlinien_title": "Normas de transcripción", -- 2.49.1 From 52920a5aba69d31a2f00b36489afee1abeeb5cbe Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 4 Jun 2026 16:42:25 +0200 Subject: [PATCH 02/12] feat(transcribe): add transcribeShortcuts keyboard action (#327) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../transcribeShortcuts.svelte.spec.ts | 254 ++++++++++++++++++ .../lib/shared/actions/transcribeShortcuts.ts | 96 +++++++ 2 files changed, 350 insertions(+) create mode 100644 frontend/src/lib/shared/actions/transcribeShortcuts.svelte.spec.ts create mode 100644 frontend/src/lib/shared/actions/transcribeShortcuts.ts 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); + } + }; +} -- 2.49.1 From ee728e352212901df119682e3ee6134fc252cfb6 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 4 Jun 2026 16:45:25 +0200 Subject: [PATCH 03/12] feat(transcribe): add ShortcutCheatsheet dialog overlay (#327) Native cheatsheet: showModal()/close() bridge, close button focused on open, eight grouped rows (nav/edit/utility), an autosave footer line, and a reduced-motion-guarded fade. Closes on Esc, backdrop click, and the close button; "?" while open is a no-op. Adds the shortcut_close_panel i18n key. 8 component tests. Co-Authored-By: Claude Sonnet 4.6 --- frontend/messages/de.json | 1 + frontend/messages/en.json | 1 + frontend/messages/es.json | 1 + .../transcription/ShortcutCheatsheet.svelte | 104 ++++++++++++++++++ .../ShortcutCheatsheet.svelte.spec.ts | 65 +++++++++++ 5 files changed, 172 insertions(+) create mode 100644 frontend/src/lib/document/transcription/ShortcutCheatsheet.svelte create mode 100644 frontend/src/lib/document/transcription/ShortcutCheatsheet.svelte.spec.ts diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 9e862c3c..efab5e90 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -935,6 +935,7 @@ "shortcut_new_region": "Neuen Bereich zeichnen", "shortcut_toggle_training": "Für Training markieren", "shortcut_delete_region": "Aktuellen Bereich löschen", + "shortcut_close_panel": "Bereich schließen", "shortcut_help": "Tastaturkürzel anzeigen", "shortcut_draw_hint": "Ziehen Sie mit der Maus einen Bereich auf.", "cheatsheet_title": "Tastaturkürzel", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 10001f03..0639283e 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -935,6 +935,7 @@ "shortcut_new_region": "Draw a new region", "shortcut_toggle_training": "Mark for training", "shortcut_delete_region": "Delete current region", + "shortcut_close_panel": "Close panel", "shortcut_help": "Show keyboard shortcuts", "shortcut_draw_hint": "Drag a region with your mouse.", "cheatsheet_title": "Keyboard shortcuts", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 5deaa9c8..8b7170cc 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -935,6 +935,7 @@ "shortcut_new_region": "Dibujar una nueva región", "shortcut_toggle_training": "Marcar para entrenamiento", "shortcut_delete_region": "Eliminar la región actual", + "shortcut_close_panel": "Cerrar panel", "shortcut_help": "Mostrar atajos de teclado", "shortcut_draw_hint": "Arrastre una región con el ratón.", "cheatsheet_title": "Atajos de teclado", diff --git a/frontend/src/lib/document/transcription/ShortcutCheatsheet.svelte b/frontend/src/lib/document/transcription/ShortcutCheatsheet.svelte new file mode 100644 index 00000000..199f85e5 --- /dev/null +++ b/frontend/src/lib/document/transcription/ShortcutCheatsheet.svelte @@ -0,0 +1,104 @@ + + + +
+

+ {m.cheatsheet_title()} +

+ +
+ +
+ {#each groups as group, i (i)} +
+ {#each group as shortcut (shortcut.cap)} +
+ {shortcut.cap} + {shortcut.label} +
+ {/each} +
+ {/each} +
+ +

+ {m.cheatsheet_autosave_hint()} +

+
+ + diff --git a/frontend/src/lib/document/transcription/ShortcutCheatsheet.svelte.spec.ts b/frontend/src/lib/document/transcription/ShortcutCheatsheet.svelte.spec.ts new file mode 100644 index 00000000..a5f90402 --- /dev/null +++ b/frontend/src/lib/document/transcription/ShortcutCheatsheet.svelte.spec.ts @@ -0,0 +1,65 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import ShortcutCheatsheet from './ShortcutCheatsheet.svelte'; + +afterEach(cleanup); + +describe('ShortcutCheatsheet', () => { + it('is not in the accessibility tree when closed', async () => { + render(ShortcutCheatsheet, { open: false, onClose: vi.fn() }); + await expect.element(page.getByRole('dialog')).not.toBeInTheDocument(); + }); + + it('opens as a modal dialog with a labelled heading when open', async () => { + render(ShortcutCheatsheet, { open: true, onClose: vi.fn() }); + await expect.element(page.getByRole('dialog')).toBeInTheDocument(); + await expect.element(page.getByRole('heading')).toBeInTheDocument(); + }); + + it('lists all eight shortcut rows', async () => { + render(ShortcutCheatsheet, { open: true, onClose: vi.fn() }); + const dialog = page.getByRole('dialog').element() as HTMLElement; + const keyCaps = dialog.querySelectorAll('kbd'); + expect(keyCaps.length).toBe(8); + }); + + it('shows the autosave footer line', async () => { + render(ShortcutCheatsheet, { open: true, onClose: vi.fn() }); + const dialog = page.getByRole('dialog').element() as HTMLElement; + expect(dialog.textContent).toContain('automatisch'); + }); + + it('calls onClose when Escape is pressed', async () => { + const onClose = vi.fn(); + render(ShortcutCheatsheet, { open: true, onClose }); + const dialog = page.getByRole('dialog').element() as HTMLDialogElement; + dialog.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + // native turns Esc into a 'cancel' + 'close'; assert close fired onClose + dialog.dispatchEvent(new Event('close')); + expect(onClose).toHaveBeenCalled(); + }); + + it('calls onClose when the backdrop is clicked', async () => { + const onClose = vi.fn(); + render(ShortcutCheatsheet, { open: true, onClose }); + const dialog = page.getByRole('dialog').element() as HTMLDialogElement; + // a click whose target is the dialog element itself is a backdrop click + dialog.dispatchEvent(new MouseEvent('click', { bubbles: true })); + expect(onClose).toHaveBeenCalled(); + }); + + it('does not close on "?" while open (open-only, not a toggle)', async () => { + const onClose = vi.fn(); + render(ShortcutCheatsheet, { open: true, onClose }); + const dialog = page.getByRole('dialog').element() as HTMLDialogElement; + dialog.dispatchEvent(new KeyboardEvent('keydown', { key: '?', bubbles: true })); + expect(onClose).not.toHaveBeenCalled(); + }); + + it('focuses the close button on open', async () => { + render(ShortcutCheatsheet, { open: true, onClose: vi.fn() }); + const closeButton = page.getByRole('button', { name: /schließen/i }).element(); + expect(document.activeElement).toBe(closeButton); + }); +}); -- 2.49.1 From bc89426063b57a26613401a2eca636940d6b3d8c Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 4 Jun 2026 16:56:36 +0200 Subject: [PATCH 04/12] feat(transcribe): wire keyboard shortcuts into the document panel (#327) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Attaches the transcribeShortcuts action to the document page and wires every command to existing context setters: j/k walk the sortOrder-sorted regions and set activeAnnotationId, e toggles read/edit, n arms a draw cue (edit only), Delete routes to the existing confirm path, ? opens the cheatsheet, and Esc is now owned solely by the action — the inline onMount Esc listener is removed (decision B1). Renders ShortcutCheatsheet and a draw-armed hint. "t" toggles the document-level KURRENT_RECOGNITION training enrollment (the only training surface that exists; there is no per-region flag yet — see #321) and no-ops unless a region is active. Also reconciles annotation Delete: the shape no longer self-handles the key, with onfocus syncing the active region so the action deletes exactly once. Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/document/DocumentViewer.svelte | 6 +- .../annotation/AnnotationLayer.svelte | 6 +- .../annotation/AnnotationShape.svelte | 15 +++- .../annotation/AnnotationShape.svelte.spec.ts | 60 +++++++++---- .../src/lib/document/viewer/PdfViewer.svelte | 6 +- .../src/routes/documents/[id]/+page.svelte | 84 +++++++++++++++++-- 6 files changed, 142 insertions(+), 35 deletions(-) diff --git a/frontend/src/lib/document/DocumentViewer.svelte b/frontend/src/lib/document/DocumentViewer.svelte index aa8a575f..6b189961 100644 --- a/frontend/src/lib/document/DocumentViewer.svelte +++ b/frontend/src/lib/document/DocumentViewer.svelte @@ -25,7 +25,7 @@ type Props = { flashAnnotationId?: string | null; onAnnotationClick: (id: string) => void; onTranscriptionDraw?: (rect: DrawRect) => void; - onDeleteAnnotationRequest?: (annotationId: string) => void; + onAnnotationFocus?: (id: string) => void; }; let { @@ -42,7 +42,7 @@ let { flashAnnotationId = null, onAnnotationClick, onTranscriptionDraw, - onDeleteAnnotationRequest + onAnnotationFocus }: Props = $props(); @@ -104,7 +104,7 @@ let { flashAnnotationId={flashAnnotationId} onAnnotationClick={onAnnotationClick} onTranscriptionDraw={onTranscriptionDraw} - onDeleteAnnotationRequest={onDeleteAnnotationRequest} + onAnnotationFocus={onAnnotationFocus} documentFileHash={doc.fileHash ?? null} /> {:else if fileUrl} diff --git a/frontend/src/lib/document/annotation/AnnotationLayer.svelte b/frontend/src/lib/document/annotation/AnnotationLayer.svelte index d392628c..35b321de 100644 --- a/frontend/src/lib/document/annotation/AnnotationLayer.svelte +++ b/frontend/src/lib/document/annotation/AnnotationLayer.svelte @@ -19,7 +19,7 @@ let { flashAnnotationId = null, onDraw, onAnnotationClick, - onDeleteRequest + onAnnotationFocus }: { annotations: Annotation[]; canDraw: boolean; @@ -30,7 +30,7 @@ let { flashAnnotationId?: string | null; onDraw: (rect: DrawRect) => void; onAnnotationClick?: (id: string) => void; - onDeleteRequest?: (annotationId: string) => void; + onAnnotationFocus?: (id: string) => void; } = $props(); let drawStart = $state<{ x: number; y: number } | null>(null); @@ -115,8 +115,8 @@ const containerStyle = $derived( blockNumber={blockNumbers[annotation.id]} isFlashing={flashAnnotationId === annotation.id} showDelete={canDraw} - onDeleteRequest={() => onDeleteRequest?.(annotation.id)} onclick={() => onAnnotationClick?.(annotation.id)} + onfocus={() => onAnnotationFocus?.(annotation.id)} onpointerenter={() => (hoveredId = annotation.id)} onpointerleave={() => (hoveredId = null)} /> diff --git a/frontend/src/lib/document/annotation/AnnotationShape.svelte b/frontend/src/lib/document/annotation/AnnotationShape.svelte index c0cb985f..7b301d76 100644 --- a/frontend/src/lib/document/annotation/AnnotationShape.svelte +++ b/frontend/src/lib/document/annotation/AnnotationShape.svelte @@ -1,5 +1,6 @@