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 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-07 11:14:53 +02:00
parent 7d98081390
commit 306eef2e95
2 changed files with 171 additions and 0 deletions

View File

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