feat(transcribe): wire keyboard shortcuts into the document panel (#327)

Attaches the transcribeShortcuts action to the document page and wires every
command to existing context setters: j/k walk the sortOrder-sorted regions
and set activeAnnotationId, e toggles read/edit, n arms a draw cue (edit
only), Delete routes to the existing confirm path, ? opens the cheatsheet,
and Esc is now owned solely by the action — the inline onMount Esc listener
is removed (decision B1). Renders ShortcutCheatsheet and a draw-armed hint.

"t" toggles the document-level KURRENT_RECOGNITION training enrollment (the
only training surface that exists; there is no per-region flag yet — see
#321) and no-ops unless a region is active. Also reconciles annotation
Delete: the shape no longer self-handles the key, with onfocus syncing the
active region so the action deletes exactly once.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-06-04 16:56:36 +02:00
committed by marcel
parent 6aaf8ddb9e
commit 61256942e1
6 changed files with 142 additions and 35 deletions

View File

@@ -25,7 +25,7 @@ type Props = {
flashAnnotationId?: string | null; flashAnnotationId?: string | null;
onAnnotationClick: (id: string) => void; onAnnotationClick: (id: string) => void;
onTranscriptionDraw?: (rect: DrawRect) => void; onTranscriptionDraw?: (rect: DrawRect) => void;
onDeleteAnnotationRequest?: (annotationId: string) => void; onAnnotationFocus?: (id: string) => void;
}; };
let { let {
@@ -42,7 +42,7 @@ let {
flashAnnotationId = null, flashAnnotationId = null,
onAnnotationClick, onAnnotationClick,
onTranscriptionDraw, onTranscriptionDraw,
onDeleteAnnotationRequest onAnnotationFocus
}: Props = $props(); }: Props = $props();
</script> </script>
@@ -104,7 +104,7 @@ let {
flashAnnotationId={flashAnnotationId} flashAnnotationId={flashAnnotationId}
onAnnotationClick={onAnnotationClick} onAnnotationClick={onAnnotationClick}
onTranscriptionDraw={onTranscriptionDraw} onTranscriptionDraw={onTranscriptionDraw}
onDeleteAnnotationRequest={onDeleteAnnotationRequest} onAnnotationFocus={onAnnotationFocus}
documentFileHash={doc.fileHash ?? null} documentFileHash={doc.fileHash ?? null}
/> />
{:else if fileUrl} {:else if fileUrl}

View File

@@ -19,7 +19,7 @@ let {
flashAnnotationId = null, flashAnnotationId = null,
onDraw, onDraw,
onAnnotationClick, onAnnotationClick,
onDeleteRequest onAnnotationFocus
}: { }: {
annotations: Annotation[]; annotations: Annotation[];
canDraw: boolean; canDraw: boolean;
@@ -30,7 +30,7 @@ let {
flashAnnotationId?: string | null; flashAnnotationId?: string | null;
onDraw: (rect: DrawRect) => void; onDraw: (rect: DrawRect) => void;
onAnnotationClick?: (id: string) => void; onAnnotationClick?: (id: string) => void;
onDeleteRequest?: (annotationId: string) => void; onAnnotationFocus?: (id: string) => void;
} = $props(); } = $props();
let drawStart = $state<{ x: number; y: number } | null>(null); let drawStart = $state<{ x: number; y: number } | null>(null);
@@ -115,8 +115,8 @@ const containerStyle = $derived(
blockNumber={blockNumbers[annotation.id]} blockNumber={blockNumbers[annotation.id]}
isFlashing={flashAnnotationId === annotation.id} isFlashing={flashAnnotationId === annotation.id}
showDelete={canDraw} showDelete={canDraw}
onDeleteRequest={() => onDeleteRequest?.(annotation.id)}
onclick={() => onAnnotationClick?.(annotation.id)} onclick={() => onAnnotationClick?.(annotation.id)}
onfocus={() => onAnnotationFocus?.(annotation.id)}
onpointerenter={() => (hoveredId = annotation.id)} onpointerenter={() => (hoveredId = annotation.id)}
onpointerleave={() => (hoveredId = null)} onpointerleave={() => (hoveredId = null)}
/> />

View File

@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { Annotation } from '$lib/shared/types'; import type { Annotation } from '$lib/shared/types';
import { m } from '$lib/paraglide/messages.js';
import AnnotationEditOverlay from './AnnotationEditOverlay.svelte'; import AnnotationEditOverlay from './AnnotationEditOverlay.svelte';
let { let {
@@ -12,8 +13,8 @@ let {
isFlashing = false, isFlashing = false,
isResizable = false, isResizable = false,
showDelete = false, showDelete = false,
onDeleteRequest,
onclick, onclick,
onfocus,
onpointerenter, onpointerenter,
onpointerleave onpointerleave
}: { }: {
@@ -26,12 +27,17 @@ let {
isFlashing?: boolean; isFlashing?: boolean;
isResizable?: boolean; isResizable?: boolean;
showDelete?: boolean; showDelete?: boolean;
onDeleteRequest?: () => void;
onclick: () => void; onclick: () => void;
onfocus?: () => void;
onpointerenter: () => void; onpointerenter: () => void;
onpointerleave: () => void; onpointerleave: () => void;
} = $props(); } = $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() : 'Block anzeigen');
function hexToRgba(hex: string, alpha: number): string { function hexToRgba(hex: string, alpha: number): string {
const r = parseInt(hex.slice(1, 3), 16); const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16); const g = parseInt(hex.slice(3, 5), 16);
@@ -83,11 +89,12 @@ let shapeStyle = $derived(
class:annotation-flash={isFlashing} class:annotation-flash={isFlashing}
role="button" role="button"
tabindex="0" tabindex="0"
aria-label="Block anzeigen" aria-label={ariaLabel}
aria-keyshortcuts={showDelete ? 'Delete' : undefined}
onclick={onclick} onclick={onclick}
onfocus={onfocus}
onkeydown={(e) => { onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') onclick(); if (e.key === 'Enter' || e.key === ' ') onclick();
if (e.key === 'Delete' && showDelete) onDeleteRequest?.();
}} }}
onpointerenter={onpointerenter} onpointerenter={onpointerenter}
onpointerleave={onpointerleave} onpointerleave={onpointerleave}

View File

@@ -43,7 +43,6 @@ describe('AnnotationShape', () => {
isHovered: true, isHovered: true,
isActive: true, isActive: true,
showDelete: true, showDelete: true,
onDeleteRequest: vi.fn(),
onclick: () => {}, onclick: () => {},
onpointerenter: () => {}, onpointerenter: () => {},
onpointerleave: () => {} onpointerleave: () => {}
@@ -57,16 +56,17 @@ describe('AnnotationShape', () => {
expect(annotationEl.querySelectorAll('button').length).toBe(0); expect(annotationEl.querySelectorAll('button').length).toBe(0);
}); });
it('calls onDeleteRequest when Delete key is pressed on the annotation', async () => { // Deletion is owned solely by the transcribeShortcuts action (issue #327,
const onDeleteRequest = vi.fn(); // 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, { render(AnnotationShape, {
annotation: makeAnnotation(), annotation: makeAnnotation(),
isHovered: false, isHovered: false,
isActive: true, isActive: true,
showDelete: true, showDelete: true,
onDeleteRequest, onclick,
onclick: () => {},
onpointerenter: () => {}, onpointerenter: () => {},
onpointerleave: () => {} onpointerleave: () => {}
}); });
@@ -74,26 +74,58 @@ describe('AnnotationShape', () => {
const annotationEl = page.getByTestId('annotation-ann-1').element() as HTMLElement; const annotationEl = page.getByTestId('annotation-ann-1').element() as HTMLElement;
annotationEl.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', bubbles: true })); 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 () => { it('announces the Delete affordance via aria when deletion is available', async () => {
const onDeleteRequest = vi.fn();
render(AnnotationShape, { render(AnnotationShape, {
annotation: makeAnnotation(), annotation: makeAnnotation(),
isHovered: false, isHovered: false,
isActive: true, isActive: true,
showDelete: false, showDelete: true,
onDeleteRequest,
onclick: () => {}, onclick: () => {},
onpointerenter: () => {}, onpointerenter: () => {},
onpointerleave: () => {} onpointerleave: () => {}
}); });
const annotationEl = page.getByTestId('annotation-ann-1').element() as HTMLElement; const annotationEl = page.getByTestId('annotation-ann-1').element() as HTMLElement;
annotationEl.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', bubbles: true })); expect(annotationEl.getAttribute('aria-keyshortcuts')).toBe('Delete');
expect(annotationEl.getAttribute('aria-label')).toContain('Entf');
});
expect(onDeleteRequest).not.toHaveBeenCalled(); 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();
}); });
}); });

