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

@@ -43,7 +43,6 @@ describe('AnnotationShape', () => {
isHovered: true,
isActive: true,
showDelete: true,
onDeleteRequest: vi.fn(),
onclick: () => {},
onpointerenter: () => {},
onpointerleave: () => {}
@@ -57,16 +56,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 +74,58 @@ 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;
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();
});
});