feat(transcribe): add transcribeShortcuts keyboard action (#327)
Single-owner window keydown action for the Transcribe panel: j/k region nav, e mode toggle, n draw (edit only), t training mark, Delete, ? cheat- sheet, and the Esc precedence ladder (cheatsheet → editable no-op → close panel). Pure input-to-callback translator with a focus guard that exempts only "?"; removes its listener on destroy. 20 unit tests cover every key, the panel/focus guards, the Esc matrix, and teardown. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
96
frontend/src/lib/shared/actions/transcribeShortcuts.ts
Normal file
96
frontend/src/lib/shared/actions/transcribeShortcuts.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user