View File

@@ -20,7 +20,7 @@ let {
activeAnnotationId = $bindable<string | null>(null), activeAnnotationId = $bindable<string | null>(null),
onAnnotationClick, onAnnotationClick,
onTranscriptionDraw, onTranscriptionDraw,
onDeleteAnnotationRequest, onAnnotationFocus,
documentFileHash, documentFileHash,
annotationsDimmed = false, annotationsDimmed = false,
flashAnnotationId = null, flashAnnotationId = null,
@@ -35,7 +35,7 @@ let {
activeAnnotationId?: string | null; activeAnnotationId?: string | null;
onAnnotationClick?: (id: string) => void; onAnnotationClick?: (id: string) => void;
onTranscriptionDraw?: (rect: DrawRect) => void; onTranscriptionDraw?: (rect: DrawRect) => void;
onDeleteAnnotationRequest?: (annotationId: string) => void; onAnnotationFocus?: (id: string) => void;
documentFileHash?: string | null; documentFileHash?: string | null;
annotationsDimmed?: boolean; annotationsDimmed?: boolean;
flashAnnotationId?: string | null; flashAnnotationId?: string | null;
@@ -294,7 +294,7 @@ function handleAnnotationClick(id: string) {
flashAnnotationId={flashAnnotationId} flashAnnotationId={flashAnnotationId}
onDraw={handleDraw} onDraw={handleDraw}
onAnnotationClick={handleAnnotationClick} onAnnotationClick={handleAnnotationClick}
onDeleteRequest={onDeleteAnnotationRequest} onAnnotationFocus={onAnnotationFocus}
/> />
{/if} {/if}
</div> </div>

View File

@@ -8,6 +8,8 @@ import DocumentViewer from '$lib/document/DocumentViewer.svelte';
import TranscriptionEditView from '$lib/document/transcription/TranscriptionEditView.svelte'; import TranscriptionEditView from '$lib/document/transcription/TranscriptionEditView.svelte';
import TranscriptionReadView from '$lib/document/transcription/TranscriptionReadView.svelte'; import TranscriptionReadView from '$lib/document/transcription/TranscriptionReadView.svelte';
import TranscriptionPanelHeader from '$lib/document/transcription/TranscriptionPanelHeader.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 { createOcrJob } from '$lib/ocr/useOcrJob.svelte';
import { createTranscriptionBlocks } from '$lib/document/transcription/useTranscriptionBlocks.svelte'; import { createTranscriptionBlocks } from '$lib/document/transcription/useTranscriptionBlocks.svelte';
import { createFileLoader } from '$lib/document/viewer/useFileLoader.svelte'; import { createFileLoader } from '$lib/document/viewer/useFileLoader.svelte';
@@ -42,6 +44,8 @@ let activeAnnotationId = $state<string | null>(null);
let highlightBlockId = $state<string | null>(null); let highlightBlockId = $state<string | null>(null);
let flashAnnotationId = $state<string | null>(null); let flashAnnotationId = $state<string | null>(null);
let pdfStripExpanded = $state(false); 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 // 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 // overwrite the panelMode it picked (e.g. forcing 'edit' on notification
// click-through). One-shot: consumed after the effect's loadBlocks resolves. // click-through). One-shot: consumed after the effect's loadBlocks resolves.
@@ -86,12 +90,69 @@ async function createBlockFromDraw(rect: {
height: number; height: number;
pageNumber: number; pageNumber: number;
}) { }) {
drawArmed = false;
const created = await transcription.createFromDraw(rect); const created = await transcription.createFromDraw(rect);
if (created) { if (created) {
activeAnnotationId = created.annotationId; activeAnnotationId = created.annotationId;
} }
} }
// ── Keyboard shortcuts (issue #327) ──────────────────────────────────────────
const sortedBlocks = $derived([...transcription.blocks].sort((a, b) => a.sortOrder - b.sortOrder));
function goToRegion(delta: 1 | -1) {
if (sortedBlocks.length === 0) return;
const current = sortedBlocks.findIndex((b) => b.annotationId === activeAnnotationId);
const next =
current === -1
? delta > 0
? 0
: sortedBlocks.length - 1
: (current + delta + sortedBlocks.length) % sortedBlocks.length;
activeAnnotationId = sortedBlocks[next].annotationId;
}
function toggleMode() {
if (canWrite) panelMode = panelMode === 'read' ? 'edit' : 'read';
}
// Training enrollment is document-level — two fixed script-type chips
// (KURRENT_RECOGNITION / KURRENT_SEGMENTATION); there is no per-region training
// flag (that would arrive with #321). "t" toggles the primary recognition
// enrollment and stays a no-op unless a region is active, so it reads as an
// action on the region the transcriber is working on.
const RECOGNITION_TRAINING_LABEL = 'KURRENT_RECOGNITION';
function toggleTrainingMark() {
if (!activeAnnotationId) return;
const enrolled = !(doc.trainingLabels ?? []).includes(RECOGNITION_TRAINING_LABEL);
transcription.toggleTrainingLabel(RECOGNITION_TRAINING_LABEL, enrolled);
}
function deleteCurrentRegion() {
if (activeAnnotationId) handleAnnotationDeleteRequest(activeAnnotationId);
}
// Disarm the draw cue whenever we leave edit mode.
$effect(() => {
if (panelMode !== 'edit') drawArmed = false;
});
const shortcutOptions = {
isPanelOpen: () => transcribeMode,
isCheatsheetOpen: () => cheatsheetOpen,
panelMode: () => panelMode,
goToNextRegion: () => goToRegion(1),
goToPrevRegion: () => goToRegion(-1),
toggleMode,
closePanel: () => (transcribeMode = false),
startDrawMode: () => (drawArmed = true),
toggleTrainingMark,
deleteCurrentRegion,
openCheatsheet: () => (cheatsheetOpen = true)
};
function handleBlockFocus(blockId: string) { function handleBlockFocus(blockId: string) {
const block = transcription.blocks.find((b) => b.id === blockId); const block = transcription.blocks.find((b) => b.id === blockId);
if (block) { if (block) {
@@ -208,14 +269,9 @@ onMount(() => {
onStripUrl: () => replaceState(page.url.pathname, page.state ?? {}) onStripUrl: () => replaceState(page.url.pathname, page.state ?? {})
}).catch((e) => console.error('deep-link scroll failed', e)); }).catch((e) => console.error('deep-link scroll failed', e));
function onKeyDown(e: KeyboardEvent) { // Esc is owned solely by the transcribeShortcuts action (issue #327, decision
if (e.key === 'Escape' && transcribeMode) { // B1) — no competing inline keydown listener here.
transcribeMode = false;
}
}
document.addEventListener('keydown', onKeyDown);
return () => { return () => {
document.removeEventListener('keydown', onKeyDown);
ocrJob.destroy(); ocrJob.destroy();
}; };
}); });
@@ -229,6 +285,7 @@ onMount(() => {
class="fixed right-0 bottom-0 left-0 z-40 flex flex-col overflow-hidden bg-surface" class="fixed right-0 bottom-0 left-0 z-40 flex flex-col overflow-hidden bg-surface"
style="top: {navHeight}px" style="top: {navHeight}px"
data-hydrated data-hydrated
use:transcribeShortcuts={shortcutOptions}
> >
<DocumentTopBar <DocumentTopBar
doc={doc} doc={doc}
@@ -260,7 +317,7 @@ onMount(() => {
bind:activeAnnotationId={activeAnnotationId} bind:activeAnnotationId={activeAnnotationId}
onAnnotationClick={handleAnnotationClick} onAnnotationClick={handleAnnotationClick}
onTranscriptionDraw={createBlockFromDraw} onTranscriptionDraw={createBlockFromDraw}
onDeleteAnnotationRequest={handleAnnotationDeleteRequest} onAnnotationFocus={(id) => (activeAnnotationId = id)}
/> />
</div> </div>
@@ -369,4 +426,15 @@ onMount(() => {
</div> </div>
{/if} {/if}
</div> </div>
{#if drawArmed && panelMode === 'edit'}
<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> </div>