From a94df4b225da92307b3fb90244eeec1e40b1fd97 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 7 Apr 2026 10:57:47 +0200 Subject: [PATCH 01/20] feat(i18n): add read mode translation keys for de/en/es Co-Authored-By: Claude Sonnet 4.6 --- frontend/messages/de.json | 13 ++++++++++++- frontend/messages/en.json | 13 ++++++++++++- frontend/messages/es.json | 13 ++++++++++++- 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/frontend/messages/de.json b/frontend/messages/de.json index ea422b13..a7018bdc 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -468,5 +468,16 @@ "transcription_quote_stale": "Zitat aus älterer Version", "transcription_block_conflict": "Dieser Block wurde von jemand anderem geändert — bitte neu laden", "sort_dir_asc": "Aufsteigend sortieren", - "sort_dir_desc": "Absteigend sortieren" + "sort_dir_desc": "Absteigend sortieren", + "mode_read": "Lesen", + "mode_edit": "Bearbeiten", + "mode_edit_short": "Bearb.", + "transcription_status_section": "1 Abschnitt", + "transcription_status_sections": "{count} Abschnitte", + "transcription_status_last_edited": "Zuletzt bearbeitet: {time}", + "scan_expand": "Scan vergrößern", + "scan_collapse": "Scan verkleinern", + "transcription_empty_title": "Noch keine Transkription", + "transcription_empty_desc": "Zeichne Bereiche auf dem Scan und tippe den Text ab, um eine Transkription zu erstellen.", + "transcription_panel_close": "Panel schließen" } diff --git a/frontend/messages/en.json b/frontend/messages/en.json index c58d2061..0cedda03 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -468,5 +468,16 @@ "transcription_quote_stale": "Quote from an older version", "transcription_block_conflict": "This block was changed by someone else — please reload", "sort_dir_asc": "Sort ascending", - "sort_dir_desc": "Sort descending" + "sort_dir_desc": "Sort descending", + "mode_read": "Read", + "mode_edit": "Edit", + "mode_edit_short": "Edit", + "transcription_status_section": "1 section", + "transcription_status_sections": "{count} sections", + "transcription_status_last_edited": "Last edited: {time}", + "scan_expand": "Expand scan", + "scan_collapse": "Collapse scan", + "transcription_empty_title": "No transcription yet", + "transcription_empty_desc": "Draw regions on the scan and type the text to create a transcription.", + "transcription_panel_close": "Close panel" } diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 10c0b414..3c16f293 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -468,5 +468,16 @@ "transcription_quote_stale": "Cita de una versión anterior", "transcription_block_conflict": "Este bloque fue cambiado por otra persona — por favor recargue", "sort_dir_asc": "Ordenar ascendente", - "sort_dir_desc": "Ordenar descendente" + "sort_dir_desc": "Ordenar descendente", + "mode_read": "Leer", + "mode_edit": "Editar", + "mode_edit_short": "Edit.", + "transcription_status_section": "1 seccion", + "transcription_status_sections": "{count} secciones", + "transcription_status_last_edited": "Ultima edicion: {time}", + "scan_expand": "Ampliar escaneo", + "scan_collapse": "Reducir escaneo", + "transcription_empty_title": "Sin transcripcion", + "transcription_empty_desc": "Dibuja regiones en el escaneo y escribe el texto para crear una transcripcion.", + "transcription_panel_close": "Cerrar panel" } -- 2.49.1 From f38c384268073e6d644315b4eb4894fcbf7d30f1 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 7 Apr 2026 10:58:34 +0200 Subject: [PATCH 02/20] feat(types): add updatedAt to TranscriptionBlockData Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/types.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 490f5352..7ddfd95c 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -35,6 +35,7 @@ export type TranscriptionBlockData = { label: string | null; sortOrder: number; version: number; + updatedAt?: string | null; }; export type Annotation = { -- 2.49.1 From 3279342ea747c17994d134290853cf49a627eb96 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 7 Apr 2026 11:00:23 +0200 Subject: [PATCH 03/20] feat(util): add splitByMarkers for [unleserlich] and [...] text splitting Co-Authored-By: Claude Sonnet 4.6 --- .../lib/utils/transcriptionMarkers.spec.ts | 60 +++++++++++++++++++ .../src/lib/utils/transcriptionMarkers.ts | 25 ++++++++ 2 files changed, 85 insertions(+) create mode 100644 frontend/src/lib/utils/transcriptionMarkers.spec.ts create mode 100644 frontend/src/lib/utils/transcriptionMarkers.ts diff --git a/frontend/src/lib/utils/transcriptionMarkers.spec.ts b/frontend/src/lib/utils/transcriptionMarkers.spec.ts new file mode 100644 index 00000000..0f82b728 --- /dev/null +++ b/frontend/src/lib/utils/transcriptionMarkers.spec.ts @@ -0,0 +1,60 @@ +import { describe, it, expect } from 'vitest'; +import { splitByMarkers } from './transcriptionMarkers'; + +describe('splitByMarkers', () => { + it('should return single text segment for plain text', () => { + const result = splitByMarkers('Hello world'); + expect(result).toEqual([{ type: 'text', text: 'Hello world' }]); + }); + + it('should split [unleserlich] into a marker segment', () => { + const result = splitByMarkers('before [unleserlich] after'); + expect(result).toEqual([ + { type: 'text', text: 'before ' }, + { type: 'marker', text: '[unleserlich]' }, + { type: 'text', text: ' after' } + ]); + }); + + it('should split [...] into a marker segment', () => { + const result = splitByMarkers('some text [...] more text'); + expect(result).toEqual([ + { type: 'text', text: 'some text ' }, + { type: 'marker', text: '[...]' }, + { type: 'text', text: ' more text' } + ]); + }); + + it('should handle multiple markers in one string', () => { + const result = splitByMarkers('[unleserlich] middle [...] end'); + expect(result).toEqual([ + { type: 'marker', text: '[unleserlich]' }, + { type: 'text', text: ' middle ' }, + { type: 'marker', text: '[...]' }, + { type: 'text', text: ' end' } + ]); + }); + + it('should handle text that is only a marker', () => { + const result = splitByMarkers('[unleserlich]'); + expect(result).toEqual([{ type: 'marker', text: '[unleserlich]' }]); + }); + + it('should handle empty string', () => { + const result = splitByMarkers(''); + expect(result).toEqual([]); + }); + + it('should not match other bracket markers', () => { + const result = splitByMarkers('text [Seitenumbruch] more'); + expect(result).toEqual([{ type: 'text', text: 'text [Seitenumbruch] more' }]); + }); + + it('should handle adjacent markers', () => { + const result = splitByMarkers('[unleserlich][...]'); + expect(result).toEqual([ + { type: 'marker', text: '[unleserlich]' }, + { type: 'marker', text: '[...]' } + ]); + }); +}); diff --git a/frontend/src/lib/utils/transcriptionMarkers.ts b/frontend/src/lib/utils/transcriptionMarkers.ts new file mode 100644 index 00000000..e1f4a26b --- /dev/null +++ b/frontend/src/lib/utils/transcriptionMarkers.ts @@ -0,0 +1,25 @@ +export type TextSegment = { type: 'text' | 'marker'; text: string }; + +const MARKER_PATTERN = /(\[unleserlich\]|\[\.{3}\])/g; + +export function splitByMarkers(input: string): TextSegment[] { + if (!input) return []; + + const segments: TextSegment[] = []; + let lastIndex = 0; + + for (const match of input.matchAll(MARKER_PATTERN)) { + const matchStart = match.index; + if (matchStart > lastIndex) { + segments.push({ type: 'text', text: input.slice(lastIndex, matchStart) }); + } + segments.push({ type: 'marker', text: match[0] }); + lastIndex = matchStart + match[0].length; + } + + if (lastIndex < input.length) { + segments.push({ type: 'text', text: input.slice(lastIndex) }); + } + + return segments; +} -- 2.49.1 From d070ae2612fbd042ce0a73cc3333890c613c3227 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 7 Apr 2026 11:07:23 +0200 Subject: [PATCH 04/20] feat(annotation): add dimmed prop to AnnotationLayer Hides block number badges and disables hover/active visual feedback when dimmed=true. Click handlers remain active for scroll-sync. Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/components/AnnotationLayer.svelte | 10 +-- .../components/AnnotationLayer.svelte.test.ts | 67 +++++++++++++++++++ 2 files changed, 73 insertions(+), 4 deletions(-) create mode 100644 frontend/src/lib/components/AnnotationLayer.svelte.test.ts diff --git a/frontend/src/lib/components/AnnotationLayer.svelte b/frontend/src/lib/components/AnnotationLayer.svelte index 2afe3b47..cb862772 100644 --- a/frontend/src/lib/components/AnnotationLayer.svelte +++ b/frontend/src/lib/components/AnnotationLayer.svelte @@ -14,6 +14,7 @@ let { color, blockNumbers = {}, activeAnnotationId = null, + dimmed = false, onDraw, onAnnotationClick }: { @@ -22,6 +23,7 @@ let { color: string; blockNumbers?: Record; activeAnnotationId?: string | null; + dimmed?: boolean; onDraw: (rect: DrawRect) => void; onAnnotationClick?: (id: string) => void; } = $props(); @@ -123,15 +125,15 @@ const containerStyle = $derived( top: {annotation.y * 100}%; width: {annotation.width * 100}%; height: {annotation.height * 100}%; - background-color: {hexToRgba(annotation.color, hoveredId === annotation.id || annotation.id === activeAnnotationId ? 0.5 : 0.3)}; - box-shadow: {annotation.id === activeAnnotationId ? `inset 0 0 0 2px ${hexToRgba(annotation.color, 0.8)}` : hoveredId === annotation.id ? `inset 0 0 0 2px ${hexToRgba(annotation.color, 0.8)}` : 'none'}; - opacity: {activeAnnotationId && annotation.id !== activeAnnotationId ? 0.3 : 1}; + background-color: {hexToRgba(annotation.color, dimmed ? 0.3 : (hoveredId === annotation.id || annotation.id === activeAnnotationId ? 0.5 : 0.3))}; + box-shadow: {dimmed ? 'none' : (annotation.id === activeAnnotationId ? `inset 0 0 0 2px ${hexToRgba(annotation.color, 0.8)}` : hoveredId === annotation.id ? `inset 0 0 0 2px ${hexToRgba(annotation.color, 0.8)}` : 'none')}; + opacity: {dimmed ? 1 : (activeAnnotationId && annotation.id !== activeAnnotationId ? 0.3 : 1)}; pointer-events: auto; cursor: pointer; transition: background-color 0.15s ease, box-shadow 0.15s ease, opacity 0.3s ease; " > - {#if blockNumbers[annotation.id]} + {#if !dimmed && blockNumbers[annotation.id]}
Date: Tue, 7 Apr 2026 11:10:39 +0200 Subject: [PATCH 05/20] feat(ui): add TranscriptionPanelHeader with mode toggle and status Segmented Lesen/Bearbeiten control, block count, last-edited date, and close button. Lesen disabled when no blocks exist. Co-Authored-By: Claude Sonnet 4.6 --- .../TranscriptionPanelHeader.svelte | 94 +++++++++++++++ .../TranscriptionPanelHeader.svelte.test.ts | 108 ++++++++++++++++++ 2 files changed, 202 insertions(+) create mode 100644 frontend/src/lib/components/TranscriptionPanelHeader.svelte create mode 100644 frontend/src/lib/components/TranscriptionPanelHeader.svelte.test.ts diff --git a/frontend/src/lib/components/TranscriptionPanelHeader.svelte b/frontend/src/lib/components/TranscriptionPanelHeader.svelte new file mode 100644 index 00000000..6b001378 --- /dev/null +++ b/frontend/src/lib/components/TranscriptionPanelHeader.svelte @@ -0,0 +1,94 @@ + + +
+ +
+ + +
+ + +

+ {#if blockCount === 1} + {m.transcription_status_section()} + {:else} + {m.transcription_status_sections({ count: blockCount })} + {/if} + {#if formattedDate} + · {m.transcription_status_last_edited({ time: formattedDate })} + {/if} +

+ + + +
diff --git a/frontend/src/lib/components/TranscriptionPanelHeader.svelte.test.ts b/frontend/src/lib/components/TranscriptionPanelHeader.svelte.test.ts new file mode 100644 index 00000000..c2d12e9d --- /dev/null +++ b/frontend/src/lib/components/TranscriptionPanelHeader.svelte.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import TranscriptionPanelHeader from './TranscriptionPanelHeader.svelte'; + +describe('TranscriptionPanelHeader', () => { + it('should render Lesen and Bearbeiten buttons', async () => { + render(TranscriptionPanelHeader, { + mode: 'read', + hasBlocks: true, + blockCount: 3, + lastEditedAt: null, + onModeChange: () => {}, + onClose: () => {} + }); + + await expect.element(page.getByText('Lesen')).toBeInTheDocument(); + await expect.element(page.getByText('Bearbeiten')).toBeInTheDocument(); + }); + + it('should disable Lesen button when hasBlocks is false', async () => { + render(TranscriptionPanelHeader, { + mode: 'edit', + hasBlocks: false, + blockCount: 0, + lastEditedAt: null, + onModeChange: () => {}, + onClose: () => {} + }); + + const lesenBtn = document.querySelector('[data-testid="mode-read"]') as HTMLButtonElement; + expect(lesenBtn.getAttribute('aria-disabled')).toBe('true'); + }); + + it('should call onModeChange when clicking Bearbeiten', async () => { + const onModeChange = vi.fn(); + render(TranscriptionPanelHeader, { + mode: 'read', + hasBlocks: true, + blockCount: 3, + lastEditedAt: null, + onModeChange, + onClose: () => {} + }); + + const editBtn = document.querySelector('[data-testid="mode-edit"]')!; + editBtn.dispatchEvent(new MouseEvent('click', { bubbles: true })); + expect(onModeChange).toHaveBeenCalledWith('edit'); + }); + + it('should not call onModeChange when clicking disabled Lesen', async () => { + const onModeChange = vi.fn(); + render(TranscriptionPanelHeader, { + mode: 'edit', + hasBlocks: false, + blockCount: 0, + lastEditedAt: null, + onModeChange, + onClose: () => {} + }); + + const readBtn = document.querySelector('[data-testid="mode-read"]')!; + readBtn.dispatchEvent(new MouseEvent('click', { bubbles: true })); + expect(onModeChange).not.toHaveBeenCalled(); + }); + + it('should call onClose when clicking close button', async () => { + const onClose = vi.fn(); + render(TranscriptionPanelHeader, { + mode: 'read', + hasBlocks: true, + blockCount: 3, + lastEditedAt: null, + onModeChange: () => {}, + onClose + }); + + const closeBtn = document.querySelector('[data-testid="panel-close"]')!; + closeBtn.dispatchEvent(new MouseEvent('click', { bubbles: true })); + expect(onClose).toHaveBeenCalled(); + }); + + it('should show singular block count for 1 block', async () => { + render(TranscriptionPanelHeader, { + mode: 'read', + hasBlocks: true, + blockCount: 1, + lastEditedAt: null, + onModeChange: () => {}, + onClose: () => {} + }); + + await expect.element(page.getByText('1 Abschnitt')).toBeInTheDocument(); + }); + + it('should show plural block count for multiple blocks', async () => { + render(TranscriptionPanelHeader, { + mode: 'read', + hasBlocks: true, + blockCount: 5, + lastEditedAt: null, + onModeChange: () => {}, + onClose: () => {} + }); + + await expect.element(page.getByText('5 Abschnitte')).toBeInTheDocument(); + }); +}); -- 2.49.1 From 306eef2e9555ebabd431254508235411a9806193 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 7 Apr 2026 11:14:53 +0200 Subject: [PATCH 06/20] feat(ui): add TranscriptionReadView for flowing prose display Renders transcription blocks as readable text with [unleserlich]/[...] markers styled as italic muted text. Supports click-to-sync and flash highlight for scroll-sync feedback. Co-Authored-By: Claude Sonnet 4.6 --- .../components/TranscriptionReadView.svelte | 51 ++++++++ .../TranscriptionReadView.svelte.test.ts | 120 ++++++++++++++++++ 2 files changed, 171 insertions(+) create mode 100644 frontend/src/lib/components/TranscriptionReadView.svelte create mode 100644 frontend/src/lib/components/TranscriptionReadView.svelte.test.ts diff --git a/frontend/src/lib/components/TranscriptionReadView.svelte b/frontend/src/lib/components/TranscriptionReadView.svelte new file mode 100644 index 00000000..e70077d0 --- /dev/null +++ b/frontend/src/lib/components/TranscriptionReadView.svelte @@ -0,0 +1,51 @@ + + +
+ {#each sorted as block (block.id)} +
onParagraphClick(block.annotationId)} + role="button" + tabindex="0" + onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') onParagraphClick(block.annotationId); }} + > + {#each splitByMarkers(block.text) as segment, i (i)} + {#if segment.type === 'marker'} + {segment.text} + {:else} + {segment.text} + {/if} + {/each} +
+ {/each} +
+ + diff --git a/frontend/src/lib/components/TranscriptionReadView.svelte.test.ts b/frontend/src/lib/components/TranscriptionReadView.svelte.test.ts new file mode 100644 index 00000000..7c9b3d76 --- /dev/null +++ b/frontend/src/lib/components/TranscriptionReadView.svelte.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import TranscriptionReadView from './TranscriptionReadView.svelte'; +import type { TranscriptionBlockData } from '$lib/types'; + +const blocks: TranscriptionBlockData[] = [ + { + id: 'b1', + annotationId: 'ann-1', + documentId: 'doc-1', + text: 'First paragraph text.', + label: null, + sortOrder: 1, + version: 1 + }, + { + id: 'b2', + annotationId: 'ann-2', + documentId: 'doc-1', + text: 'Second paragraph text.', + label: null, + sortOrder: 2, + version: 1 + } +]; + +describe('TranscriptionReadView', () => { + it('should render one paragraph per block', async () => { + render(TranscriptionReadView, { + blocks, + onParagraphClick: () => {} + }); + + await expect.element(page.getByText('First paragraph text.')).toBeInTheDocument(); + await expect.element(page.getByText('Second paragraph text.')).toBeInTheDocument(); + + const paragraphs = document.querySelectorAll('[data-block-id]'); + expect(paragraphs.length).toBe(2); + }); + + it('should render [unleserlich] as italic muted text', async () => { + render(TranscriptionReadView, { + blocks: [ + { + id: 'b1', + annotationId: 'ann-1', + documentId: 'doc-1', + text: 'Text before [unleserlich] text after', + label: null, + sortOrder: 1, + version: 1 + } + ], + onParagraphClick: () => {} + }); + + const marker = document.querySelector('[data-marker]'); + expect(marker).not.toBeNull(); + expect(marker!.textContent).toBe('[unleserlich]'); + expect(marker!.tagName.toLowerCase()).toBe('em'); + }); + + it('should render [...] as italic muted text', async () => { + render(TranscriptionReadView, { + blocks: [ + { + id: 'b1', + annotationId: 'ann-1', + documentId: 'doc-1', + text: 'Some [...] text', + label: null, + sortOrder: 1, + version: 1 + } + ], + onParagraphClick: () => {} + }); + + const marker = document.querySelector('[data-marker]'); + expect(marker).not.toBeNull(); + expect(marker!.textContent).toBe('[...]'); + }); + + it('should call onParagraphClick with annotationId when paragraph is clicked', async () => { + const onParagraphClick = vi.fn(); + render(TranscriptionReadView, { + blocks, + onParagraphClick + }); + + const paragraph = document.querySelector('[data-block-id="b1"]')!; + paragraph.dispatchEvent(new MouseEvent('click', { bubbles: true })); + expect(onParagraphClick).toHaveBeenCalledWith('ann-1'); + }); + + it('should render blocks sorted by sortOrder', async () => { + render(TranscriptionReadView, { + blocks: [ + { ...blocks[1], sortOrder: 1 }, + { ...blocks[0], sortOrder: 2 } + ], + onParagraphClick: () => {} + }); + + const paragraphs = document.querySelectorAll('[data-block-id]'); + expect(paragraphs[0].getAttribute('data-block-id')).toBe('b2'); + expect(paragraphs[1].getAttribute('data-block-id')).toBe('b1'); + }); + + it('should render empty state when no blocks', async () => { + render(TranscriptionReadView, { + blocks: [], + onParagraphClick: () => {} + }); + + const paragraphs = document.querySelectorAll('[data-block-id]'); + expect(paragraphs.length).toBe(0); + }); +}); -- 2.49.1 From e089192d7a8f7c7dc69d3bbbb5ae07ab53085d99 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 7 Apr 2026 11:21:15 +0200 Subject: [PATCH 07/20] feat(ui): wire panelMode state with read/edit view switching Adds TranscriptionPanelHeader and TranscriptionReadView to the document detail page. Default mode is 'read' when blocks exist, 'edit' otherwise. Annotations dimmed in read mode. Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/components/DocumentViewer.svelte | 3 + frontend/src/lib/components/PdfViewer.svelte | 5 +- .../src/routes/documents/[id]/+page.svelte | 76 +++++++++++++++---- 3 files changed, 69 insertions(+), 15 deletions(-) diff --git a/frontend/src/lib/components/DocumentViewer.svelte b/frontend/src/lib/components/DocumentViewer.svelte index 6266f975..707ae0ba 100644 --- a/frontend/src/lib/components/DocumentViewer.svelte +++ b/frontend/src/lib/components/DocumentViewer.svelte @@ -20,6 +20,7 @@ type Props = { blockNumbers?: Record; annotationReloadKey?: number; activeAnnotationId: string | null; + annotationsDimmed?: boolean; onAnnotationClick: (id: string) => void; onTranscriptionDraw?: (rect: DrawRect) => void; }; @@ -33,6 +34,7 @@ let { blockNumbers = {}, annotationReloadKey = 0, activeAnnotationId = $bindable(), + annotationsDimmed = false, onAnnotationClick, onTranscriptionDraw }: Props = $props(); @@ -90,6 +92,7 @@ let { blockNumbers={blockNumbers} annotationReloadKey={annotationReloadKey} bind:activeAnnotationId={activeAnnotationId} + annotationsDimmed={annotationsDimmed} onAnnotationClick={onAnnotationClick} onTranscriptionDraw={onTranscriptionDraw} documentFileHash={doc.fileHash ?? null} diff --git a/frontend/src/lib/components/PdfViewer.svelte b/frontend/src/lib/components/PdfViewer.svelte index 2eac4308..05373848 100644 --- a/frontend/src/lib/components/PdfViewer.svelte +++ b/frontend/src/lib/components/PdfViewer.svelte @@ -16,7 +16,8 @@ let { activeAnnotationId = $bindable(null), onAnnotationClick, onTranscriptionDraw, - documentFileHash + documentFileHash, + annotationsDimmed = false }: { url: string; documentId?: string; @@ -27,6 +28,7 @@ let { onAnnotationClick?: (id: string) => void; onTranscriptionDraw?: (rect: DrawRect) => void; documentFileHash?: string | null; + annotationsDimmed?: boolean; } = $props(); let pdfDoc = $state(null); @@ -456,6 +458,7 @@ function zoomOut() { color={TRANSCRIPTION_COLOR} blockNumbers={blockNumbers} activeAnnotationId={activeAnnotationId} + dimmed={annotationsDimmed} onDraw={handleDraw} onAnnotationClick={handleAnnotationClick} /> diff --git a/frontend/src/routes/documents/[id]/+page.svelte b/frontend/src/routes/documents/[id]/+page.svelte index a3af67a0..f4f5d4eb 100644 --- a/frontend/src/routes/documents/[id]/+page.svelte +++ b/frontend/src/routes/documents/[id]/+page.svelte @@ -3,6 +3,8 @@ import { onMount } from 'svelte'; import DocumentTopBar from '$lib/components/DocumentTopBar.svelte'; import DocumentViewer from '$lib/components/DocumentViewer.svelte'; import TranscriptionEditView from '$lib/components/TranscriptionEditView.svelte'; +import TranscriptionReadView from '$lib/components/TranscriptionReadView.svelte'; +import TranscriptionPanelHeader from '$lib/components/TranscriptionPanelHeader.svelte'; import type { TranscriptionBlockData } from '$lib/types'; let { data } = $props(); @@ -49,7 +51,9 @@ async function loadFile(id: string) { // ── Mode state ─────────────────────────────────────────────────────────────── let transcribeMode = $state(false); +let panelMode = $state<'read' | 'edit'>('read'); let activeAnnotationId = $state(null); +let highlightBlockId = $state(null); // ── Transcription blocks ───────────────────────────────────────────────────── @@ -64,6 +68,17 @@ const blockNumbers = $derived( ) ); +const hasBlocks = $derived(transcriptionBlocks.length > 0); + +const lastEditedAt = $derived.by(() => { + if (transcriptionBlocks.length === 0) return null; + const dates = transcriptionBlocks + .filter((b) => b.updatedAt) + .map((b) => new Date(b.updatedAt!).getTime()); + if (dates.length === 0) return null; + return new Date(Math.max(...dates)).toISOString(); +}); + async function loadTranscriptionBlocks() { if (!doc?.id) return; try { @@ -142,9 +157,17 @@ async function handleAnnotationClick(annotationId: string) { await loadTranscriptionBlocks(); } - // Wait for DOM to render the blocks, then scroll to the matching one + // In read mode, highlight the matching paragraph + const block = transcriptionBlocks.find((b) => b.annotationId === annotationId); + if (block) { + highlightBlockId = block.id; + setTimeout(() => { + highlightBlockId = null; + }, 1500); + } + + // Wait for DOM to render, then scroll to the matching block requestAnimationFrame(() => { - const block = transcriptionBlocks.find((b) => b.annotationId === annotationId); if (block) { const el = document.querySelector(`[data-block-id="${block.id}"]`); el?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); @@ -152,10 +175,16 @@ async function handleAnnotationClick(annotationId: string) { }); } -// Load blocks when transcribe mode is entered +function handleParagraphClick(annotationId: string) { + activeAnnotationId = annotationId; +} + +// Load blocks when transcribe mode is entered and set default panel mode $effect(() => { if (transcribeMode) { - loadTranscriptionBlocks(); + loadTranscriptionBlocks().then(() => { + panelMode = transcriptionBlocks.length > 0 ? 'read' : 'edit'; + }); } }); @@ -211,6 +240,7 @@ onMount(() => { transcribeMode={transcribeMode} blockNumbers={blockNumbers} annotationReloadKey={annotationReloadKey} + annotationsDimmed={transcribeMode && panelMode === 'read'} bind:activeAnnotationId={activeAnnotationId} onAnnotationClick={handleAnnotationClick} onTranscriptionDraw={createBlockFromDraw} @@ -219,18 +249,36 @@ onMount(() => { {#if transcribeMode}
- (panelMode = m)} + onClose={() => (transcribeMode = false)} /> +
+ {#if panelMode === 'read'} + + {:else} + + {/if} +
{/if}
-- 2.49.1 From 81b14e50264749e7f0cb57efaeaee85515c84d9e Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 7 Apr 2026 11:25:23 +0200 Subject: [PATCH 08/20] feat(ui): add bidirectional scroll-sync with flash animations Paragraph click flashes the PDF annotation outline (1.5s fade). Annotation click highlights the paragraph with a background flash. Both directions scroll the target into view. Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/components/AnnotationLayer.svelte | 20 +++++++++++++++++++ .../src/lib/components/DocumentViewer.svelte | 3 +++ frontend/src/lib/components/PdfViewer.svelte | 5 ++++- .../src/routes/documents/[id]/+page.svelte | 6 ++++++ 4 files changed, 33 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/components/AnnotationLayer.svelte b/frontend/src/lib/components/AnnotationLayer.svelte index cb862772..4dbe4c28 100644 --- a/frontend/src/lib/components/AnnotationLayer.svelte +++ b/frontend/src/lib/components/AnnotationLayer.svelte @@ -15,6 +15,7 @@ let { blockNumbers = {}, activeAnnotationId = null, dimmed = false, + flashAnnotationId = null, onDraw, onAnnotationClick }: { @@ -24,6 +25,7 @@ let { blockNumbers?: Record; activeAnnotationId?: string | null; dimmed?: boolean; + flashAnnotationId?: string | null; onDraw: (rect: DrawRect) => void; onAnnotationClick?: (id: string) => void; } = $props(); @@ -110,6 +112,7 @@ const containerStyle = $derived(
{/if} + + diff --git a/frontend/src/lib/components/DocumentViewer.svelte b/frontend/src/lib/components/DocumentViewer.svelte index 707ae0ba..c48d1f2a 100644 --- a/frontend/src/lib/components/DocumentViewer.svelte +++ b/frontend/src/lib/components/DocumentViewer.svelte @@ -21,6 +21,7 @@ type Props = { annotationReloadKey?: number; activeAnnotationId: string | null; annotationsDimmed?: boolean; + flashAnnotationId?: string | null; onAnnotationClick: (id: string) => void; onTranscriptionDraw?: (rect: DrawRect) => void; }; @@ -35,6 +36,7 @@ let { annotationReloadKey = 0, activeAnnotationId = $bindable(), annotationsDimmed = false, + flashAnnotationId = null, onAnnotationClick, onTranscriptionDraw }: Props = $props(); @@ -93,6 +95,7 @@ let { annotationReloadKey={annotationReloadKey} bind:activeAnnotationId={activeAnnotationId} annotationsDimmed={annotationsDimmed} + flashAnnotationId={flashAnnotationId} onAnnotationClick={onAnnotationClick} onTranscriptionDraw={onTranscriptionDraw} documentFileHash={doc.fileHash ?? null} diff --git a/frontend/src/lib/components/PdfViewer.svelte b/frontend/src/lib/components/PdfViewer.svelte index 05373848..7705620b 100644 --- a/frontend/src/lib/components/PdfViewer.svelte +++ b/frontend/src/lib/components/PdfViewer.svelte @@ -17,7 +17,8 @@ let { onAnnotationClick, onTranscriptionDraw, documentFileHash, - annotationsDimmed = false + annotationsDimmed = false, + flashAnnotationId = null }: { url: string; documentId?: string; @@ -29,6 +30,7 @@ let { onTranscriptionDraw?: (rect: DrawRect) => void; documentFileHash?: string | null; annotationsDimmed?: boolean; + flashAnnotationId?: string | null; } = $props(); let pdfDoc = $state(null); @@ -459,6 +461,7 @@ function zoomOut() { blockNumbers={blockNumbers} activeAnnotationId={activeAnnotationId} dimmed={annotationsDimmed} + flashAnnotationId={flashAnnotationId} onDraw={handleDraw} onAnnotationClick={handleAnnotationClick} /> diff --git a/frontend/src/routes/documents/[id]/+page.svelte b/frontend/src/routes/documents/[id]/+page.svelte index f4f5d4eb..2b1005a9 100644 --- a/frontend/src/routes/documents/[id]/+page.svelte +++ b/frontend/src/routes/documents/[id]/+page.svelte @@ -54,6 +54,7 @@ let transcribeMode = $state(false); let panelMode = $state<'read' | 'edit'>('read'); let activeAnnotationId = $state(null); let highlightBlockId = $state(null); +let flashAnnotationId = $state(null); // ── Transcription blocks ───────────────────────────────────────────────────── @@ -177,6 +178,10 @@ async function handleAnnotationClick(annotationId: string) { function handleParagraphClick(annotationId: string) { activeAnnotationId = annotationId; + flashAnnotationId = annotationId; + setTimeout(() => { + flashAnnotationId = null; + }, 1500); } // Load blocks when transcribe mode is entered and set default panel mode @@ -241,6 +246,7 @@ onMount(() => { blockNumbers={blockNumbers} annotationReloadKey={annotationReloadKey} annotationsDimmed={transcribeMode && panelMode === 'read'} + flashAnnotationId={flashAnnotationId} bind:activeAnnotationId={activeAnnotationId} onAnnotationClick={handleAnnotationClick} onTranscriptionDraw={createBlockFromDraw} -- 2.49.1 From 10cecb01f54dcb8f8e7c48f9a57b32f481ba0f0f Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 7 Apr 2026 11:27:01 +0200 Subject: [PATCH 09/20] feat(a11y): respect prefers-reduced-motion for scroll-sync Uses scrollIntoView behavior 'instant' instead of 'smooth', skips CSS animations (static highlight instead), and extends timeout to 2s for reduced-motion users. Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/components/AnnotationLayer.svelte | 7 ++++++ .../components/TranscriptionReadView.svelte | 7 ++++++ .../src/routes/documents/[id]/+page.svelte | 25 +++++++++++++------ 3 files changed, 32 insertions(+), 7 deletions(-) diff --git a/frontend/src/lib/components/AnnotationLayer.svelte b/frontend/src/lib/components/AnnotationLayer.svelte index 4dbe4c28..dcaddf15 100644 --- a/frontend/src/lib/components/AnnotationLayer.svelte +++ b/frontend/src/lib/components/AnnotationLayer.svelte @@ -194,4 +194,11 @@ const containerStyle = $derived( .annotation-flash { animation: annotation-flash-anim 1.5s ease-out; } + +@media (prefers-reduced-motion: reduce) { + .annotation-flash { + animation: none; + outline: 3px solid rgba(0, 199, 177, 0.8); + } +} diff --git a/frontend/src/lib/components/TranscriptionReadView.svelte b/frontend/src/lib/components/TranscriptionReadView.svelte index e70077d0..e9876a58 100644 --- a/frontend/src/lib/components/TranscriptionReadView.svelte +++ b/frontend/src/lib/components/TranscriptionReadView.svelte @@ -48,4 +48,11 @@ let sorted = $derived([...blocks].sort((a, b) => a.sortOrder - b.sortOrder)); .flash-highlight { animation: flash 1.2s ease-out; } + +@media (prefers-reduced-motion: reduce) { + .flash-highlight { + animation: none; + background-color: rgba(0, 199, 177, 0.18); + } +} diff --git a/frontend/src/routes/documents/[id]/+page.svelte b/frontend/src/routes/documents/[id]/+page.svelte index 2b1005a9..bc1b2890 100644 --- a/frontend/src/routes/documents/[id]/+page.svelte +++ b/frontend/src/routes/documents/[id]/+page.svelte @@ -56,6 +56,10 @@ let activeAnnotationId = $state(null); let highlightBlockId = $state(null); let flashAnnotationId = $state(null); +const prefersReducedMotion = $derived( + typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches +); + // ── Transcription blocks ───────────────────────────────────────────────────── let transcriptionBlocks = $state([]); @@ -162,16 +166,20 @@ async function handleAnnotationClick(annotationId: string) { const block = transcriptionBlocks.find((b) => b.annotationId === annotationId); if (block) { highlightBlockId = block.id; - setTimeout(() => { - highlightBlockId = null; - }, 1500); + setTimeout( + () => { + highlightBlockId = null; + }, + prefersReducedMotion ? 2000 : 1500 + ); } // Wait for DOM to render, then scroll to the matching block + const scrollBehavior = prefersReducedMotion ? 'instant' : 'smooth'; requestAnimationFrame(() => { if (block) { const el = document.querySelector(`[data-block-id="${block.id}"]`); - el?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + el?.scrollIntoView({ behavior: scrollBehavior, block: 'nearest' }); } }); } @@ -179,9 +187,12 @@ async function handleAnnotationClick(annotationId: string) { function handleParagraphClick(annotationId: string) { activeAnnotationId = annotationId; flashAnnotationId = annotationId; - setTimeout(() => { - flashAnnotationId = null; - }, 1500); + setTimeout( + () => { + flashAnnotationId = null; + }, + prefersReducedMotion ? 2000 : 1500 + ); } // Load blocks when transcribe mode is entered and set default panel mode -- 2.49.1 From 4d5b8b4eadf6eebb74032247aa7c762f60597209 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 7 Apr 2026 11:30:36 +0200 Subject: [PATCH 10/20] feat(ui): add collapsible PDF strip and abbreviated labels on mobile PDF viewer collapses to 70px on mobile in read mode, expandable to 50vh. Toggle button with chevron. Paragraph tap auto-expands strip. Mode toggle abbreviates to "Bearb." on small screens. Co-Authored-By: Claude Sonnet 4.6 --- .../TranscriptionPanelHeader.svelte | 7 +++-- .../src/routes/documents/[id]/+page.svelte | 30 +++++++++++++++++-- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/frontend/src/lib/components/TranscriptionPanelHeader.svelte b/frontend/src/lib/components/TranscriptionPanelHeader.svelte index 6b001378..fd574baf 100644 --- a/frontend/src/lib/components/TranscriptionPanelHeader.svelte +++ b/frontend/src/lib/components/TranscriptionPanelHeader.svelte @@ -54,12 +54,13 @@ function handleReadClick() { ? 'bg-primary text-primary-fg' : 'text-ink-2 hover:text-ink'}" > - {m.mode_edit()} + {m.mode_edit_short()} + - -

+ +