diff --git a/frontend/e2e/transcribe-shortcuts.spec.ts b/frontend/e2e/transcribe-shortcuts.spec.ts new file mode 100644 index 00000000..40b03bd4 --- /dev/null +++ b/frontend/e2e/transcribe-shortcuts.spec.ts @@ -0,0 +1,163 @@ +import { test, expect, type Page } from '@playwright/test'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import fs from 'fs'; +import { AxeBuilder } from '@axe-core/playwright'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const PDF_FIXTURE = path.resolve(__dirname, 'fixtures/minimal.pdf'); + +/** + * E2E tests for the transcribe keyboard shortcuts + cheatsheet overlay — #327. + * + * Strategy mirrors annotations.spec: seed a document with two transcription + * blocks via API in beforeAll (no OCR, no manual drawing), then drive the + * keyboard. j/k navigation is exercised in read mode so no editable can trap + * focus — the active region's resize overlay renders regardless of read/edit. + */ + +const RESIZE_AREA_LABEL = 'Annotationsgröße und -position ändern'; + +let docHref: string; +let docId: string; +let annotAId: string; +let annotBId: string; + +test.describe('Transcribe keyboard shortcuts', () => { + test.beforeAll(async ({ request }) => { + const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000'; + + const createRes = await request.post('/api/documents', { + multipart: { title: 'E2E Shortcuts Test', documentDate: '1945-05-08' } + }); + if (!createRes.ok()) throw new Error(`Create document failed: ${createRes.status()}`); + const doc = await createRes.json(); + docId = doc.id; + docHref = `${baseURL}/documents/${docId}`; + + const uploadRes = await request.put(`/api/documents/${docId}`, { + multipart: { + title: doc.title, + documentDate: '1945-05-08', + file: { + name: 'minimal.pdf', + mimeType: 'application/pdf', + buffer: fs.readFileSync(PDF_FIXTURE) + } + } + }); + if (!uploadRes.ok()) throw new Error(`Upload PDF failed: ${uploadRes.status()}`); + + const blockARes = await request.post(`/api/documents/${docId}/transcription-blocks`, { + data: { + pageNumber: 1, + x: 0.1, + y: 0.1, + width: 0.3, + height: 0.1, + text: 'Erste Zeile.', + label: 'Anrede' + } + }); + if (!blockARes.ok()) throw new Error(`Create block A failed: ${blockARes.status()}`); + annotAId = (await blockARes.json()).annotationId; + + const blockBRes = await request.post(`/api/documents/${docId}/transcription-blocks`, { + data: { + pageNumber: 1, + x: 0.1, + y: 0.35, + width: 0.3, + height: 0.1, + text: 'Zweite Zeile.', + label: null + } + }); + if (!blockBRes.ok()) throw new Error(`Create block B failed: ${blockBRes.status()}`); + annotBId = (await blockBRes.json()).annotationId; + }); + + async function openTranscribe(page: Page) { + await page.goto(docHref); + await page.waitForSelector('[data-hydrated]'); + await page.getByRole('button', { name: 'Transkribieren' }).click(); + await page.locator('.tabular-nums').waitFor({ timeout: 15_000 }); + await page.locator(`[data-testid="annotation-${annotAId}"]`).waitFor({ timeout: 10_000 }); + } + + function activeRegionOverlay(page: Page, annotationId: string) { + return page.locator(`[data-testid="annotation-${annotationId}"]`).getByLabel(RESIZE_AREA_LABEL); + } + + test('? opens the cheatsheet; Esc closes it, then a second Esc closes the panel', async ({ + page + }) => { + test.setTimeout(30_000); + await openTranscribe(page); + + await page.keyboard.press('?'); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + await expect(dialog.getByRole('heading', { name: 'Tastaturkürzel' })).toBeVisible(); + + await page.keyboard.press('Escape'); + await expect(dialog).not.toBeVisible(); + + // Panel still open after closing only the cheatsheet (Esc ladder rung 1). + await expect(page.getByRole('button', { name: 'Fertig' })).toBeVisible(); + + await page.keyboard.press('Escape'); + await expect(page.getByRole('button', { name: 'Transkribieren' })).toBeVisible(); + }); + + test('e toggles between read and edit mode', async ({ page }) => { + test.setTimeout(30_000); + await openTranscribe(page); + + // The "mark for training" section renders only in the edit view. + const editMarker = page.getByText('Für Training vormerken'); + + // Default for a writer with existing blocks is read mode. + await expect(editMarker).toHaveCount(0); + + await page.keyboard.press('e'); + await expect(editMarker).toBeVisible(); + + await page.keyboard.press('e'); + await expect(editMarker).toHaveCount(0); + }); + + test('j and k walk forward and back through the regions', async ({ page }) => { + test.setTimeout(30_000); + await openTranscribe(page); + + await page.keyboard.press('j'); + await expect(activeRegionOverlay(page, annotAId)).toBeVisible(); + + await page.keyboard.press('j'); + await expect(activeRegionOverlay(page, annotBId)).toBeVisible(); + await expect(activeRegionOverlay(page, annotAId)).toHaveCount(0); + + await page.keyboard.press('k'); + await expect(activeRegionOverlay(page, annotAId)).toBeVisible(); + }); + + test('the open cheatsheet has no critical accessibility violations', async ({ page }) => { + test.setTimeout(30_000); + await openTranscribe(page); + + await page.keyboard.press('?'); + await expect(page.getByRole('dialog')).toBeVisible(); + + const results = await new AxeBuilder({ page }) + .include('dialog') + .withTags(['wcag2a', 'wcag2aa']) + .analyze(); + const critical = results.violations.filter((v) => v.impact === 'critical'); + expect(critical).toEqual([]); + + // The dialog exposes a modal role with an accessible name (labelled heading). + const dialog = page.getByRole('dialog'); + await expect(dialog).toHaveAttribute('aria-modal', 'true'); + }); +}); diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 7601d996..53b79850 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -927,6 +927,23 @@ "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_close_panel": "Panel schließen", + "shortcut_help": "Tastaturkürzel anzeigen", + "shortcut_draw_hint": "Ziehen Sie mit der Maus einen Bereich auf.", + "key_cap_delete": "Entf", + "cheatsheet_title": "Tastaturkürzel", + "cheatsheet_close": "Kürzelübersicht schließen", + "cheatsheet_autosave_hint": "Änderungen werden automatisch gespeichert.", + "annotation_view_label": "Block anzeigen", + "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..808452e1 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -927,6 +927,23 @@ "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_close_panel": "Close panel", + "shortcut_help": "Show keyboard shortcuts", + "shortcut_draw_hint": "Drag a region with your mouse.", + "key_cap_delete": "Del", + "cheatsheet_title": "Keyboard shortcuts", + "cheatsheet_close": "Close shortcut overview", + "cheatsheet_autosave_hint": "Changes are saved automatically.", + "annotation_view_label": "View block", + "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..c0c376d4 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -927,6 +927,23 @@ "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_close_panel": "Cerrar panel", + "shortcut_help": "Mostrar atajos de teclado", + "shortcut_draw_hint": "Arrastre una región con el ratón.", + "key_cap_delete": "Supr", + "cheatsheet_title": "Atajos de teclado", + "cheatsheet_close": "Cerrar el resumen de atajos", + "cheatsheet_autosave_hint": "Los cambios se guardan automáticamente.", + "annotation_view_label": "Ver bloque", + "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", 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..2a67761c 100644 --- a/frontend/src/lib/document/annotation/AnnotationShape.svelte +++ b/frontend/src/lib/document/annotation/AnnotationShape.svelte @@ -1,5 +1,6 @@ + + + + + {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); + }); +}); diff --git a/frontend/src/lib/document/transcription/drawCue.spec.ts b/frontend/src/lib/document/transcription/drawCue.spec.ts new file mode 100644 index 00000000..f259bb38 --- /dev/null +++ b/frontend/src/lib/document/transcription/drawCue.spec.ts @@ -0,0 +1,14 @@ +import { describe, it, expect } from 'vitest'; +import { canArmDraw, shouldDisarmDraw } from './drawCue'; + +describe('draw cue policy', () => { + it('arms only in edit mode', () => { + expect(canArmDraw('edit')).toBe(true); + expect(canArmDraw('read')).toBe(false); + }); + + it('disarms in every mode except edit', () => { + expect(shouldDisarmDraw('read')).toBe(true); + expect(shouldDisarmDraw('edit')).toBe(false); + }); +}); diff --git a/frontend/src/lib/document/transcription/drawCue.ts b/frontend/src/lib/document/transcription/drawCue.ts new file mode 100644 index 00000000..3d034838 --- /dev/null +++ b/frontend/src/lib/document/transcription/drawCue.ts @@ -0,0 +1,20 @@ +/** + * Policy for the "draw a new region" keyboard cue (the `n` shortcut) in the + * transcribe panel — issue #327. + * + * The cue is only valid while editing: `n` arms it in edit mode, and it must + * clear when a region is drawn or when the panel leaves edit mode. Pure so both + * rules are testable without mounting the page. + */ + +type PanelMode = 'read' | 'edit'; + +/** The draw cue may only be armed while in edit mode. */ +export function canArmDraw(panelMode: PanelMode): boolean { + return panelMode === 'edit'; +} + +/** Leaving edit mode must disarm the draw cue. */ +export function shouldDisarmDraw(panelMode: PanelMode): boolean { + return !canArmDraw(panelMode); +} diff --git a/frontend/src/lib/document/transcription/regionNavigation.spec.ts b/frontend/src/lib/document/transcription/regionNavigation.spec.ts new file mode 100644 index 00000000..38e7e21c --- /dev/null +++ b/frontend/src/lib/document/transcription/regionNavigation.spec.ts @@ -0,0 +1,48 @@ +import { describe, it, expect } from 'vitest'; +import { stepRegion } from './regionNavigation'; + +describe('stepRegion', () => { + const ids = ['a', 'b', 'c']; + + it('returns null for an empty list', () => { + expect(stepRegion([], null, 1)).toBe(null); + expect(stepRegion([], 'a', -1)).toBe(null); + }); + + it('steps forward from the middle', () => { + expect(stepRegion(ids, 'a', 1)).toBe('b'); + expect(stepRegion(ids, 'b', 1)).toBe('c'); + }); + + it('steps backward from the middle', () => { + expect(stepRegion(ids, 'c', -1)).toBe('b'); + expect(stepRegion(ids, 'b', -1)).toBe('a'); + }); + + it('wraps forward past the last region to the first', () => { + expect(stepRegion(ids, 'c', 1)).toBe('a'); + }); + + it('wraps backward past the first region to the last', () => { + expect(stepRegion(ids, 'a', -1)).toBe('c'); + }); + + it('lands on the first region when entering fresh (no active) going forward', () => { + expect(stepRegion(ids, null, 1)).toBe('a'); + }); + + it('lands on the last region when entering fresh (no active) going backward', () => { + expect(stepRegion(ids, null, -1)).toBe('c'); + }); + + it('treats an unknown active id as a fresh entry', () => { + expect(stepRegion(ids, 'zzz', 1)).toBe('a'); + expect(stepRegion(ids, 'zzz', -1)).toBe('c'); + }); + + it('returns the single region for both directions (wrap of length 1)', () => { + expect(stepRegion(['only'], 'only', 1)).toBe('only'); + expect(stepRegion(['only'], 'only', -1)).toBe('only'); + expect(stepRegion(['only'], null, 1)).toBe('only'); + }); +}); diff --git a/frontend/src/lib/document/transcription/regionNavigation.ts b/frontend/src/lib/document/transcription/regionNavigation.ts new file mode 100644 index 00000000..5febc25c --- /dev/null +++ b/frontend/src/lib/document/transcription/regionNavigation.ts @@ -0,0 +1,33 @@ +/** + * Region navigation for the transcribe keyboard shortcuts (j/k) — issue #327. + * + * Pure and side-effect free so the wrap-around / fresh-entry branches are + * unit-testable without mounting the page. + */ + +/** + * Pick the annotation id one step from the active region, wrapping around the + * ends. Entering fresh (no active region, or an unknown id) lands on the first + * region going forward and the last going backward. + * + * @param orderedAnnotationIds region annotation ids in display order + * @param activeId the currently active region, or null + * @param delta +1 for next (j), -1 for previous (k) + * @returns the next annotation id, or null when there are no regions + */ +export function stepRegion( + orderedAnnotationIds: string[], + activeId: string | null, + delta: 1 | -1 +): string | null { + const count = orderedAnnotationIds.length; + if (count === 0) return null; + + const current = activeId === null ? -1 : orderedAnnotationIds.indexOf(activeId); + if (current === -1) { + return delta > 0 ? orderedAnnotationIds[0] : orderedAnnotationIds[count - 1]; + } + + const next = (current + delta + count) % count; + return orderedAnnotationIds[next]; +} diff --git a/frontend/src/lib/document/transcription/trainingMark.spec.ts b/frontend/src/lib/document/transcription/trainingMark.spec.ts new file mode 100644 index 00000000..b8f2212b --- /dev/null +++ b/frontend/src/lib/document/transcription/trainingMark.spec.ts @@ -0,0 +1,30 @@ +import { describe, it, expect } from 'vitest'; +import { resolveTrainingMark, RECOGNITION_TRAINING_LABEL } from './trainingMark'; + +describe('resolveTrainingMark', () => { + it('is a no-op (null) when no region is active', () => { + expect(resolveTrainingMark(null, [])).toBe(null); + expect(resolveTrainingMark(null, [RECOGNITION_TRAINING_LABEL])).toBe(null); + }); + + it('enrols recognition training when a region is active and not yet enrolled', () => { + expect(resolveTrainingMark('ann-1', [])).toEqual({ + label: RECOGNITION_TRAINING_LABEL, + enrolled: true + }); + }); + + it('un-enrols when recognition training is already enrolled', () => { + expect(resolveTrainingMark('ann-1', [RECOGNITION_TRAINING_LABEL])).toEqual({ + label: RECOGNITION_TRAINING_LABEL, + enrolled: false + }); + }); + + it('ignores unrelated document training labels', () => { + expect(resolveTrainingMark('ann-1', ['KURRENT_SEGMENTATION'])).toEqual({ + label: RECOGNITION_TRAINING_LABEL, + enrolled: true + }); + }); +}); diff --git a/frontend/src/lib/document/transcription/trainingMark.ts b/frontend/src/lib/document/transcription/trainingMark.ts new file mode 100644 index 00000000..0f930c83 --- /dev/null +++ b/frontend/src/lib/document/transcription/trainingMark.ts @@ -0,0 +1,31 @@ +/** + * "Mark for training" (the `t` shortcut) decision logic — issue #327. + * + * Training enrollment is document-level — two fixed script-type chips + * (KURRENT_RECOGNITION / KURRENT_SEGMENTATION); there is no per-region training + * flag yet (that arrives with #321). `t` toggles the primary recognition + * enrollment and is a silent no-op unless a region is active, so it reads as an + * action on the region the transcriber is working on. + * + * Pure so the no-op-when-no-region guard and the enrolled flip are testable + * without mounting the page. + */ +export const RECOGNITION_TRAINING_LABEL = 'KURRENT_RECOGNITION'; + +export type TrainingMarkToggle = { label: string; enrolled: boolean }; + +/** + * Decide the recognition-training toggle for the active region, or null when no + * region is active (the `t` shortcut is then a silent no-op). + * + * @param activeAnnotationId the currently active region, or null + * @param currentLabels the document's currently enrolled training labels + */ +export function resolveTrainingMark( + activeAnnotationId: string | null, + currentLabels: readonly string[] +): TrainingMarkToggle | null { + if (!activeAnnotationId) return null; + const enrolled = !currentLabels.includes(RECOGNITION_TRAINING_LABEL); + return { label: RECOGNITION_TRAINING_LABEL, enrolled }; +} diff --git a/frontend/src/lib/document/viewer/PdfViewer.svelte b/frontend/src/lib/document/viewer/PdfViewer.svelte index 5164178d..90aa8853 100644 --- a/frontend/src/lib/document/viewer/PdfViewer.svelte +++ b/frontend/src/lib/document/viewer/PdfViewer.svelte @@ -20,7 +20,7 @@ let { activeAnnotationId = $bindable(null), onAnnotationClick, onTranscriptionDraw, - onDeleteAnnotationRequest, + onAnnotationFocus, documentFileHash, annotationsDimmed = false, flashAnnotationId = null, @@ -35,7 +35,7 @@ let { activeAnnotationId?: string | null; onAnnotationClick?: (id: string) => void; onTranscriptionDraw?: (rect: DrawRect) => void; - onDeleteAnnotationRequest?: (annotationId: string) => void; + onAnnotationFocus?: (id: string) => void; documentFileHash?: string | null; annotationsDimmed?: boolean; flashAnnotationId?: string | null; @@ -294,7 +294,7 @@ function handleAnnotationClick(id: string) { flashAnnotationId={flashAnnotationId} onDraw={handleDraw} onAnnotationClick={handleAnnotationClick} - onDeleteRequest={onDeleteAnnotationRequest} + onAnnotationFocus={onAnnotationFocus} /> {/if} 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..ec333825 --- /dev/null +++ b/frontend/src/lib/shared/actions/transcribeShortcuts.ts @@ -0,0 +1,100 @@ +/** + * 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); + } + }; +} diff --git a/frontend/src/lib/shared/help/TranscribeCoachEmptyState.svelte b/frontend/src/lib/shared/help/TranscribeCoachEmptyState.svelte index 26b31f8d..8690161d 100644 --- a/frontend/src/lib/shared/help/TranscribeCoachEmptyState.svelte +++ b/frontend/src/lib/shared/help/TranscribeCoachEmptyState.svelte @@ -72,5 +72,13 @@ import TranscribeDragDemo from './TranscribeDragDemo.svelte'; {m.transcribe_coach_footer_richtlinien()} {m.common_opens_new_tab()} + + {m.transcribe_coach_shortcut_hint_before()} + ? + {m.transcribe_coach_shortcut_hint_after()} + diff --git a/frontend/src/lib/shared/help/TranscribeCoachEmptyState.svelte.spec.ts b/frontend/src/lib/shared/help/TranscribeCoachEmptyState.svelte.spec.ts index 5f8d397a..f3a25fc4 100644 --- a/frontend/src/lib/shared/help/TranscribeCoachEmptyState.svelte.spec.ts +++ b/frontend/src/lib/shared/help/TranscribeCoachEmptyState.svelte.spec.ts @@ -14,6 +14,8 @@ vi.mock('$lib/paraglide/messages.js', () => ({ 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.', common_opens_new_tab: () => '(öffnet in neuem Tab)' } })); @@ -63,6 +65,21 @@ describe('TranscribeCoachEmptyState', () => { await expect.element(annotations.first()).toBeInTheDocument(); }); + it('renders the keyboard-shortcut hint with a "?" key cap', async () => { + render(TranscribeCoachEmptyState); + await expect.element(page.getByText('Tastenkürzel', { exact: false })).toBeInTheDocument(); + const kbd = document.querySelector('kbd'); + expect(kbd?.textContent).toBe('?'); + }); + + it('hides the keyboard hint on touch-only (coarse-pointer) devices', async () => { + render(TranscribeCoachEmptyState); + const hint = document.querySelector('kbd')?.closest('p'); + // The hint is gated behind a fine-pointer media query so touch-only + // transcribers are never told to press a key they do not have (#327). + expect(hint?.className).toContain('pointer:coarse'); + }); + it('renders the drag demo animation region inside step 1', async () => { render(TranscribeCoachEmptyState); const demo = page.getByRole('img', { name: /Rahmen ziehen|Animation/i }); diff --git a/frontend/src/routes/documents/[id]/+page.svelte b/frontend/src/routes/documents/[id]/+page.svelte index 32cd894e..ff3ac527 100644 --- a/frontend/src/routes/documents/[id]/+page.svelte +++ b/frontend/src/routes/documents/[id]/+page.svelte @@ -8,8 +8,13 @@ import DocumentViewer from '$lib/document/DocumentViewer.svelte'; import TranscriptionEditView from '$lib/document/transcription/TranscriptionEditView.svelte'; import TranscriptionReadView from '$lib/document/transcription/TranscriptionReadView.svelte'; import TranscriptionPanelHeader from '$lib/document/transcription/TranscriptionPanelHeader.svelte'; +import ShortcutCheatsheet from '$lib/document/transcription/ShortcutCheatsheet.svelte'; +import { transcribeShortcuts } from '$lib/shared/actions/transcribeShortcuts'; import { createOcrJob } from '$lib/ocr/useOcrJob.svelte'; import { createTranscriptionBlocks } from '$lib/document/transcription/useTranscriptionBlocks.svelte'; +import { stepRegion } from '$lib/document/transcription/regionNavigation'; +import { resolveTrainingMark } from '$lib/document/transcription/trainingMark'; +import { canArmDraw, shouldDisarmDraw } from '$lib/document/transcription/drawCue'; import { createFileLoader } from '$lib/document/viewer/useFileLoader.svelte'; import { scrollToCommentFromQuery } from '$lib/shared/utils/deepLinkScroll'; import { getConfirmService } from '$lib/shared/services/confirm.svelte'; @@ -42,6 +47,8 @@ let activeAnnotationId = $state(null); let highlightBlockId = $state(null); let flashAnnotationId = $state(null); let pdfStripExpanded = $state(false); +let cheatsheetOpen = $state(false); +let drawArmed = $state(false); // Flag set by the deep-link helper so the transcribe-mode $effect does not // overwrite the panelMode it picked (e.g. forcing 'edit' on notification // click-through). One-shot: consumed after the effect's loadBlocks resolves. @@ -86,12 +93,57 @@ async function createBlockFromDraw(rect: { height: number; pageNumber: number; }) { + drawArmed = false; const created = await transcription.createFromDraw(rect); if (created) { activeAnnotationId = created.annotationId; } } +// ── Keyboard shortcuts (issue #327) ────────────────────────────────────────── + +const sortedBlocks = $derived([...transcription.blocks].sort((a, b) => a.sortOrder - b.sortOrder)); + +function goToRegion(delta: 1 | -1) { + const ids = sortedBlocks.map((b) => b.annotationId); + const next = stepRegion(ids, activeAnnotationId, delta); + if (next) activeAnnotationId = next; +} + +function toggleMode() { + if (canWrite) panelMode = panelMode === 'read' ? 'edit' : 'read'; +} + +function toggleTrainingMark() { + const toggle = resolveTrainingMark(activeAnnotationId, doc.trainingLabels ?? []); + if (toggle) transcription.toggleTrainingLabel(toggle.label, toggle.enrolled); +} + +function deleteCurrentRegion() { + if (activeAnnotationId) handleAnnotationDeleteRequest(activeAnnotationId); +} + +// Disarm the draw cue whenever we leave edit mode. +$effect(() => { + if (shouldDisarmDraw(panelMode)) drawArmed = false; +}); + +const shortcutOptions = { + isPanelOpen: () => transcribeMode, + isCheatsheetOpen: () => cheatsheetOpen, + panelMode: () => panelMode, + goToNextRegion: () => goToRegion(1), + goToPrevRegion: () => goToRegion(-1), + toggleMode, + closePanel: () => (transcribeMode = false), + startDrawMode: () => { + if (canArmDraw(panelMode)) drawArmed = true; + }, + toggleTrainingMark, + deleteCurrentRegion, + openCheatsheet: () => (cheatsheetOpen = true) +}; + function handleBlockFocus(blockId: string) { const block = transcription.blocks.find((b) => b.id === blockId); if (block) { @@ -208,14 +260,9 @@ onMount(() => { onStripUrl: () => replaceState(page.url.pathname, page.state ?? {}) }).catch((e) => console.error('deep-link scroll failed', e)); - function onKeyDown(e: KeyboardEvent) { - if (e.key === 'Escape' && transcribeMode) { - transcribeMode = false; - } - } - document.addEventListener('keydown', onKeyDown); + // Esc is owned solely by the transcribeShortcuts action (issue #327, decision + // B1) — no competing inline keydown listener here. return () => { - document.removeEventListener('keydown', onKeyDown); ocrJob.destroy(); }; }); @@ -229,6 +276,7 @@ onMount(() => { class="fixed right-0 bottom-0 left-0 z-40 flex flex-col overflow-hidden bg-surface" style="top: {navHeight}px" data-hydrated + use:transcribeShortcuts={shortcutOptions} > { bind:activeAnnotationId={activeAnnotationId} onAnnotationClick={handleAnnotationClick} onTranscriptionDraw={createBlockFromDraw} - onDeleteAnnotationRequest={handleAnnotationDeleteRequest} + onAnnotationFocus={(id) => (activeAnnotationId = id)} /> @@ -369,4 +417,15 @@ onMount(() => { {/if} + + {#if drawArmed} + + {m.shortcut_draw_hint()} + + {/if} + + (cheatsheetOpen = false)} />
+ {m.cheatsheet_autosave_hint()} +
+ {m.transcribe_coach_shortcut_hint_before()} + ? + {m.transcribe_coach_shortcut_hint_after()} +