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:
@@ -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,17 @@ 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() : 'Block anzeigen');
|
||||
|
||||
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 +89,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}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user