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" } diff --git a/frontend/src/lib/components/AnnotationLayer.svelte b/frontend/src/lib/components/AnnotationLayer.svelte index 2afe3b47..ec1c29c0 100644 --- a/frontend/src/lib/components/AnnotationLayer.svelte +++ b/frontend/src/lib/components/AnnotationLayer.svelte @@ -14,6 +14,8 @@ let { color, blockNumbers = {}, activeAnnotationId = null, + dimmed = false, + flashAnnotationId = null, onDraw, onAnnotationClick }: { @@ -22,6 +24,8 @@ let { color: string; blockNumbers?: Record; activeAnnotationId?: string | null; + dimmed?: boolean; + flashAnnotationId?: string | null; onDraw: (rect: DrawRect) => void; onAnnotationClick?: (id: string) => void; } = $props(); @@ -108,6 +112,7 @@ const containerStyle = $derived(
- {#if blockNumbers[annotation.id]} + {#if !dimmed && blockNumbers[annotation.id]}
{ + it('should apply annotation-flash class when flashAnnotationId matches', async () => { + render(AnnotationLayer, { + annotations: [annotation], + canDraw: false, + color: '#00c7b1', + flashAnnotationId: 'ann-1', + onDraw: () => {} + }); + + const el = document.querySelector('[data-testid="annotation-ann-1"]')!; + expect(el.classList.contains('annotation-flash')).toBe(true); + }); + + it('should not apply annotation-flash class when flashAnnotationId does not match', async () => { + render(AnnotationLayer, { + annotations: [annotation], + canDraw: false, + color: '#00c7b1', + flashAnnotationId: 'other-id', + onDraw: () => {} + }); + + const el = document.querySelector('[data-testid="annotation-ann-1"]')!; + expect(el.classList.contains('annotation-flash')).toBe(false); + }); + }); +}); diff --git a/frontend/src/lib/components/DocumentViewer.svelte b/frontend/src/lib/components/DocumentViewer.svelte index 6266f975..c48d1f2a 100644 --- a/frontend/src/lib/components/DocumentViewer.svelte +++ b/frontend/src/lib/components/DocumentViewer.svelte @@ -20,6 +20,8 @@ type Props = { blockNumbers?: Record; annotationReloadKey?: number; activeAnnotationId: string | null; + annotationsDimmed?: boolean; + flashAnnotationId?: string | null; onAnnotationClick: (id: string) => void; onTranscriptionDraw?: (rect: DrawRect) => void; }; @@ -33,6 +35,8 @@ let { blockNumbers = {}, annotationReloadKey = 0, activeAnnotationId = $bindable(), + annotationsDimmed = false, + flashAnnotationId = null, onAnnotationClick, onTranscriptionDraw }: Props = $props(); @@ -90,6 +94,8 @@ let { blockNumbers={blockNumbers} 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 2eac4308..7705620b 100644 --- a/frontend/src/lib/components/PdfViewer.svelte +++ b/frontend/src/lib/components/PdfViewer.svelte @@ -16,7 +16,9 @@ let { activeAnnotationId = $bindable(null), onAnnotationClick, onTranscriptionDraw, - documentFileHash + documentFileHash, + annotationsDimmed = false, + flashAnnotationId = null }: { url: string; documentId?: string; @@ -27,6 +29,8 @@ let { onAnnotationClick?: (id: string) => void; onTranscriptionDraw?: (rect: DrawRect) => void; documentFileHash?: string | null; + annotationsDimmed?: boolean; + flashAnnotationId?: string | null; } = $props(); let pdfDoc = $state(null); @@ -456,6 +460,8 @@ function zoomOut() { color={TRANSCRIPTION_COLOR} blockNumbers={blockNumbers} activeAnnotationId={activeAnnotationId} + dimmed={annotationsDimmed} + flashAnnotationId={flashAnnotationId} onDraw={handleDraw} onAnnotationClick={handleAnnotationClick} /> diff --git a/frontend/src/lib/components/TranscriptionPanelHeader.svelte b/frontend/src/lib/components/TranscriptionPanelHeader.svelte new file mode 100644 index 00000000..c3ceeb69 --- /dev/null +++ b/frontend/src/lib/components/TranscriptionPanelHeader.svelte @@ -0,0 +1,96 @@ + + +
+ +
+ + +
+ + + + + + +
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..1a5ba2ad --- /dev/null +++ b/frontend/src/lib/components/TranscriptionPanelHeader.svelte.test.ts @@ -0,0 +1,151 @@ +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(); + }); + + it('should show "0 Abschnitte" when blockCount is 0', async () => { + render(TranscriptionPanelHeader, { + mode: 'edit', + hasBlocks: false, + blockCount: 0, + lastEditedAt: null, + onModeChange: () => {}, + onClose: () => {} + }); + + await expect.element(page.getByText('0 Abschnitte')).toBeInTheDocument(); + }); + + it('should have close button with 44px touch target classes', async () => { + render(TranscriptionPanelHeader, { + mode: 'read', + hasBlocks: true, + blockCount: 3, + lastEditedAt: null, + onModeChange: () => {}, + onClose: () => {} + }); + + const closeBtn = document.querySelector('[data-testid="panel-close"]') as HTMLElement; + expect(closeBtn.classList.contains('h-11')).toBe(true); + expect(closeBtn.classList.contains('w-11')).toBe(true); + }); + + it('should show formatted date when lastEditedAt is provided', async () => { + render(TranscriptionPanelHeader, { + mode: 'read', + hasBlocks: true, + blockCount: 3, + lastEditedAt: '2026-04-07T10:00:00Z', + onModeChange: () => {}, + onClose: () => {} + }); + + const statusText = document.querySelector('.hidden.md\\:block'); + expect(statusText).not.toBeNull(); + expect(statusText!.textContent).toContain('2026'); + }); +}); diff --git a/frontend/src/lib/components/TranscriptionReadView.svelte b/frontend/src/lib/components/TranscriptionReadView.svelte new file mode 100644 index 00000000..f1586e6c --- /dev/null +++ b/frontend/src/lib/components/TranscriptionReadView.svelte @@ -0,0 +1,58 @@ + + +
+ {#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..70823be2 --- /dev/null +++ b/frontend/src/lib/components/TranscriptionReadView.svelte.test.ts @@ -0,0 +1,142 @@ +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 apply flash-highlight class when highlightBlockId matches', async () => { + render(TranscriptionReadView, { + blocks: [blocks[0]], + onParagraphClick: () => {}, + highlightBlockId: 'b1' + }); + + const el = document.querySelector('[data-block-id="b1"]')!; + expect(el.classList.contains('flash-highlight')).toBe(true); + }); + + it('should not apply flash-highlight class when highlightBlockId does not match', async () => { + render(TranscriptionReadView, { + blocks: [blocks[0]], + onParagraphClick: () => {}, + highlightBlockId: 'other-id' + }); + + const el = document.querySelector('[data-block-id="b1"]')!; + expect(el.classList.contains('flash-highlight')).toBe(false); + }); + + 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); + }); +}); 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 = { 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; +} diff --git a/frontend/src/routes/documents/[id]/+page.svelte b/frontend/src/routes/documents/[id]/+page.svelte index a3af67a0..0717353c 100644 --- a/frontend/src/routes/documents/[id]/+page.svelte +++ b/frontend/src/routes/documents/[id]/+page.svelte @@ -1,8 +1,11 @@