From 306eef2e9555ebabd431254508235411a9806193 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 7 Apr 2026 11:14:53 +0200 Subject: [PATCH] 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); + }); +});