feat: Transcription read mode (clean split) #177 #205

Merged
marcel merged 20 commits from feat/issue-177-transcription-read-mode into main 2026-04-07 12:46:21 +02:00
15 changed files with 817 additions and 24 deletions

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -14,6 +14,8 @@ let {
color,
blockNumbers = {},
activeAnnotationId = null,
dimmed = false,
flashAnnotationId = null,
onDraw,
onAnnotationClick
}: {
@@ -22,6 +24,8 @@ let {
color: string;
blockNumbers?: Record<string, number>;
activeAnnotationId?: string | null;
dimmed?: boolean;
flashAnnotationId?: string | null;
onDraw: (rect: DrawRect) => void;
onAnnotationClick?: (id: string) => void;
} = $props();
@@ -108,6 +112,7 @@ const containerStyle = $derived(
<div
data-testid="annotation-{annotation.id}"
data-annotation
class:annotation-flash={flashAnnotationId === annotation.id}
role="button"
tabindex="0"
aria-label="Block anzeigen"
@@ -123,15 +128,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]}
<div
style="
position: absolute;
@@ -173,3 +178,27 @@ const containerStyle = $derived(
></div>
{/if}
</div>
<style>
@keyframes annotation-flash-anim {
0% {
outline: 3px solid color-mix(in srgb, var(--color-turquoise) 80%, transparent);
outline-offset: 0px;
}
100% {
outline: 3px solid color-mix(in srgb, var(--color-turquoise) 0%, transparent);
outline-offset: 2px;
}
}
.annotation-flash {
animation: annotation-flash-anim 1.5s ease-out;
}
@media (prefers-reduced-motion: reduce) {
.annotation-flash {
animation: none;
outline: 3px solid color-mix(in srgb, var(--color-turquoise) 80%, transparent);
}
}
</style>

View File

@@ -0,0 +1,95 @@
import { describe, it, expect } from 'vitest';
import { render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import AnnotationLayer from './AnnotationLayer.svelte';
import type { Annotation } from '$lib/types';
const annotation: Annotation = {
id: 'ann-1',
documentId: 'doc-1',
pageNumber: 1,
x: 0.1,
y: 0.2,
width: 0.3,
height: 0.1,
color: '#00c7b1',
createdAt: '2026-01-01T00:00:00Z'
};
describe('AnnotationLayer', () => {
describe('dimmed prop', () => {
it('should hide block number badges when dimmed is true', async () => {
render(AnnotationLayer, {
annotations: [annotation],
canDraw: false,
color: '#00c7b1',
blockNumbers: { 'ann-1': 1 },
dimmed: true,
onDraw: () => {}
});
const badge = page.getByText('1');
await expect.element(badge).not.toBeInTheDocument();
});
it('should show block number badges when dimmed is false', async () => {
render(AnnotationLayer, {
annotations: [annotation],
canDraw: false,
color: '#00c7b1',
blockNumbers: { 'ann-1': 1 },
dimmed: false,
onDraw: () => {}
});
const badge = page.getByText('1');
await expect.element(badge).toBeInTheDocument();
});
it('should still fire onAnnotationClick when dimmed', async () => {
let clickedId: string | undefined;
render(AnnotationLayer, {
annotations: [annotation],
canDraw: false,
color: '#00c7b1',
dimmed: true,
onDraw: () => {},
onAnnotationClick: (id: string) => {
clickedId = id;
}
});
const el = document.querySelector('[data-testid="annotation-ann-1"]')!;
el.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(clickedId).toBe('ann-1');
});
});
describe('flashAnnotationId prop', () => {
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);
});
});
});

View File

@@ -20,6 +20,8 @@ type Props = {
blockNumbers?: Record<string, number>;
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}

View File

