feat(transcribe): keyboard shortcuts for the transcribe power path + cheatsheet overlay (#327) #728

Merged
marcel merged 12 commits from feat/issue-327-transcribe-shortcuts into main 2026-06-04 17:54:26 +02:00
5 changed files with 103 additions and 12 deletions
Showing only changes of commit 52f0babb2e - Show all commits

View 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);
});
});

View 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);
}

View 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
});
});
});

View 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 };
}

View File

@@ -13,6 +13,8 @@ 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';
@@ -112,17 +114,9 @@ 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);
const toggle = resolveTrainingMark(activeAnnotationId, doc.trainingLabels ?? []);
if (toggle) transcription.toggleTrainingLabel(toggle.label, toggle.enrolled);
}
function deleteCurrentRegion() {
@@ -131,7 +125,7 @@ function deleteCurrentRegion() {
// Disarm the draw cue whenever we leave edit mode.
$effect(() => {
if (panelMode !== 'edit') drawArmed = false;
if (shouldDisarmDraw(panelMode)) drawArmed = false;
});
const shortcutOptions = {
@@ -142,7 +136,9 @@ const shortcutOptions = {
goToPrevRegion: () => goToRegion(-1),
toggleMode,
closePanel: () => (transcribeMode = false),
startDrawMode: () => (drawArmed = true),
startDrawMode: () => {
if (canArmDraw(panelMode)) drawArmed = true;
},
toggleTrainingMark,
deleteCurrentRegion,
openCheatsheet: () => (cheatsheetOpen = true)