feat(transcribe): keyboard shortcuts for the transcribe power path + cheatsheet overlay (#327) #728

Merged
marcel merged 12 commits from feat/issue-327-transcribe-shortcuts into main 2026-06-04 17:54:26 +02:00
22 changed files with 1120 additions and 34 deletions

View File

@@ -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');
});
});

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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();
</script>
@@ -104,7 +104,7 @@ let {
flashAnnotationId={flashAnnotationId}
onAnnotationClick={onAnnotationClick}
onTranscriptionDraw={onTranscriptionDraw}
onDeleteAnnotationRequest={onDeleteAnnotationRequest}
onAnnotationFocus={onAnnotationFocus}
documentFileHash={doc.fileHash ?? null}
/>
{:else if fileUrl}

View File

@@ -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)}
/>

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import type { Annotation } from '$lib/shared/types';
import { m } from '$lib/paraglide/messages.js';
import AnnotationEditOverlay from './AnnotationEditOverlay.svelte';
let {
@@ -12,8 +13,8 @@ let {
isFlashing = false,
isResizable = false,
showDelete = false,
onDeleteRequest,
onclick,
onfocus,
onpointerenter,
onpointerleave
}: {
@@ -26,12 +27,19 @@ let {
isFlashing?: boolean;
isResizable?: boolean;
showDelete?: boolean;
onDeleteRequest?: () => void;
onclick: () => void;
onfocus?: () => void;
onpointerenter: () => void;
onpointerleave: () => void;
} = $props();
// When deletion is available (transcribe mode), announce the otherwise-hidden
// Delete affordance to assistive tech (issue #327). The transcribeShortcuts
// action is the single owner of the key itself.
const ariaLabel = $derived(
showDelete ? m.annotation_label_with_delete() : m.annotation_view_label()
);
function hexToRgba(hex: string, alpha: number): string {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
@@ -83,11 +91,12 @@ let shapeStyle = $derived(
class:annotation-flash={isFlashing}
role="button"
tabindex="0"
aria-label="Block anzeigen"
aria-label={ariaLabel}
aria-keyshortcuts={showDelete ? 'Delete' : undefined}
onclick={onclick}
onfocus={onfocus}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') onclick();
if (e.key === 'Delete' && showDelete) onDeleteRequest?.();
}}
onpointerenter={onpointerenter}
onpointerleave={onpointerleave}

View File

@@ -2,9 +2,32 @@ import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import AnnotationShape from './AnnotationShape.svelte';
import {
transcribeShortcuts,
type TranscribeShortcutOptions
} from '$lib/shared/actions/transcribeShortcuts';
afterEach(cleanup);
function noopShortcutOptions(
overrides: Partial<TranscribeShortcutOptions> = {}
): TranscribeShortcutOptions {
return {
isPanelOpen: () => true,
isCheatsheetOpen: () => false,
panelMode: () => 'edit',
goToNextRegion: () => {},
goToPrevRegion: () => {},
toggleMode: () => {},
closePanel: () => {},
startDrawMode: () => {},
toggleTrainingMark: () => {},
deleteCurrentRegion: () => {},
openCheatsheet: () => {},
...overrides
};
}
function makeAnnotation(id = 'ann-1') {
return {
id,
@@ -43,7 +66,6 @@ describe('AnnotationShape', () => {
isHovered: true,
isActive: true,
showDelete: true,
onDeleteRequest: vi.fn(),
onclick: () => {},
onpointerenter: () => {},
onpointerleave: () => {}
@@ -57,16 +79,17 @@ describe('AnnotationShape', () => {
expect(annotationEl.querySelectorAll('button').length).toBe(0);
});
it('calls onDeleteRequest when Delete key is pressed on the annotation', async () => {
const onDeleteRequest = vi.fn();
// Deletion is owned solely by the transcribeShortcuts action (issue #327,
// decision: action is the single Delete owner). The shape must NOT handle
// the Delete key itself, or the key would delete twice.
it('does not act on the Delete key itself (the action owns deletion)', async () => {
const onclick = vi.fn();
render(AnnotationShape, {
annotation: makeAnnotation(),
isHovered: false,
isActive: true,
showDelete: true,
onDeleteRequest,
onclick: () => {},
onclick,
onpointerenter: () => {},
onpointerleave: () => {}
});
@@ -74,26 +97,83 @@ describe('AnnotationShape', () => {
const annotationEl = page.getByTestId('annotation-ann-1').element() as HTMLElement;
annotationEl.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', bubbles: true }));
expect(onDeleteRequest).toHaveBeenCalledOnce();
// No side effect from the shape; it stays in the document for the action to act on.
expect(onclick).not.toHaveBeenCalled();
await expect.element(page.getByTestId('annotation-ann-1')).toBeInTheDocument();
});
it('does not call onDeleteRequest on Delete key when showDelete is false', async () => {
const onDeleteRequest = vi.fn();
it('announces the Delete affordance via aria when deletion is available', async () => {
render(AnnotationShape, {
annotation: makeAnnotation(),
isHovered: false,
isActive: true,
showDelete: false,
onDeleteRequest,
showDelete: true,
onclick: () => {},
onpointerenter: () => {},
onpointerleave: () => {}
});
const annotationEl = page.getByTestId('annotation-ann-1').element() as HTMLElement;
expect(annotationEl.getAttribute('aria-keyshortcuts')).toBe('Delete');
expect(annotationEl.getAttribute('aria-label')).toContain('Entf');
});
it('keeps the plain label and no key hint when deletion is unavailable', async () => {
render(AnnotationShape, {
annotation: makeAnnotation(),
isHovered: false,
isActive: false,
showDelete: false,
onclick: () => {},
onpointerenter: () => {},
onpointerleave: () => {}
});
const annotationEl = page.getByTestId('annotation-ann-1').element() as HTMLElement;
expect(annotationEl.getAttribute('aria-keyshortcuts')).toBe(null);
expect(annotationEl.getAttribute('aria-label')).toBe('Block anzeigen');
});
it('calls onfocus when the annotation receives focus', async () => {
const onfocus = vi.fn();
render(AnnotationShape, {
annotation: makeAnnotation(),
isHovered: false,
isActive: false,
onfocus,
onclick: () => {},
onpointerenter: () => {},
onpointerleave: () => {}
});
const annotationEl = page.getByTestId('annotation-ann-1').element() as HTMLElement;
annotationEl.dispatchEvent(new FocusEvent('focus'));
expect(onfocus).toHaveBeenCalledOnce();
});
// Integration: a real rendered shape + the live transcribeShortcuts action.
// Pressing Delete on the focused region must delete exactly once — proving the
// action is the single owner and the shape contributes no competing handler.
it('with the transcribeShortcuts action active, Delete deletes the focused region exactly once', () => {
const deleteCurrentRegion = vi.fn();
render(AnnotationShape, {
annotation: makeAnnotation(),
isHovered: false,
isActive: true,
showDelete: true,
onclick: () => {},
onpointerenter: () => {},
onpointerleave: () => {}
});
const annotationEl = page.getByTestId('annotation-ann-1').element() as HTMLElement;
const action = transcribeShortcuts(annotationEl, noopShortcutOptions({ deleteCurrentRegion }));
annotationEl.focus();
annotationEl.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', bubbles: true }));
expect(onDeleteRequest).not.toHaveBeenCalled();
expect(deleteCurrentRegion).toHaveBeenCalledTimes(1);
action.destroy();
});
});

View File

@@ -0,0 +1,104 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
let { open = false, onClose }: { open?: boolean; onClose: () => void } = $props();
let dialogEl = $state<HTMLDialogElement>();
let closeButton = $state<HTMLButtonElement>();
// Grouped navigation / editing / utility — whitespace dividers, no headers.
const groups = [
[
{ cap: 'j', label: m.shortcut_next_region() },
{ cap: 'k', label: m.shortcut_prev_region() }
],
[
{ cap: 'e', label: m.shortcut_toggle_mode() },
{ cap: 'n', label: m.shortcut_new_region() },
{ cap: 't', label: m.shortcut_toggle_training() },
{ cap: m.key_cap_delete(), label: m.shortcut_delete_region() }
],
[
{ cap: 'Esc', label: m.shortcut_close_panel() },
{ cap: '?', label: m.shortcut_help() }
]
];
$effect(() => {
const el = dialogEl;
if (!el) return;
if (open && !el.open) {
el.showModal();
closeButton?.focus();
} else if (!open && el.open) {
el.close();
}
});
function handleBackdropClick(event: MouseEvent) {
if (event.target === dialogEl) onClose();
}
</script>
<dialog
bind:this={dialogEl}
aria-modal="true"
aria-labelledby="cheatsheet-title"
class="w-[calc(100%-2rem)] max-w-md rounded-sm border border-line bg-surface p-6 shadow-lg backdrop:bg-black/40"
onclose={onClose}
onclick={handleBackdropClick}
>
<div class="mb-5 flex items-center justify-between">
<h2 id="cheatsheet-title" class="font-serif text-lg font-bold text-ink">
{m.cheatsheet_title()}
</h2>
<button
bind:this={closeButton}
type="button"
onclick={onClose}
aria-label={m.cheatsheet_close()}
class="flex h-11 w-11 items-center justify-center rounded-sm text-ink-2 hover:bg-muted focus-visible:ring-2 focus-visible:ring-brand-mint focus-visible:outline-none"
>
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-width="2" d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
</div>
<div class="divide-y divide-line">
{#each groups as group, i (i)}
<div class="flex flex-col gap-2 py-3 first:pt-0 last:pb-0">
{#each group as shortcut (shortcut.cap)}
<div class="flex items-center justify-between gap-4">
<kbd
class="rounded border border-line bg-muted px-2 py-0.5 font-mono text-sm text-ink shadow-sm"
>{shortcut.cap}</kbd
>
<span class="flex-1 text-right font-serif text-sm text-ink">{shortcut.label}</span>
</div>
{/each}
</div>
{/each}
</div>
<p class="mt-5 border-t border-line pt-3.5 font-sans text-xs text-ink-3">
{m.cheatsheet_autosave_hint()}
</p>
</dialog>
<style>
@media (prefers-reduced-motion: no-preference) {
dialog[open] {
animation: fadeIn 150ms ease;
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
</style>

View File

@@ -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 <dialog> 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);
});
});

View File

@@ -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);
});
});

View File

@@ -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);
}

View File

@@ -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');
});
});