@@ -16,7 +16,9 @@ let {
activeAnnotationId = $bindable<string | null>(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<PDFDocumentProxy | null>(null);
@@ -456,6 +460,8 @@ function zoomOut() {
color={TRANSCRIPTION_COLOR}
blockNumbers={blockNumbers}
activeAnnotationId={activeAnnotationId}
dimmed={annotationsDimmed}
flashAnnotationId={flashAnnotationId}
onDraw={handleDraw}
onAnnotationClick={handleAnnotationClick}
/>

View File

@@ -0,0 +1,96 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { getLocale } from '$lib/paraglide/runtime.js';
type Props = {
mode: 'read' | 'edit';
hasBlocks: boolean;
blockCount: number;
lastEditedAt: string | null;
onModeChange: (mode: 'read' | 'edit') => void;
onClose: () => void;
};
let { mode, hasBlocks, blockCount, lastEditedAt, onModeChange, onClose }: Props = $props();
const formattedDate = $derived(
lastEditedAt
? new Intl.DateTimeFormat(getLocale(), {
day: 'numeric',
month: 'short',
year: 'numeric'
}).format(new Date(lastEditedAt))
: null
);
function handleReadClick() {
if (hasBlocks) {
onModeChange('read');
}
}
</script>
<div
class="flex h-[44px] items-center justify-between border-b border-line bg-surface px-3 font-sans"
>
<!-- Segmented toggle -->
<div class="flex h-9 items-center rounded-full border border-line bg-muted p-0.5 md:h-7">
<button
type="button"
data-testid="mode-read"
aria-disabled={!hasBlocks}
onclick={handleReadClick}
class="h-full rounded-full px-3 text-xs font-semibold transition-colors {mode === 'read'
? 'bg-primary text-primary-fg'
: 'text-ink-2 hover:text-ink'}"
style:opacity={!hasBlocks ? '0.35' : undefined}
>
{m.mode_read()}
</button>
<button
type="button"
data-testid="mode-edit"
onclick={() => onModeChange('edit')}
class="h-full rounded-full px-3 text-xs font-semibold transition-colors {mode === 'edit'
? 'bg-primary text-primary-fg'
: 'text-ink-2 hover:text-ink'}"
>
<span class="md:hidden">{m.mode_edit_short()}</span>
<span class="hidden md:inline">{m.mode_edit()}</span>
</button>
</div>
<!-- Status line (hidden on mobile to save space) -->
<p class="hidden text-xs text-ink-2 md:block">
{#if blockCount === 1}
{m.transcription_status_section()}
{:else}
{m.transcription_status_sections({ count: blockCount })}
{/if}
{#if formattedDate}
<span class="ml-1"
>&middot; {m.transcription_status_last_edited({ time: formattedDate })}</span
>
{/if}
</p>
<!-- Close button -->
<button
type="button"
data-testid="panel-close"
onclick={onClose}
aria-label={m.transcription_panel_close()}
class="flex h-11 w-11 items-center justify-center rounded text-ink-2 transition-colors hover:bg-muted hover:text-ink"
>
<svg
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
aria-hidden="true"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>

View File

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

View File

@@ -0,0 +1,58 @@
<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-turquoise/10"
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: color-mix(in srgb, var(--color-turquoise) 18%, transparent);
}
100% {
background-color: transparent;
}
}
.flash-highlight {
animation: flash 1.2s ease-out;
}
@media (prefers-reduced-motion: reduce) {
.flash-highlight {
animation: none;
background-color: color-mix(in srgb, var(--color-turquoise) 18%, transparent);
}
}
</style>

View File

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

View File

@@ -35,6 +35,7 @@ export type TranscriptionBlockData = {
label: string | null;
sortOrder: number;
version: number;
updatedAt?: string | null;
};
export type Annotation = {

View File

@@ -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: '[...]' }
]);
});
});

View File

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

View File

