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:
51
frontend/src/lib/components/TranscriptionReadView.svelte
Normal file
51
frontend/src/lib/components/TranscriptionReadView.svelte
Normal file
@@ -0,0 +1,51 @@
|
||||
<script lang="ts">
|
||||
import type { TranscriptionBlockData } from '$lib/types';
|
||||
import { splitByMarkers } from '$lib/utils/transcriptionMarkers';
|
||||
|
||||
interface Props {
|
||||
blocks: TranscriptionBlockData[];
|
||||
onParagraphClick: (annotationId: string) => void;
|
||||
highlightBlockId?: string | null;
|
||||
}
|
||||
|
||||
let { blocks, onParagraphClick, highlightBlockId = null }: Props = $props();
|
||||
|
||||
let sorted = $derived([...blocks].sort((a, b) => a.sortOrder - b.sortOrder));
|
||||
</script>
|
||||
|
||||
<article class="px-6 py-8">
|
||||
{#each sorted as block (block.id)}
|
||||
<div
|
||||
class="-mx-2 mb-6 cursor-pointer rounded-sm px-2 py-1 font-serif text-[16px] leading-[1.85] text-ink transition-colors hover:bg-[rgba(0,199,177,0.06)]"
|
||||
class:flash-highlight={highlightBlockId === block.id}
|
||||
data-block-id={block.id}
|
||||
onclick={() => 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'}
|
||||
<em data-marker class="text-ink-2 italic">{segment.text}</em>
|
||||
{:else}
|
||||
{segment.text}
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</article>
|
||||
|
||||
<style>
|
||||
@keyframes flash {
|
||||
0% {
|
||||
background-color: rgba(0, 199, 177, 0.18);
|
||||
}
|
||||
100% {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.flash-highlight {
|
||||
animation: flash 1.2s ease-out;
|
||||
}
|
||||
</style>
|
||||
120
frontend/src/lib/components/TranscriptionReadView.svelte.test.ts
Normal file
120
frontend/src/lib/components/TranscriptionReadView.svelte.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user