feat(transcribe): keyboard shortcuts for the transcribe power path + cheatsheet overlay (#327) #728
163
frontend/e2e/transcribe-shortcuts.spec.ts
Normal file
163
frontend/e2e/transcribe-shortcuts.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
14
frontend/src/lib/document/transcription/drawCue.spec.ts
Normal file
14
frontend/src/lib/document/transcription/drawCue.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
20
frontend/src/lib/document/transcription/drawCue.ts
Normal file
20
frontend/src/lib/document/transcription/drawCue.ts
Normal 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);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
33
frontend/src/lib/document/transcription/regionNavigation.ts
Normal file
33
frontend/src/lib/document/transcription/regionNavigation.ts
Normal 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];
|
||||
}
|
||||
30
frontend/src/lib/document/transcription/trainingMark.spec.ts
Normal file
30
frontend/src/lib/document/transcription/trainingMark.spec.ts
Normal 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
|
||||
});
|
||||
});
|
||||
});
|
||||
31
frontend/src/lib/document/transcription/trainingMark.ts
Normal file
31
frontend/src/lib/document/transcription/trainingMark.ts
Normal 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 };
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
100
frontend/src/lib/shared/actions/transcribeShortcuts.ts
Normal file
100
frontend/src/lib/shared/actions/transcribeShortcuts.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user