@@ -1,8 +1,11 @@
<script lang="ts">
import { onMount } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
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 +52,15 @@ async function loadFile(id: string) {
// ── Mode state ───────────────────────────────────────────────────────────────
let transcribeMode = $state(false);
let panelMode = $state<'read' | 'edit'>('read');
let activeAnnotationId = $state<string | null>(null);
let highlightBlockId = $state<string | null>(null);
let flashAnnotationId = $state<string | null>(null);
let pdfStripExpanded = $state(false);
const prefersReducedMotion = $derived(
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches
);
// ── Transcription blocks ─────────────────────────────────────────────────────
@@ -64,6 +75,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,20 +164,46 @@ 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;
},
prefersReducedMotion ? 2000 : 1500
);
}
// Wait for DOM to render, then scroll to the matching block
const scrollBehavior = prefersReducedMotion ? 'instant' : 'smooth';
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' });
el?.scrollIntoView({ behavior: scrollBehavior, block: 'nearest' });
}
});
}
// Load blocks when transcribe mode is entered
function handleParagraphClick(annotationId: string) {
activeAnnotationId = annotationId;
flashAnnotationId = annotationId;
pdfStripExpanded = true;
setTimeout(
() => {
flashAnnotationId = null;
},
prefersReducedMotion ? 2000 : 1500
);
}
// Load blocks when transcribe mode is entered and set default panel mode
$effect(() => {
if (transcribeMode) {
loadTranscriptionBlocks();
loadTranscriptionBlocks().then(() => {
panelMode = transcriptionBlocks.length > 0 ? 'read' : 'edit';
});
}
});
@@ -201,7 +249,9 @@ onMount(() => {
<div class="relative flex-1 overflow-hidden {transcribeMode ? 'flex flex-col md:flex-row' : ''}">
<div
class={transcribeMode ? 'relative min-h-[40vh] flex-1 overflow-hidden md:min-h-0' : 'absolute inset-0'}
class={transcribeMode
? `relative flex-1 overflow-hidden md:min-h-0 ${panelMode === 'read' ? (pdfStripExpanded ? 'max-h-[50vh] min-h-[50vh] md:max-h-none md:min-h-0' : 'max-h-[70px] min-h-[70px] md:max-h-none md:min-h-0') : 'min-h-[40vh]'} transition-[min-height,max-height] duration-300`
: 'absolute inset-0'}
>
<DocumentViewer
doc={doc}
@@ -211,26 +261,67 @@ onMount(() => {
transcribeMode={transcribeMode}
blockNumbers={blockNumbers}
annotationReloadKey={annotationReloadKey}
annotationsDimmed={transcribeMode && panelMode === 'read'}
flashAnnotationId={flashAnnotationId}
bind:activeAnnotationId={activeAnnotationId}
onAnnotationClick={handleAnnotationClick}
onTranscriptionDraw={createBlockFromDraw}
/>
</div>
{#if transcribeMode && panelMode === 'read'}
<button
type="button"
onclick={() => (pdfStripExpanded = !pdfStripExpanded)}
class="flex h-7 w-full shrink-0 items-center justify-center border-t border-line bg-muted text-xs font-semibold text-ink-2 md:hidden"
aria-label={pdfStripExpanded ? m.scan_collapse() : m.scan_expand()}
>
<svg
class="mr-1 h-3 w-3 transition-transform duration-200 {pdfStripExpanded ? 'rotate-180' : ''}"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
aria-hidden="true"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
</svg>
{pdfStripExpanded ? m.scan_collapse() : m.scan_expand()}
</button>
{/if}
{#if transcribeMode}
<div
class="shrink-0 border-t border-line md:w-[400px] md:border-t-0 md:border-l lg:w-[480px]"
class="flex min-h-0 flex-1 shrink-0 flex-col border-t border-line md:w-[400px] md:flex-none md:border-t-0 md:border-l lg:w-[480px]"
>
<TranscriptionEditView
documentId={doc.id}
blocks={transcriptionBlocks}
canComment={canWrite}
currentUserId={currentUserId}
activeAnnotationId={activeAnnotationId}
onBlockFocus={handleBlockFocus}
onSaveBlock={saveBlock}
onDeleteBlock={deleteBlock}
<TranscriptionPanelHeader
mode={panelMode}
hasBlocks={hasBlocks}
blockCount={transcriptionBlocks.length}
lastEditedAt={lastEditedAt}
onModeChange={(newMode) => (panelMode = newMode)}
onClose={() => (transcribeMode = false)}
/>
<div class="flex-1 overflow-y-auto">
{#if panelMode === 'read'}
<TranscriptionReadView
blocks={transcriptionBlocks}
highlightBlockId={highlightBlockId}
onParagraphClick={handleParagraphClick}
/>
{:else}
<TranscriptionEditView
documentId={doc.id}
blocks={transcriptionBlocks}
canComment={canWrite}
currentUserId={currentUserId}
activeAnnotationId={activeAnnotationId}
onBlockFocus={handleBlockFocus}
onSaveBlock={saveBlock}
onDeleteBlock={deleteBlock}
/>
{/if}
</div>
</div>
{/if}
</div>