View File

@@ -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];
}

View File

@@ -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
});
});
});

View File

@@ -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 };
}

View File

@@ -20,7 +20,7 @@ let {
activeAnnotationId = $bindable<string | null>(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}
</div>

View File

@@ -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> = {}
): 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 <input>', () => {
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();
});
});
});

View File

@@ -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 <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);
}
};
}

View File

@@ -72,5 +72,13 @@ import TranscribeDragDemo from './TranscribeDragDemo.svelte';
{m.transcribe_coach_footer_richtlinien()}
<span class="ml-1 text-[11px] text-ink-3">{m.common_opens_new_tab()}</span>
</a>
<p class="w-full text-ink-3 [@media(pointer:coarse)]:hidden">
{m.transcribe_coach_shortcut_hint_before()}
<kbd
class="rounded border border-line bg-muted px-1.5 py-0.5 font-mono text-xs text-ink shadow-sm"
>?</kbd
>
{m.transcribe_coach_shortcut_hint_after()}
</p>
</div>
</div>

View File

@@ -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 });

View File

@@ -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<string | null>(null);
let highlightBlockId = $state<string | null>(null);
let flashAnnotationId = $state<string | null>(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}
>
<DocumentTopBar
doc={doc}
@@ -260,7 +308,7 @@ onMount(() => {
bind:activeAnnotationId={activeAnnotationId}
onAnnotationClick={handleAnnotationClick}
onTranscriptionDraw={createBlockFromDraw}
onDeleteAnnotationRequest={handleAnnotationDeleteRequest}
onAnnotationFocus={(id) => (activeAnnotationId = id)}
/>
</div>
@@ -369,4 +417,15 @@ onMount(() => {
</div>
{/if}
</div>
{#if drawArmed}
<div
class="pointer-events-none absolute bottom-4 left-1/2 z-50 -translate-x-1/2 rounded-full bg-ink px-4 py-2 font-sans text-xs text-white shadow-lg"
role="status"
>
{m.shortcut_draw_hint()}
</div>
{/if}
<ShortcutCheatsheet open={cheatsheetOpen} onClose={() => (cheatsheetOpen = false)} />
</div>