refactor: move document transcription, annotation, viewer sub-packages

- transcription/: TranscriptionBlock, Column, EditView, PanelHeader, ReadView,
  Section + transcriptionMarkers, blockConflictMerge, saveBlockWithConflictRetry
  + useBlockAutoSave, useBlockDragDrop hooks
- annotation/: AnnotationLayer, AnnotationShape, AnnotationEditOverlay
- viewer/: PdfViewer, PdfControls + useFileLoader, usePdfRenderer hooks

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-05 14:01:39 +02:00
parent e7f8aa5894
commit 1e656d2db4
43 changed files with 32 additions and 27 deletions

View File

@@ -0,0 +1,292 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { getConfirmService } from '$lib/services/confirm.svelte.js';
import CommentThread from '$lib/components/CommentThread.svelte';
import PersonMentionEditor from '$lib/components/PersonMentionEditor.svelte';
import type { PersonMention } from '$lib/types';
const { confirm } = getConfirmService();
type SaveState = 'idle' | 'saving' | 'saved' | 'fading' | 'error';
type Props = {
blockId: string;
documentId: string;
blockNumber: number;
text: string;
mentionedPersons: PersonMention[];
label: string | null;
active: boolean;
reviewed: boolean;
saveState: SaveState;
canComment: boolean;
currentUserId: string | null;
onTextChange: (text: string, mentionedPersons: PersonMention[]) => void;
onFocus: () => void;
onDeleteClick: () => void;
onRetry: () => void;
onReviewToggle: () => void;
onMoveUp?: () => void;
onMoveDown?: () => void;
isFirst?: boolean;
isLast?: boolean;
source?: 'MANUAL' | 'OCR';
};
let {
blockId,
documentId,
blockNumber,
text,
mentionedPersons,
label = null,
active,
reviewed,
saveState,
canComment,
currentUserId,
onTextChange,
onFocus,
onDeleteClick,
onRetry,
onReviewToggle,
onMoveUp,
onMoveDown,
isFirst = false,
isLast = false,
source = 'MANUAL'
}: Props = $props();
let localText = $state(text);
let localMentions = $state<PersonMention[]>([...mentionedPersons]);
let commentOpen = $state(false);
let commentCount = $state(0);
let selectedQuote = $state<string | null>(null);
const hasComments = $derived(commentCount > 0);
// Sync from prop only when switching to a different block (not on save responses)
let prevBlockId = $state(blockId);
$effect(() => {
if (blockId !== prevBlockId) {
localText = text;
localMentions = [...mentionedPersons];
prevBlockId = blockId;
}
});
let leftBorderClass = $derived(
saveState === 'error' ? 'border-l-2 border-error' : active ? 'border-l-2 border-turquoise' : ''
);
function emitChange() {
onTextChange(localText, localMentions);
}
async function handleDelete() {
const confirmed = await confirm({
title: m.transcription_block_delete_confirm(),
destructive: true
});
if (confirmed) onDeleteClick();
}
</script>
<div
class="relative flex overflow-visible rounded border border-line {leftBorderClass}"
data-block-id={blockId}
>
<!-- Turquoise numbered badge — overlaps top-left of card -->
<span
class="absolute -top-2 -left-2 z-10 flex h-6 w-6 items-center justify-center rounded-full bg-turquoise text-xs font-bold text-turquoise-fg shadow-sm"
>
{blockNumber}
</span>
<!-- Drag handle (desktop) / Arrow buttons (mobile) -->
<div class="flex shrink-0 flex-col items-center justify-center border-r border-line px-1">
<!-- Mobile: arrow buttons -->
<button
type="button"
class="flex h-7 w-7 cursor-pointer items-center justify-center rounded text-ink-3 transition-colors hover:bg-muted hover:text-ink disabled:opacity-20 md:hidden"
disabled={isFirst}
aria-label="Nach oben"
onclick={() => onMoveUp?.()}
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 15l7-7 7 7" />
</svg>
</button>
<!-- Desktop: grip handle (drag target) -->
<div
class="hidden cursor-grab text-ink-3 transition-colors select-none hover:text-ink active:cursor-grabbing md:block"
data-drag-handle
aria-label="Ziehen zum Sortieren"
>
</div>
<!-- Mobile: arrow down -->
<button
type="button"
class="flex h-7 w-7 cursor-pointer items-center justify-center rounded text-ink-3 transition-colors hover:bg-muted hover:text-ink disabled:opacity-20 md:hidden"
disabled={isLast}
aria-label="Nach unten"
onclick={() => onMoveDown?.()}
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
</div>
<div class="min-w-0 flex-1 p-4 pl-3">
<!-- Header -->
<div class="mb-2 flex items-center gap-2">
{#if label}
<span class="text-xs font-medium tracking-wide text-ink-2 uppercase">
{label}
</span>
{/if}
{#if (!text || text.trim() === '') && source === 'MANUAL'}
<span class="rounded bg-muted px-1.5 py-0.5 text-xs font-medium text-ink-3"
>{m.transcription_block_segmentation_only()}</span
>
{/if}
</div>
<!-- Editor powered by PersonMentionEditor (Tiptap) for @-mention typeahead -->
<PersonMentionEditor
bind:value={() => localText,
(v) => {
localText = v;
emitChange();
}}
bind:mentionedPersons={() => localMentions,
(next) => {
localMentions = next;
emitChange();
}}
placeholder={m.transcription_block_placeholder()}
onfocus={onFocus}
onSelectionChange={(text) => (selectedQuote = text)}
/>
{#if selectedQuote}
<p class="mt-1 text-xs text-ink-3">{m.transcription_block_quote_hint()}</p>
{/if}
<!-- Footer -->
<div class="flex items-center justify-between border-t border-line pt-2">
<div>
{#if !hasComments}
<button
type="button"
class="flex cursor-pointer items-center gap-1 text-xs font-medium text-ink-2 transition-colors hover:text-ink"
onclick={() => (commentOpen = true)}
>
<svg
class="h-3 w-3"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M2.25 12.76c0 1.6 1.123 2.994 2.707 3.227 1.087.16 2.185.283 3.293.369V21l4.076-4.076a1.526 1.526 0 011.037-.443 48.282 48.282 0 005.68-.494c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z"
/>
</svg>
{m.transcription_block_comment_btn()}
</button>
{/if}
</div>
<div class="flex items-center gap-2">
<!-- Save state indicator -->
{#if saveState === 'saving'}
<span class="animate-pulse text-xs text-ink-3">
{m.transcription_block_save_saving()}
</span>
{:else if saveState === 'saved' || saveState === 'fading'}
<span
class="text-xs text-green-600 transition-opacity duration-300 {saveState === 'fading' ? 'opacity-0' : 'opacity-100'}"
>
{m.transcription_block_save_saved()} <span class="inline-block">&#10003;</span>
</span>
{:else if saveState === 'error'}
<span class="text-error text-xs">
{m.transcription_block_save_error()}
<span class="mx-1">&mdash;</span>
<button
type="button"
class="underline transition-colors hover:text-ink"
onclick={onRetry}
>
{m.transcription_block_save_retry()}
</button>
</span>
{/if}
<!-- Review toggle -->
<button
type="button"
class="cursor-pointer transition-colors {reviewed ? 'text-turquoise hover:text-turquoise/70' : 'text-ink-3 hover:text-turquoise'}"
aria-label={reviewed ? m.transcription_block_unreview() : m.transcription_block_review()}
title={reviewed ? m.transcription_block_unreview() : m.transcription_block_review()}
onclick={onReviewToggle}
>
<svg
class="h-4 w-4"
fill={reviewed ? 'currentColor' : 'none'}
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</button>
<!-- Delete button -->
<button
type="button"
class="hover:text-error cursor-pointer text-ink-3 transition-colors"
aria-label={m.btn_delete()}
onclick={handleDelete}
>
<svg
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
</div>
<!-- Comment thread — list always visible, compose toggled by Kommentieren -->
<div class="mt-3">
<CommentThread
documentId={documentId}
blockId={blockId}
loadOnMount={true}
canComment={canComment}
currentUserId={currentUserId}
quotedText={selectedQuote}
showCompose={commentOpen}
onCountChange={(count) => (commentCount = count)}
/>
</div>
</div>
</div>

View File

@@ -0,0 +1,255 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import TranscriptionBlockHost from './TranscriptionBlock.test-host.svelte';
import type { ConfirmService } from '$lib/services/confirm.svelte.js';
afterEach(cleanup);
const BASE_PROPS = {
blockId: 'block-1',
documentId: 'doc-1',
blockNumber: 3,
text: 'Liebe Mutter,',
label: null,
active: false,
saveState: 'idle' as const,
canComment: true,
currentUserId: 'user-1',
onTextChange: vi.fn(),
onFocus: vi.fn(),
onDeleteClick: vi.fn(),
onRetry: vi.fn()
};
// Renders TranscriptionBlock via the host, which provides ConfirmService context.
function renderBlock(overrides: Record<string, unknown> = {}) {
return render(TranscriptionBlockHost, {
...BASE_PROPS,
onServiceReady: () => {},
...overrides
});
}
// ─── Rendering ───────────────────────────────────────────────────────────────
describe('TranscriptionBlock — rendering', () => {
it('renders block number in turquoise badge', async () => {
renderBlock();
await expect.element(page.getByText('3')).toBeInTheDocument();
});
it('renders text in textarea', async () => {
renderBlock();
await expect.element(page.getByText('Liebe Mutter,')).toBeInTheDocument();
});
it('renders optional label when provided', async () => {
renderBlock({ label: 'Anrede' });
await expect.element(page.getByText('Anrede')).toBeInTheDocument();
});
it('does not render label when null', async () => {
renderBlock({ label: null });
const label = page.getByText('Anrede');
await expect.element(label).not.toBeInTheDocument();
});
});
// ─── Save states ─────────────────────────────────────────────────────────────
describe('TranscriptionBlock — save states', () => {
it('shows nothing in idle state', async () => {
renderBlock({ saveState: 'idle' });
const saving = page.getByText('Speichere...');
await expect.element(saving).not.toBeInTheDocument();
});
it('shows "Speichere..." in saving state', async () => {
renderBlock({ saveState: 'saving' });
await expect.element(page.getByText('Speichere...')).toBeInTheDocument();
});
it('shows "Gespeichert" in saved state', async () => {
renderBlock({ saveState: 'saved' });
await expect.element(page.getByText(/Gespeichert/)).toBeInTheDocument();
});
it('shows error with retry button in error state', async () => {
const onRetry = vi.fn();
renderBlock({ saveState: 'error', onRetry });
await expect.element(page.getByText('Nicht gespeichert')).toBeInTheDocument();
const retryBtn = page.getByText('Erneut versuchen');
await expect.element(retryBtn).toBeInTheDocument();
});
});
// ─── Active state ────────────────────────────────────────────────────────────
describe('TranscriptionBlock — active border', () => {
it('has turquoise left border when active', async () => {
renderBlock({ active: true });
await expect.element(page.getByRole('textbox')).toBeInTheDocument();
const block = document.querySelector('[data-block-id="block-1"]')!;
expect(block.className).toContain('border-turquoise');
});
it('has error left border when save failed', async () => {
renderBlock({ saveState: 'error' });
await expect.element(page.getByRole('textbox')).toBeInTheDocument();
const block = document.querySelector('[data-block-id="block-1"]')!;
expect(block.className).toContain('border-error');
});
});
// ─── Interactions ────────────────────────────────────────────────────────────
describe('TranscriptionBlock — interactions', () => {
it('calls onTextChange when typing in textarea', async () => {
const onTextChange = vi.fn();
renderBlock({ onTextChange });
const textarea = page.getByRole('textbox');
await textarea.fill('Neue Zeile');
expect(onTextChange).toHaveBeenCalled();
});
it('calls onFocus when textarea is focused', async () => {
const onFocus = vi.fn();
renderBlock({ onFocus });
const textarea = page.getByRole('textbox');
await textarea.click();
expect(onFocus).toHaveBeenCalled();
});
it('shows Kommentieren button when no comments exist', async () => {
renderBlock();
const btn = page.getByText('Kommentieren');
await expect.element(btn).toBeInTheDocument();
});
});
// ─── Reorder controls ────────────────────────────────────────────────────────
describe('TranscriptionBlock — reorder controls', () => {
it('shows a drag handle element', async () => {
renderBlock();
const handle = document.querySelector('[data-drag-handle]');
expect(handle).not.toBeNull();
});
it('disables move-up button when isFirst', async () => {
renderBlock({ isFirst: true });
const btn = page.getByRole('button', { name: 'Nach oben' });
await expect.element(btn).toBeDisabled();
});
it('disables move-down button when isLast', async () => {
renderBlock({ isLast: true });
const btn = page.getByRole('button', { name: 'Nach unten' });
await expect.element(btn).toBeDisabled();
});
it('calls onMoveUp when up arrow clicked', async () => {
const onMoveUp = vi.fn();
renderBlock({ onMoveUp, isFirst: false });
const btn = page.getByRole('button', { name: 'Nach oben' });
await btn.click();
expect(onMoveUp).toHaveBeenCalled();
});
it('calls onMoveDown when down arrow clicked', async () => {
const onMoveDown = vi.fn();
renderBlock({ onMoveDown, isLast: false });
const btn = page.getByRole('button', { name: 'Nach unten' });
await btn.click();
expect(onMoveDown).toHaveBeenCalled();
});
});
// ─── Delete confirmation ──────────────────────────────────────────────────────
function renderBlockWithService(overrides: Record<string, unknown> = {}) {
let service!: ConfirmService;
render(TranscriptionBlockHost, {
blockId: 'block-1',
documentId: 'doc-1',
blockNumber: 3,
text: 'Liebe Mutter,',
label: null,
active: false,
saveState: 'idle' as const,
canComment: true,
currentUserId: 'user-1',
onTextChange: vi.fn(),
onFocus: vi.fn(),
onDeleteClick: vi.fn(),
onRetry: vi.fn(),
onServiceReady: (s: ConfirmService) => {
service = s;
},
...overrides
});
return { service };
}
describe('TranscriptionBlock — delete confirmation', () => {
it('does not call onDeleteClick when user cancels via confirm service', async () => {
const onDeleteClick = vi.fn();
const { service } = renderBlockWithService({ onDeleteClick });
// Use native DOM click so the async handler starts but yields at the await,
// letting the test observe service.options and settle the promise.
const deleteBtn = document.querySelector('button[aria-label="Löschen"]') as HTMLButtonElement;
deleteBtn.click();
await vi.waitFor(() => expect(service.options).not.toBeNull());
service.settle(false);
await vi.waitFor(() => expect(service.options).toBeNull());
expect(onDeleteClick).not.toHaveBeenCalled();
});
it('calls onDeleteClick when user confirms via confirm service', async () => {
const onDeleteClick = vi.fn();
const { service } = renderBlockWithService({ onDeleteClick });
const deleteBtn = document.querySelector('button[aria-label="Löschen"]') as HTMLButtonElement;
deleteBtn.click();
await vi.waitFor(() => expect(service.options).not.toBeNull());
service.settle(true);
await vi.waitFor(() => expect(service.options).toBeNull());
expect(onDeleteClick).toHaveBeenCalledOnce();
});
});
// ─── Quote selection ─────────────────────────────────────────────────────────
describe('TranscriptionBlock — quote selection', () => {
it('shows quote hint after text is selected in the editor', async () => {
renderBlock({ text: 'Breslau, den 12. August' });
await page.getByRole('textbox').click();
// Select all text in the contenteditable via the native Selection API.
// Tiptap fires selectionUpdate which the block forwards as onSelectionChange.
const editorEl = document.querySelector('[role="textbox"]') as HTMLElement;
const range = document.createRange();
range.selectNodeContents(editorEl);
const selection = window.getSelection()!;
selection.removeAllRanges();
selection.addRange(range);
editorEl.dispatchEvent(new Event('input', { bubbles: true }));
await expect.element(page.getByText(/Zitat/)).toBeInTheDocument();
});
});
// ─── Fading state ────────────────────────────────────────────────────────────
describe('TranscriptionBlock — fading save state', () => {
it('shows Gespeichert text in fading state (opacity-0 fade-out)', async () => {
renderBlock({ saveState: 'fading' });
const indicator = page.getByText(/Gespeichert/);
await expect.element(indicator).toBeInTheDocument();
// The fading class sets opacity-0
const el = document.querySelector('.opacity-0');
expect(el).not.toBeNull();
});
});

View File

@@ -0,0 +1,48 @@
<script lang="ts">
import { provideConfirmService, type ConfirmService } from '$lib/services/confirm.svelte.js';
import TranscriptionBlock from './TranscriptionBlock.svelte';
import type { PersonMention } from '$lib/types';
type BlockProps = {
blockId: string;
documentId: string;
blockNumber: number;
text: string;
mentionedPersons?: PersonMention[];
label: string | null;
active: boolean;
saveState: 'idle' | 'saving' | 'saved' | 'fading' | 'error';
canComment: boolean;
currentUserId: string | null;
onTextChange: (text: string, mentionedPersons: PersonMention[]) => void;
onFocus: () => void;
onDeleteClick: () => void;
onRetry: () => void;
onReviewToggle?: () => void;
onMoveUp?: () => void;
onMoveDown?: () => void;
isFirst?: boolean;
isLast?: boolean;
};
let {
onServiceReady,
mentionedPersons = [],
reviewed = false,
onReviewToggle = () => {},
...blockProps
}: BlockProps & {
onServiceReady: (s: ConfirmService) => void;
reviewed?: boolean;
} = $props();
const service = provideConfirmService();
onServiceReady(service);
</script>
<TranscriptionBlock
{...blockProps}
mentionedPersons={mentionedPersons}
reviewed={reviewed}
onReviewToggle={onReviewToggle}
/>

View File

@@ -0,0 +1,85 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
import { getLocale } from '$lib/paraglide/runtime.js';
import { formatMCDate } from '$lib/utils/date.js';
import type { components } from '$lib/generated/api';
import ContributorStack from '$lib/components/ContributorStack.svelte';
type TranscriptionQueueItemDTO = components['schemas']['TranscriptionQueueItemDTO'];
interface Props {
docs: TranscriptionQueueItemDTO[];
weeklyCount: number;
}
let { docs, weeklyCount }: Props = $props();
function blockProgress(doc: TranscriptionQueueItemDTO): number {
if (doc.annotationCount === 0) return 0;
return (doc.textedBlockCount / doc.annotationCount) * 100;
}
</script>
{#if docs.length > 0}
<div class="flex flex-col gap-3 rounded-sm border border-line bg-surface p-4">
<div>
<h3 class="mb-1 font-sans text-xs font-bold tracking-widest text-ink uppercase">
{m.mission_control_transcription_heading()}
</h3>
<span
class="inline-flex items-center gap-1 rounded-full border border-line bg-surface px-2 py-0.5 text-xs font-semibold text-ink"
>
{m.mission_control_trans_skill_pill()}
</span>
{#if weeklyCount > 0}
<p class="mt-1 text-xs font-semibold text-ink-2">
{m.mission_control_weekly_pulse({ count: weeklyCount })}
</p>
{/if}
</div>
<ul class="space-y-1">
{#each docs as doc (doc.id)}
<li>
<a
href="/documents/{doc.id}?task=transcribe"
class="flex min-h-[44px] flex-col justify-center rounded px-1 py-2 hover:bg-canvas focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:outline-none"
>
<span class="font-serif text-sm text-ink">{doc.title}</span>
{#if doc.documentDate}
<span class="mt-0.5 text-xs text-ink-3"
>{formatMCDate(doc.documentDate, getLocale())}</span
>
{/if}
{#if doc.textedBlockCount > 0}
<div class="mt-1.5 flex items-center gap-2">
<span class="shrink-0 text-xs text-ink-3">
{m.mission_control_blocks_progress({
texted: doc.textedBlockCount,
total: doc.annotationCount
})}
</span>
<div class="h-1 flex-1 overflow-hidden rounded-full bg-ink/20" aria-hidden="true">
<div
class="h-full rounded-full bg-ink transition-all"
style="width: {blockProgress(doc).toFixed(0)}%"
></div>
</div>
</div>
{:else}
<span class="mt-0.5 text-xs text-ink-3 italic"></span>
{/if}
<div class="mt-1">
<ContributorStack contributors={doc.contributors} hasMore={doc.hasMoreContributors} />
</div>
</a>
</li>
{/each}
</ul>
</div>
{:else}
<div
class="flex min-h-[120px] flex-col items-center justify-center rounded-sm border border-dashed border-line bg-surface/50 p-6 text-center"
>
<p class="text-xs text-ink-3">{m.mission_control_transcription_empty()}</p>
</div>
{/if}

View File

@@ -0,0 +1,85 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import TranscriptionColumn from './TranscriptionColumn.svelte';
import type { components } from '$lib/generated/api';
type TranscriptionQueueItemDTO = components['schemas']['TranscriptionQueueItemDTO'];
afterEach(cleanup);
function makeDoc(overrides: Partial<TranscriptionQueueItemDTO> = {}): TranscriptionQueueItemDTO {
return {
id: 'doc-1',
title: 'Test Dokument',
annotationCount: 0,
textedBlockCount: 0,
reviewedBlockCount: 0,
contributors: [],
hasMoreContributors: false,
...overrides
};
}
describe('TranscriptionColumn', () => {
it('renders document list when docs are provided', async () => {
const doc1 = makeDoc({ id: 'doc-1', title: 'Familienbrief' });
const doc2 = makeDoc({ id: 'doc-2', title: 'Tagebuch Eintrag' });
render(TranscriptionColumn, { props: { docs: [doc1, doc2], weeklyCount: 0 } });
await expect.element(page.getByText('Familienbrief')).toBeInTheDocument();
await expect.element(page.getByText('Tagebuch Eintrag')).toBeInTheDocument();
});
it('renders dashed empty state when docs array is empty', async () => {
render(TranscriptionColumn, { props: { docs: [], weeklyCount: 0 } });
await expect
.element(page.getByText('Keine Dokumente warten auf Transkription.'))
.toBeInTheDocument();
});
it('renders progress bar when textedBlockCount > 0', async () => {
const doc = makeDoc({
id: 'doc-1',
title: 'Brief mit Blöcken',
annotationCount: 4,
textedBlockCount: 2
});
render(TranscriptionColumn, { props: { docs: [doc], weeklyCount: 0 } });
// The progress text should show "2 / 4 Blöcke"
await expect.element(page.getByText('2 / 4 Blöcke')).toBeInTheDocument();
// A progress bar div should exist (the visual bar)
const progressBar = document.querySelector('.h-1.flex-1');
expect(progressBar).not.toBeNull();
});
it('renders dash placeholder when textedBlockCount is 0', async () => {
const doc = makeDoc({
id: 'doc-1',
title: 'Brief ohne Blöcke',
annotationCount: 3,
textedBlockCount: 0
});
render(TranscriptionColumn, { props: { docs: [doc], weeklyCount: 0 } });
// The italic em-dash placeholder should render
const dashEl = document.querySelector('span.italic');
expect(dashEl).not.toBeNull();
expect(dashEl?.textContent?.trim()).toBe('—');
});
it('links to /documents/{id}?task=transcribe', async () => {
const doc = makeDoc({ id: 'xyz-456', title: 'Transkriptions Dokument' });
render(TranscriptionColumn, { props: { docs: [doc], weeklyCount: 0 } });
const link = page.getByRole('link', { name: /Transkriptions Dokument/ });
await expect.element(link).toHaveAttribute('href', '/documents/xyz-456?task=transcribe');
});
});

View File

@@ -0,0 +1,322 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import TranscriptionBlock from './TranscriptionBlock.svelte';
import OcrTrigger from '$lib/components/OcrTrigger.svelte';
import TranscribeCoachEmptyState from '$lib/components/TranscribeCoachEmptyState.svelte';
import type { PersonMention, TranscriptionBlockData } from '$lib/types';
import { createBlockAutoSave } from '$lib/document/transcription/useBlockAutoSave.svelte';
import { createBlockDragDrop } from '$lib/document/transcription/useBlockDragDrop.svelte';
type Props = {
documentId: string;
blocks: TranscriptionBlockData[];
canComment: boolean;
currentUserId: string | null;
activeAnnotationId?: string | null;
storedScriptType?: string;
canRunOcr?: boolean;
onBlockFocus: (blockId: string) => void;
onSaveBlock: (blockId: string, text: string, mentionedPersons: PersonMention[]) => Promise<void>;
onDeleteBlock: (blockId: string) => Promise<void>;
onReviewToggle: (blockId: string) => Promise<void>;
onMarkAllReviewed?: () => Promise<void>;
onTriggerOcr?: (scriptType: string, useExistingAnnotations: boolean) => void;
canWrite?: boolean;
trainingLabels?: string[];
onToggleTrainingLabel?: (label: string, enrolled: boolean) => Promise<void>;
};
let {
documentId,
blocks,
canComment,
currentUserId,
activeAnnotationId = null,
storedScriptType = '',
canRunOcr = false,
onBlockFocus,
onSaveBlock,
onDeleteBlock,
onReviewToggle,
onMarkAllReviewed,
onTriggerOcr,
canWrite = false,
trainingLabels = [],
onToggleTrainingLabel
}: Props = $props();
let activeBlockId: string | null = $state(null);
let localLabels: string[] = $derived.by(() => [...trainingLabels]);
let listEl: HTMLElement | null = $state(null);
let markingAllReviewed = $state(false);
const sortedBlocks = $derived([...blocks].sort((a, b) => a.sortOrder - b.sortOrder));
const hasBlocks = $derived(blocks.length > 0);
const reviewedCount = $derived(blocks.filter((b) => b.reviewed).length);
const totalCount = $derived(blocks.length);
const reviewProgress = $derived(totalCount > 0 ? (reviewedCount / totalCount) * 100 : 0);
const allReviewed = $derived(totalCount > 0 && reviewedCount === totalCount);
// Sync: when an annotation is clicked on the PDF, activate the corresponding block
$effect(() => {
if (!activeAnnotationId) return;
const block = blocks.find((b) => b.annotationId === activeAnnotationId);
if (block) activeBlockId = block.id;
});
async function handleMarkAllReviewed() {
if (!onMarkAllReviewed) return;
markingAllReviewed = true;
try {
await onMarkAllReviewed();
} finally {
markingAllReviewed = false;
}
}
const autoSave = createBlockAutoSave({ saveFn: onSaveBlock, documentId });
const dragDrop = createBlockDragDrop({
getSortedBlocks: () => sortedBlocks,
onReorder: reorder
});
// Wire listEl to drag-drop module
$effect(() => {
dragDrop.setListElement(listEl);
});
$effect(() => {
function onBeforeUnload() {
autoSave.flushOnUnload();
}
window.addEventListener('beforeunload', onBeforeUnload);
return () => {
window.removeEventListener('beforeunload', onBeforeUnload);
autoSave.destroy();
};
});
function handleFocus(blockId: string) {
activeBlockId = blockId;
onBlockFocus(blockId);
}
function handleDelete(blockId: string) {
autoSave.clearBlock(blockId);
onDeleteBlock(blockId);
}
async function reorder(newOrder: string[]) {
try {
const res = await fetch(`/api/documents/${documentId}/transcription-blocks/reorder`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ blockIds: newOrder })
});
if (!res.ok) return;
const updated = await res.json();
for (const b of updated) {
const existing = blocks.find((x) => x.id === b.id);
if (existing) existing.sortOrder = b.sortOrder;
}
} catch {
// ignore
}
}
function handleMoveUp(blockId: string) {
const sorted = [...sortedBlocks];
const idx = sorted.findIndex((b) => b.id === blockId);
if (idx <= 0) return;
[sorted[idx - 1], sorted[idx]] = [sorted[idx], sorted[idx - 1]];
reorder(sorted.map((b) => b.id));
}
function handleMoveDown(blockId: string) {
const sorted = [...sortedBlocks];
const idx = sorted.findIndex((b) => b.id === blockId);
if (idx < 0 || idx >= sorted.length - 1) return;
[sorted[idx], sorted[idx + 1]] = [sorted[idx + 1], sorted[idx]];
reorder(sorted.map((b) => b.id));
}
async function handleLabelToggle(label: string) {
if (!onToggleTrainingLabel) return;
const enrolled = !localLabels.includes(label);
if (enrolled) {
localLabels = [...localLabels, label];
} else {
localLabels = localLabels.filter((l) => l !== label);
}
try {
await onToggleTrainingLabel(label, enrolled);
} catch {
localLabels = [...trainingLabels];
}
}
</script>
<div class="flex h-full flex-col overflow-y-auto bg-surface">
{#if hasBlocks}
<!-- Sticky review progress header -->
<div class="sticky top-0 z-10 border-b border-line bg-surface px-4 pt-3 pb-2">
<div class="flex items-center justify-between">
<p class="font-sans text-xs text-ink-2">
<span class="font-semibold text-ink">{reviewedCount} / {totalCount}</span> geprüft
</p>
{#if onMarkAllReviewed}
<button
onclick={handleMarkAllReviewed}
disabled={allReviewed || markingAllReviewed}
title={allReviewed ? 'Alle Blöcke sind bereits als fertig markiert' : undefined}
class="flex min-h-[44px] items-center gap-1.5 rounded-sm px-3 font-sans text-xs font-medium text-brand-navy/80 transition-colors hover:text-brand-navy focus-visible:ring-2 focus-visible:ring-brand-navy disabled:opacity-40"
>
{#if markingAllReviewed}
<svg
class="h-3.5 w-3.5 animate-spin"
fill="none"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
></path>
</svg>
{:else}
<svg
class="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
{/if}
Alle als fertig markieren
</button>
{/if}
</div>
<div class="bg-brand-sand mt-1.5 h-0.5 w-full overflow-hidden rounded-full">
<div
class="h-full rounded-full bg-brand-mint transition-all duration-300"
style="width: {reviewProgress}%"
></div>
</div>
</div>
<div class="p-4">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="flex flex-col gap-3"
bind:this={listEl}
onpointermove={dragDrop.handlePointerMove}
onpointerup={dragDrop.handlePointerUp}
>
{#each sortedBlocks as block, i (block.id)}
{#if dragDrop.dropTargetIdx === i}
<div class="h-1 rounded-full bg-turquoise transition-all"></div>
{/if}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
data-block-wrapper
onblur={autoSave.handleBlur}
onpointerdown={(e) => dragDrop.handleGripDown(e, block.id)}
class="relative transition-all duration-150 {dragDrop.draggedBlockId === block.id ? 'z-10 rounded-lg shadow-xl ring-2 ring-turquoise/40' : ''}"
style={dragDrop.draggedBlockId === block.id
? `transform: translateY(${dragDrop.dragOffsetY}px) scale(1.02); opacity: 0.9;`
: ''}
>
<TranscriptionBlock
blockId={block.id}
documentId={documentId}
blockNumber={i + 1}
text={block.text}
mentionedPersons={block.mentionedPersons ?? []}
label={block.label}
active={activeBlockId === block.id}
reviewed={block.reviewed ?? false}
saveState={autoSave.getSaveState(block.id)}
canComment={canComment}
currentUserId={currentUserId}
onTextChange={(text, mentions) =>
autoSave.handleTextChange(block.id, text, mentions)}
onFocus={() => handleFocus(block.id)}
onDeleteClick={() => handleDelete(block.id)}
onRetry={() =>
autoSave.handleRetry(block.id, block.text, block.mentionedPersons ?? [])}
onReviewToggle={() => onReviewToggle(block.id)}
onMoveUp={() => handleMoveUp(block.id)}
onMoveDown={() => handleMoveDown(block.id)}
isFirst={i === 0}
isLast={i === sortedBlocks.length - 1}
source={block.source}
/>
</div>
{/each}
{#if dragDrop.dropTargetIdx === sortedBlocks.length}
<div class="h-1 rounded-full bg-turquoise transition-all"></div>
{/if}
<!-- Next block CTA — dashed outline hint -->
<div
class="flex items-center justify-center rounded border border-dashed border-line px-4 py-5 text-center font-sans text-sm text-ink-3"
>
{m.transcription_next_block_cta({ number: sortedBlocks.length + 1 })}
</div>
{#if canRunOcr && onTriggerOcr}
<div class="mt-6">
<p class="mb-3 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.ocr_section_heading()}
</p>
<div class="max-w-xs">
<OcrTrigger
blockCount={blocks.length}
storedScriptType={storedScriptType}
onTrigger={onTriggerOcr}
/>
</div>
</div>
{/if}
</div>
</div>
{:else}
<div class="p-4">
<TranscribeCoachEmptyState />
</div>
{/if}
{#if canWrite && hasBlocks}
<div class="border-t border-line px-4 py-3">
<p class="mb-2 font-sans text-xs font-medium text-ink-2">Für Training vormerken</p>
<div class="flex flex-wrap gap-2">
{#each [{ label: 'KURRENT_RECOGNITION', display: m.training_chip_kurrent() }, { label: 'KURRENT_SEGMENTATION', display: m.training_chip_segmentation() }] as chip (chip.label)}
<button
type="button"
onclick={() => handleLabelToggle(chip.label)}
class="rounded-full border px-3 py-1 font-sans text-xs font-medium transition-colors {localLabels.includes(chip.label)
? 'border-brand-mint bg-brand-mint text-brand-navy'
: 'border-line bg-surface text-ink-3 hover:border-brand-mint hover:text-brand-navy'}"
>
{chip.display}
</button>
{/each}
</div>
</div>
{/if}
</div>

View File

@@ -0,0 +1,357 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import TranscriptionEditView from './TranscriptionEditView.svelte';
import { createConfirmService, CONFIRM_KEY } from '$lib/services/confirm.svelte.js';
afterEach(cleanup);
const block1 = {
id: 'b1',
annotationId: 'a1',
documentId: 'doc-1',
text: 'Block eins',
label: null,
sortOrder: 0,
version: 0,
source: 'MANUAL' as const,
reviewed: false,
mentionedPersons: []
};
const block2 = {
id: 'b2',
annotationId: 'a2',
documentId: 'doc-1',
text: 'Block zwei',
label: null,
sortOrder: 1,
version: 0,
source: 'OCR' as const,
reviewed: true,
mentionedPersons: []
};
function renderView(overrides: Record<string, unknown> = {}, service = createConfirmService()) {
return {
...render(TranscriptionEditView, {
props: {
documentId: 'doc-1',
blocks: [block1, block2],
canComment: true,
currentUserId: 'user-1',
onBlockFocus: vi.fn(),
onSaveBlock: vi.fn(),
onDeleteBlock: vi.fn(),
onReviewToggle: vi.fn(),
...overrides
},
context: new Map([[CONFIRM_KEY, service]])
}),
service
};
}
const unreviewedBlock1 = { ...block1, reviewed: false };
const unreviewedBlock2 = { ...block2, reviewed: false };
const reviewedBlock1 = { ...block1, reviewed: true };
const reviewedBlock2 = { ...block2, reviewed: true };
describe('TranscriptionEditView — rendering', () => {
it('renders blocks in sort order', async () => {
renderView();
const textareas = page.getByRole('textbox').all();
expect(textareas.length).toBeGreaterThanOrEqual(2);
});
it('shows next-block CTA after block list', async () => {
renderView();
await expect.element(page.getByText(/Block 3/)).toBeInTheDocument();
});
it('shows coach card when no blocks', async () => {
renderView({ blocks: [] });
await expect
.element(page.getByRole('heading', { level: 2 }))
.toHaveTextContent('Erste Transkription?');
});
it('hides training footer when no blocks', async () => {
renderView({ blocks: [], canWrite: true });
await expect.element(page.getByText('Für Training vormerken')).not.toBeInTheDocument();
});
it('shows training footer when blocks exist', async () => {
renderView({ blocks: [block1], canWrite: true });
await expect.element(page.getByText('Für Training vormerken')).toBeInTheDocument();
});
});
describe('TranscriptionEditView — annotation sync', () => {
it('activates block matching activeAnnotationId', async () => {
renderView({ activeAnnotationId: 'a2' });
// Block 2 (annotation a2) should have turquoise border
const block = document.querySelector('[data-block-id="b2"]')!;
expect(block.className).toContain('border-turquoise');
});
it('does not activate any block when activeAnnotationId is null', async () => {
renderView({ activeAnnotationId: null });
const block1 = document.querySelector('[data-block-id="b1"]')!;
const block2 = document.querySelector('[data-block-id="b2"]')!;
expect(block1.className).not.toContain('border-turquoise');
expect(block2.className).not.toContain('border-turquoise');
});
});
describe('TranscriptionEditView — reorder', () => {
it('renders move-up button disabled on first block', async () => {
renderView();
const upButtons = page.getByRole('button', { name: 'Nach oben' }).all();
// First block's up button should be disabled
await expect.element(upButtons[0]).toBeDisabled();
});
it('renders move-down button disabled on last block', async () => {
renderView();
const downButtons = page.getByRole('button', { name: 'Nach unten' }).all();
// Last block's down button should be disabled
await expect.element(downButtons[downButtons.length - 1]).toBeDisabled();
});
it('has a drag handle on each block', async () => {
renderView();
const handles = document.querySelectorAll('[data-drag-handle]');
expect(handles.length).toBe(2);
});
});
// ─── Auto-save debounce ───────────────────────────────────────────────────────
describe('TranscriptionEditView — auto-save debounce', () => {
it('calls onSaveBlock after 1500ms debounce when text changes', async () => {
vi.useFakeTimers();
const onSaveBlock = vi.fn().mockResolvedValue(undefined);
renderView({ onSaveBlock });
const textarea = page.getByRole('textbox').first();
await textarea.fill('Neue Zeile');
// Not called immediately
expect(onSaveBlock).not.toHaveBeenCalled();
// Advance past debounce
vi.advanceTimersByTime(1500);
await vi.runAllTimersAsync();
expect(onSaveBlock).toHaveBeenCalledWith('b1', 'Neue Zeile', []);
vi.useRealTimers();
});
it('passes the block mentionedPersons array as the 3rd save argument', async () => {
vi.useFakeTimers();
const onSaveBlock = vi.fn().mockResolvedValue(undefined);
const blockWithMention = {
...block1,
mentionedPersons: [{ personId: 'p-aug', displayName: 'Auguste Raddatz' }]
};
renderView({ blocks: [blockWithMention], onSaveBlock });
const textarea = page.getByRole('textbox').first();
await textarea.fill('Hallo @Auguste Raddatz');
vi.advanceTimersByTime(1500);
await vi.runAllTimersAsync();
expect(onSaveBlock).toHaveBeenCalledWith('b1', 'Hallo @Auguste Raddatz', [
{ personId: 'p-aug', displayName: 'Auguste Raddatz' }
]);
vi.useRealTimers();
});
it('resets debounce timer on rapid successive changes', async () => {
vi.useFakeTimers();
const onSaveBlock = vi.fn().mockResolvedValue(undefined);
renderView({ onSaveBlock });
const textarea = page.getByRole('textbox').first();
await textarea.fill('First');
vi.advanceTimersByTime(500);
await textarea.fill('Second');
vi.advanceTimersByTime(500);
// 1000ms elapsed since first change — should not have saved yet
expect(onSaveBlock).not.toHaveBeenCalled();
vi.advanceTimersByTime(1000);
await vi.runAllTimersAsync();
// Only one save with the final value
expect(onSaveBlock).toHaveBeenCalledTimes(1);
expect(onSaveBlock).toHaveBeenCalledWith('b1', 'Second', []);
vi.useRealTimers();
});
});
// ─── Save state transitions ───────────────────────────────────────────────────
describe('TranscriptionEditView — save state indicators', () => {
it('shows saving indicator while onSaveBlock is in-flight', async () => {
vi.useFakeTimers();
let resolveSave!: () => void;
const onSaveBlock = vi.fn().mockReturnValue(new Promise<void>((r) => (resolveSave = r)));
renderView({ onSaveBlock });
await page.getByRole('textbox').first().fill('Hello');
vi.advanceTimersByTime(1500);
await vi.runAllTimersAsync();
await expect.element(page.getByText('Speichere...')).toBeInTheDocument();
resolveSave();
vi.useRealTimers();
});
it('shows error state when onSaveBlock rejects', async () => {
vi.useFakeTimers();
const onSaveBlock = vi.fn().mockRejectedValue(new Error('network'));
renderView({ onSaveBlock });
await page.getByRole('textbox').first().fill('Fails');
vi.advanceTimersByTime(1500);
await vi.runAllTimersAsync();
await expect.element(page.getByText('Nicht gespeichert')).toBeInTheDocument();
await expect.element(page.getByText('Erneut versuchen')).toBeInTheDocument();
vi.useRealTimers();
});
});
// ─── Flush on blur ────────────────────────────────────────────────────────────
describe('TranscriptionEditView — flush on blur', () => {
it('flushes pending save immediately on textarea blur before debounce expires', async () => {
vi.useFakeTimers();
const onSaveBlock = vi.fn().mockResolvedValue(undefined);
renderView({ onSaveBlock });
const textarea = page.getByRole('textbox').first();
await textarea.fill('Blur text');
// Blur before 1500ms debounce fires — locator.blur() not available, use native DOM
const el = document.querySelector('textarea') as HTMLTextAreaElement;
el.dispatchEvent(new FocusEvent('blur', { bubbles: true }));
await vi.runAllTimersAsync();
expect(onSaveBlock).toHaveBeenCalledWith('b1', 'Blur text', []);
vi.useRealTimers();
});
});
// ─── onDeleteBlock callback ───────────────────────────────────────────────────
describe('TranscriptionEditView — delete block', () => {
it('calls onDeleteBlock with correct blockId when delete is confirmed', async () => {
const onDeleteBlock = vi.fn().mockResolvedValue(undefined);
const { service } = renderView({ onDeleteBlock });
const deleteBtn = document.querySelector('button[aria-label="Löschen"]') as HTMLButtonElement;
deleteBtn.click();
await vi.waitFor(() => expect(service.options).not.toBeNull());
service.settle(true);
await vi.waitFor(() => expect(service.options).toBeNull());
expect(onDeleteBlock).toHaveBeenCalledWith('b1');
});
it('does not call onDeleteBlock when deletion is cancelled', async () => {
const onDeleteBlock = vi.fn();
const { service } = renderView({ onDeleteBlock });
const deleteBtn = document.querySelector('button[aria-label="Löschen"]') as HTMLButtonElement;
deleteBtn.click();
await vi.waitFor(() => expect(service.options).not.toBeNull());
service.settle(false);
await vi.waitFor(() => expect(service.options).toBeNull());
expect(onDeleteBlock).not.toHaveBeenCalled();
});
});
// ─── Review progress counter ──────────────────────────────────────────────────
describe('TranscriptionEditView — review progress counter', () => {
it('shows reviewed count and total when blocks exist', async () => {
// block1: reviewed=false, block2: reviewed=true → "1 / 2 geprüft"
renderView();
await expect.element(page.getByText(/1 \/ 2 geprüft/)).toBeInTheDocument();
});
it('shows 0 reviewed when no blocks are reviewed', async () => {
renderView({ blocks: [block1] }); // block1.reviewed = false
await expect.element(page.getByText(/0 \/ 1 geprüft/)).toBeInTheDocument();
});
it('does not show progress counter when there are no blocks', async () => {
renderView({ blocks: [] });
await expect.element(page.getByText(/geprüft/)).not.toBeInTheDocument();
});
});
// ─── Bulk mark all as reviewed ────────────────────────────────────────────────
describe('TranscriptionEditView — mark all reviewed', () => {
it('shows "Alle als fertig markieren" button when onMarkAllReviewed is provided and blocks are unreviewed', async () => {
renderView({
blocks: [unreviewedBlock1, unreviewedBlock2],
onMarkAllReviewed: vi.fn().mockResolvedValue(undefined)
});
await expect
.element(page.getByRole('button', { name: /Alle als fertig markieren/ }))
.toBeInTheDocument();
});
it('does not show "Alle als fertig markieren" button when onMarkAllReviewed is not provided', async () => {
renderView({ blocks: [unreviewedBlock1, unreviewedBlock2] });
await expect
.element(page.getByRole('button', { name: /Alle als fertig markieren/ }))
.not.toBeInTheDocument();
});
it('disables button when all blocks are already reviewed', async () => {
renderView({
blocks: [reviewedBlock1, reviewedBlock2],
onMarkAllReviewed: vi.fn().mockResolvedValue(undefined)
});
await expect
.element(page.getByRole('button', { name: /Alle als fertig markieren/ }))
.toBeDisabled();
});
it('calls onMarkAllReviewed exactly once when button is clicked', async () => {
const onMarkAllReviewed = vi.fn().mockResolvedValue(undefined);
renderView({
blocks: [unreviewedBlock1, unreviewedBlock2],
onMarkAllReviewed
});
await page.getByRole('button', { name: /Alle als fertig markieren/ }).click();
await vi.waitFor(() => expect(onMarkAllReviewed).toHaveBeenCalledTimes(1));
});
it('disables button while operation is in-flight', async () => {
let resolveMarkAll!: () => void;
const onMarkAllReviewed = vi
.fn()
.mockReturnValue(new Promise<void>((r) => (resolveMarkAll = r)));
renderView({
blocks: [unreviewedBlock1, unreviewedBlock2],
onMarkAllReviewed
});
const btn = page.getByRole('button', { name: /Alle als fertig markieren/ });
await btn.click();
await expect.element(btn).toBeDisabled();
resolveMarkAll();
});
});

View File

@@ -0,0 +1,102 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { getLocale } from '$lib/paraglide/runtime.js';
import HelpPopover from '$lib/components/HelpPopover.svelte';
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 + help chip -->
<div class="flex items-center gap-1.5">
<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>
<HelpPopover label={m.transcription_mode_help_label()}>
<p class="text-xs leading-relaxed">{m.transcription_mode_help_body()}</p>
</HelpPopover>
</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,182 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import TranscriptionPanelHeader from './TranscriptionPanelHeader.svelte';
afterEach(cleanup);
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');
});
it('renders a (?) help chip next to the Read/Edit toggle', async () => {
render(TranscriptionPanelHeader, {
mode: 'read',
hasBlocks: true,
blockCount: 3,
lastEditedAt: null,
onModeChange: () => {},
onClose: () => {}
});
const helpBtn = document.querySelector('button[aria-expanded]') as HTMLButtonElement;
expect(helpBtn).not.toBeNull();
});
it('opens a help popover with mode explanation when the chip is clicked', async () => {
render(TranscriptionPanelHeader, {
mode: 'read',
hasBlocks: true,
blockCount: 3,
lastEditedAt: null,
onModeChange: () => {},
onClose: () => {}
});
const helpBtn = document.querySelector('button[aria-expanded]') as HTMLButtonElement;
helpBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await vi.waitFor(() => expect(document.querySelector('[role="region"]')).not.toBeNull());
});
});

View File

@@ -0,0 +1,276 @@
<script lang="ts">
import type { TranscriptionBlockData } from '$lib/types';
import type { components } from '$lib/generated/api';
import { splitByMarkers } from '$lib/document/transcription/transcriptionMarkers';
import {
renderTranscriptionBody,
type SafeHtml,
PERSON_MENTION_SELECTOR
} from '$lib/utils/mention';
import { computeHoverCardPosition } from '$lib/utils/hoverCardPosition';
import PersonHoverCard from '$lib/components/PersonHoverCard.svelte';
import type { HoverData, LoadState } from '$lib/types/personHoverCard';
import { goto } from '$app/navigation';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
type Person = components['schemas']['Person'];
type RelationshipDTO = components['schemas']['RelationshipDTO'];
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));
// Per-component (per-mount) in-memory cache: a sweep across 20 mentions of the
// same person must not fire 20 backend calls (B15.5). The Promise<HoverData | null>
// shape lets simultaneous hovers share the same in-flight fetch.
//
// Trade-off: closing and re-opening the transcription panel rebuilds this cache
// (Elicit OQ-372-02). That's intentional — staleness from another tab deleting
// a person is rare in this read-only view, and a per-document/global cache would
// complicate invalidation. If user reports on stale cards accumulate, revisit.
const hoverCache = new SvelteMap<string, Promise<HoverData | null>>();
const deletedPersonIds = new SvelteSet<string>();
let activeCard: {
personId: string;
cardId: string;
state: LoadState;
position: { top: number; left: number };
} | null = $state(null);
// Compose splitByMarkers with renderTranscriptionBody. Markers are pre-rendered
// as <em data-marker> tags; text segments run through HTML-escaping + mention
// substitution. The two are concatenated to preserve marker boundaries — markers
// never end up nested inside an anchor (Felix #5324 B19b).
function renderBlockHtml(block: TranscriptionBlockData): SafeHtml {
return splitByMarkers(block.text)
.map((segment) => {
if (segment.type === 'marker') {
// splitByMarkers only emits the literal markers [unleserlich] and [...],
// no user input — safe to embed directly. Wrap in SafeHtml to satisfy
// the brand contract.
return `<em data-marker class="text-ink-2 italic">${segment.text}</em>` as SafeHtml;
}
return renderTranscriptionBody(segment.text, block.mentionedPersons ?? []);
})
.join('') as SafeHtml;
}
/**
* Fetches person + relationships from the backend. 404 returns null
* (deleted person — caller marks the link as tombstoned). Any other
* non-OK response throws so the caller can render the error state.
*/
async function loadHoverData(personId: string): Promise<HoverData | null> {
const personRes = await fetch(`/api/persons/${personId}`);
if (personRes.status === 404) return null;
if (!personRes.ok) throw new Error(`person fetch failed: ${personRes.status}`);
const person = (await personRes.json()) as Person;
const relRes = await fetch(`/api/persons/${personId}/relationships`);
const relationships: RelationshipDTO[] = relRes.ok
? ((await relRes.json()) as RelationshipDTO[])
: [];
return { person, relationships };
}
/** Cache wrapper around `loadHoverData` — first hover fires the fetch, all
* subsequent hovers (and concurrent in-flight ones) share the same Promise. */
function getOrFetchHoverData(personId: string): Promise<HoverData | null> {
const cached = hoverCache.get(personId);
if (cached) return cached;
const promise = loadHoverData(personId);
hoverCache.set(personId, promise);
return promise;
}
function currentViewport() {
return {
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight
};
}
let closeTimer = $state<ReturnType<typeof setTimeout> | null>(null);
function scheduleCardClose() {
// 150ms: long enough for pointer movement from mention to card, short enough
// to feel responsive. Matches the Radix/shadcn hover card delay.
closeTimer = setTimeout(() => {
activeCard = null;
closeTimer = null;
}, 150);
}
function cancelCardClose() {
if (closeTimer !== null) {
clearTimeout(closeTimer);
closeTimer = null;
}
}
async function handleMentionEnter(event: Event) {
cancelCardClose();
const link = event.target as HTMLAnchorElement;
const personId = link.dataset.personId;
if (!personId) return;
if (deletedPersonIds.has(personId)) return;
const cardId = `person-hover-card-${personId}`;
link.setAttribute('aria-describedby', cardId);
const rect = link.getBoundingClientRect();
const position = computeHoverCardPosition(rect, currentViewport());
activeCard = { personId, cardId, position, state: { status: 'loading' } };
try {
const data = await getOrFetchHoverData(personId);
// Bail if a different mention is now active
if (!activeCard || activeCard.personId !== personId) return;
if (data === null) {
deletedPersonIds.add(personId);
link.setAttribute('data-person-deleted', 'true');
activeCard = null;
return;
}
activeCard = {
personId,
cardId,
position,
state: { status: 'loaded', person: data.person, relationships: data.relationships }
};
} catch {
if (!activeCard || activeCard.personId !== personId) return;
activeCard = { personId, cardId, position, state: { status: 'error' } };
}
}
function scheduleMentionLeave(event: Event) {
const link = event.target as HTMLAnchorElement;
link.removeAttribute('aria-describedby');
scheduleCardClose();
}
/**
* Modified clicks (ctrl/meta/shift/alt) and middle-clicks must fall through to
* the browser's default anchor behaviour so users can open the person page in
* a new tab/window. Felix #7. Only the plain primary-button click navigates
* via SPA goto().
*/
function isPlainPrimaryClick(event: MouseEvent): boolean {
return event.button === 0 && !event.ctrlKey && !event.metaKey && !event.shiftKey && !event.altKey;
}
async function handleMentionClick(event: MouseEvent) {
if (!isPlainPrimaryClick(event)) return;
const link = event.target as HTMLAnchorElement;
const personId = link.dataset.personId;
if (!personId) return;
if (deletedPersonIds.has(personId)) {
event.preventDefault();
return;
}
event.preventDefault();
await goto(`/persons/${personId}`);
}
// Attach delegated event listeners on each rendered block. Using {@html ...}
// for the body means we cannot bind events declaratively to the injected
// anchors, so we hook up listeners via a Svelte action when the wrapper mounts.
//
// Keyboard parity (Leonie FINDING-01, WCAG 2.1.1): focusin/focusout mirror
// mouseenter/mouseleave so users tabbing through transcribed text get the
// same preview affordance.
function attachMentionHandlers(node: HTMLElement) {
function onEnter(e: Event) {
const t = e.target as HTMLElement;
if (t.matches?.(PERSON_MENTION_SELECTOR)) handleMentionEnter(e);
}
function onLeave(e: Event) {
const t = e.target as HTMLElement;
if (t.matches?.(PERSON_MENTION_SELECTOR)) scheduleMentionLeave(e);
}
function onClick(e: MouseEvent) {
const t = e.target as HTMLElement;
if (t.matches?.(PERSON_MENTION_SELECTOR)) handleMentionClick(e);
}
// mouseenter does not bubble — capture it.
node.addEventListener('mouseenter', onEnter, true);
node.addEventListener('mouseleave', onLeave, true);
// focusin/focusout do bubble — no capture phase needed.
node.addEventListener('focusin', onEnter);
node.addEventListener('focusout', onLeave);
node.addEventListener('click', onClick);
return {
destroy() {
node.removeEventListener('mouseenter', onEnter, true);
node.removeEventListener('mouseleave', onLeave, true);
node.removeEventListener('focusin', onEnter);
node.removeEventListener('focusout', onLeave);
node.removeEventListener('click', onClick);
}
};
}
</script>
<article class="px-6 py-8" use:attachMentionHandlers>
{#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);
}}
>
<!-- eslint-disable-next-line svelte/no-at-html-tags -- renderTranscriptionBody escapes all HTML before injecting mention links; mirrors CommentMessage.svelte -->
{@html renderBlockHtml(block)}
</div>
{/each}
</article>
{#if activeCard}
<PersonHoverCard
personId={activeCard.personId}
cardId={activeCard.cardId}
position={activeCard.position}
state={activeCard.state}
onmouseenter={cancelCardClose}
onmouseleave={() => { activeCard = null; }}
/>
{/if}
<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,170 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import TranscriptionReadView from './TranscriptionReadView.svelte';
import type { TranscriptionBlockData } from '$lib/types';
const PERSON_ID = '11111111-0000-0000-0000-000000000001';
const block: TranscriptionBlockData = {
id: 'b1',
annotationId: 'a1',
documentId: 'd1',
text: '@Auguste',
label: null,
sortOrder: 0,
version: 1,
source: 'MANUAL',
reviewed: false,
mentionedPersons: [{ personId: PERSON_ID, displayName: 'Auguste' }]
};
function mockPersonFetch() {
vi.stubGlobal(
'fetch',
vi.fn().mockImplementation((url: string) => {
if (url.includes('/relationships')) {
return Promise.resolve({ ok: true, json: () => Promise.resolve([]) });
}
return Promise.resolve({
ok: true,
status: 200,
json: () =>
Promise.resolve({
id: PERSON_ID,
firstName: 'Auguste',
lastName: 'Raddatz',
displayName: 'Auguste Raddatz'
})
});
})
);
}
function getMentionLink(): HTMLAnchorElement {
return document.querySelector(
`a.person-mention[data-person-id="${PERSON_ID}"]`
) as HTMLAnchorElement;
}
function getHoverCard(): HTMLElement | null {
return document.querySelector('[data-testid="person-hover-card"]');
}
/** Hover a mention and wait until the loaded card content is in the DOM. */
async function showCard(): Promise<void> {
getMentionLink().dispatchEvent(new MouseEvent('mouseenter', { bubbles: false }));
await vi.waitFor(() => {
expect(document.querySelector('[data-testid="person-hover-card-content"]')).not.toBeNull();
});
}
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
// ─── Mouse timer behavior ──────────────────────────────────────────────────────
describe('TranscriptionReadView — hover card mouse timer', () => {
it('keeps the card open when mouse moves from mention to card within 150ms', async () => {
mockPersonFetch();
render(TranscriptionReadView, { blocks: [block], onParagraphClick: () => {} });
await showCard();
// Leave mention — starts 150ms close timer
getMentionLink().dispatchEvent(new MouseEvent('mouseleave', { bubbles: false }));
// Enter card before 150ms — cancels timer
getHoverCard()!.dispatchEvent(new MouseEvent('mouseenter'));
// Wait past the original 150ms window
await new Promise((r) => setTimeout(r, 200));
expect(getHoverCard()).not.toBeNull();
});
it('closes the card immediately when mouse leaves the card (no timer)', async () => {
mockPersonFetch();
render(TranscriptionReadView, { blocks: [block], onParagraphClick: () => {} });
await showCard();
// Leave card — activeCard = null immediately, no timer
getHoverCard()!.dispatchEvent(new MouseEvent('mouseleave'));
await vi.waitFor(() => {
expect(getHoverCard()).toBeNull();
});
});
it('cancels a pending close when mouse re-enters a mention', async () => {
mockPersonFetch();
render(TranscriptionReadView, { blocks: [block], onParagraphClick: () => {} });
await showCard();
// Leave mention — starts 150ms close timer
getMentionLink().dispatchEvent(new MouseEvent('mouseleave', { bubbles: false }));
// Re-enter same mention before 150ms — cancels timer
getMentionLink().dispatchEvent(new MouseEvent('mouseenter', { bubbles: false }));
// Wait past the original 150ms window
await new Promise((r) => setTimeout(r, 200));
expect(getHoverCard()).not.toBeNull();
});
});
// ─── Keyboard focus behavior ───────────────────────────────────────────────────
describe('TranscriptionReadView — hover card keyboard focus', () => {
it('keeps the card open when keyboard focus moves from mention into card', async () => {
mockPersonFetch();
render(TranscriptionReadView, { blocks: [block], onParagraphClick: () => {} });
// Show card via keyboard focusin on mention
getMentionLink().dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
await vi.waitFor(() => {
expect(document.querySelector('[data-testid="person-hover-card-content"]')).not.toBeNull();
});
// Focus leaves mention — starts 150ms close timer
getMentionLink().dispatchEvent(new FocusEvent('focusout', { bubbles: true }));
// Focus enters card — should cancel the close timer
getHoverCard()!.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
// Wait past the 150ms window
await new Promise((r) => setTimeout(r, 200));
expect(getHoverCard()).not.toBeNull();
});
it('closes the card when keyboard focus leaves the card entirely', async () => {
mockPersonFetch();
render(TranscriptionReadView, { blocks: [block], onParagraphClick: () => {} });
// Show card via keyboard focusin
getMentionLink().dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
await vi.waitFor(() => {
expect(document.querySelector('[data-testid="person-hover-card-content"]')).not.toBeNull();
});
// Focus leaves mention — 150ms timer starts
getMentionLink().dispatchEvent(new FocusEvent('focusout', { bubbles: true }));
// Focus enters card — cancels timer
getHoverCard()!.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
// Focus leaves card entirely (relatedTarget = null means focus left the page)
getHoverCard()!.dispatchEvent(
new FocusEvent('focusout', { bubbles: true, relatedTarget: null })
);
await vi.waitFor(() => {
expect(getHoverCard()).toBeNull();
});
});
});

View File

@@ -0,0 +1,484 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { cleanup, 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,
source: 'MANUAL',
reviewed: false,
mentionedPersons: []
},
{
id: 'b2',
annotationId: 'ann-2',
documentId: 'doc-1',
text: 'Second paragraph text.',
label: null,
sortOrder: 2,
version: 1,
source: 'MANUAL',
reviewed: false,
mentionedPersons: []
}
];
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,
source: 'MANUAL',
reviewed: false,
mentionedPersons: []
}
],
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,
source: 'MANUAL',
reviewed: false,
mentionedPersons: []
}
],
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);
});
});
describe('TranscriptionReadView — person-mention rendering', () => {
const PERSON_ID = '550e8400-e29b-41d4-a716-446655440000';
const mentionBlock: TranscriptionBlockData = {
id: 'b1',
annotationId: 'ann-1',
documentId: 'doc-1',
text: 'Brief an @Auguste Raddatz vom Mai',
label: null,
sortOrder: 1,
version: 1,
source: 'MANUAL',
reviewed: false,
mentionedPersons: [{ personId: PERSON_ID, displayName: 'Auguste Raddatz' }]
};
beforeEach(() => {
// Default: any /api/persons/{id} call returns 404 unless a test overrides it.
// Tests that need loaded data stub fetch themselves.
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ status: 404, ok: false, json: vi.fn() }));
});
afterEach(() => {
vi.unstubAllGlobals();
cleanup();
});
it('renders a person mention as an anchor link with the person URL', async () => {
render(TranscriptionReadView, {
blocks: [mentionBlock],
onParagraphClick: () => {}
});
const link = document.querySelector(`a.person-mention[data-person-id="${PERSON_ID}"]`)!;
expect(link).not.toBeNull();
expect(link.getAttribute('href')).toBe(`/persons/${PERSON_ID}`);
expect(link.textContent).toBe('Auguste Raddatz');
});
it('strips the @ trigger from the rendered link text (read mode)', async () => {
render(TranscriptionReadView, {
blocks: [mentionBlock],
onParagraphClick: () => {}
});
const block = document.querySelector('[data-block-id="b1"]')!;
expect(block.textContent).not.toContain('@Auguste Raddatz');
expect(block.textContent).toContain('Auguste Raddatz');
});
it('renders mention link AND [unleserlich] marker correctly when both occur in the same block (B19b)', async () => {
const block: TranscriptionBlockData = {
...mentionBlock,
text: 'Hallo @Auguste Raddatz [unleserlich] Marie'
};
render(TranscriptionReadView, {
blocks: [block],
onParagraphClick: () => {}
});
// Mention rendered as an anchor
const link = document.querySelector('a.person-mention')!;
expect(link).not.toBeNull();
expect(link.textContent).toBe('Auguste Raddatz');
// Marker rendered as <em data-marker>
const marker = document.querySelector('[data-marker]')!;
expect(marker).not.toBeNull();
expect(marker.textContent).toBe('[unleserlich]');
// Marker text is NOT inside the anchor — they are siblings, not nested
expect(link.contains(marker)).toBe(false);
// No double-escape — text content reads cleanly
const blockEl = document.querySelector('[data-block-id="b1"]')!;
expect(blockEl.textContent).not.toContain('&amp;');
expect(blockEl.textContent).not.toContain('&lt;');
});
it('does not render mention link for plain text without the @ trigger', async () => {
const plain: TranscriptionBlockData = {
...mentionBlock,
text: 'Auguste Raddatz war hier',
mentionedPersons: [{ personId: PERSON_ID, displayName: 'Auguste Raddatz' }]
};
render(TranscriptionReadView, {
blocks: [plain],
onParagraphClick: () => {}
});
const link = document.querySelector('a.person-mention');
expect(link).toBeNull();
});
it('escapes HTML in the block text — no stored XSS via raw text', async () => {
const xss: TranscriptionBlockData = {
...mentionBlock,
text: '<img src=x onerror=alert(1)>',
mentionedPersons: []
};
render(TranscriptionReadView, {
blocks: [xss],
onParagraphClick: () => {}
});
// No raw <img> tag in DOM
expect(document.querySelector('[data-block-id="b1"] img')).toBeNull();
// The escaped text is visible
const block = document.querySelector('[data-block-id="b1"]')!;
expect(block.textContent).toContain('<img src=x onerror=alert(1)>');
});
it('triggers fetch for the person on mention mouseenter (B15.5 cache, single call)', async () => {
const fetchMock = vi.fn().mockResolvedValue({
status: 404,
ok: false,
json: vi.fn()
});
vi.stubGlobal('fetch', fetchMock);
render(TranscriptionReadView, {
blocks: [mentionBlock],
onParagraphClick: () => {}
});
const link = document.querySelector('a.person-mention')!;
link.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
await vi.waitFor(() => {
const personFetches = fetchMock.mock.calls.filter((c) =>
String(c[0]).includes(`/api/persons/${PERSON_ID}`)
);
expect(personFetches.length).toBeGreaterThanOrEqual(1);
});
});
it('deduplicates fetches for the same personId across multiple mouseenter events (B15.5)', async () => {
const fetchMock = vi.fn().mockResolvedValue({
status: 404,
ok: false,
json: vi.fn()
});
vi.stubGlobal('fetch', fetchMock);
// Two blocks both mention the same person
const block2: TranscriptionBlockData = { ...mentionBlock, id: 'b2', annotationId: 'ann-2' };
render(TranscriptionReadView, {
blocks: [mentionBlock, block2],
onParagraphClick: () => {}
});
const links = document.querySelectorAll('a.person-mention');
links.forEach((link) => link.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true })));
// Plus a re-hover on the first
links[0].dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
await vi.waitFor(() => {
const personFetches = fetchMock.mock.calls.filter(
(c) => String(c[0]) === `/api/persons/${PERSON_ID}`
);
expect(personFetches.length).toBe(1);
});
});
it('mounts the hover card on mouseenter when the fetch loads', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockImplementation((url: string) => {
if (url.endsWith('/relationships')) {
return Promise.resolve({ status: 200, ok: true, json: () => Promise.resolve([]) });
}
return Promise.resolve({
status: 200,
ok: true,
json: () =>
Promise.resolve({
id: PERSON_ID,
firstName: 'Auguste',
lastName: 'Raddatz',
displayName: 'Auguste Raddatz',
personType: 'PERSON',
familyMember: true,
birthYear: 1882,
deathYear: 1944
})
});
})
);
render(TranscriptionReadView, {
blocks: [mentionBlock],
onParagraphClick: () => {}
});
const link = document.querySelector('a.person-mention')!;
link.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
await vi.waitFor(() => {
const card = document.querySelector('[data-testid="person-hover-card"]');
expect(card).not.toBeNull();
});
});
it('unmounts the hover card on mouseleave', async () => {
render(TranscriptionReadView, {
blocks: [mentionBlock],
onParagraphClick: () => {}
});
const link = document.querySelector('a.person-mention')!;
link.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
link.dispatchEvent(new MouseEvent('mouseleave', { bubbles: true }));
await vi.waitFor(() => {
const card = document.querySelector('[data-testid="person-hover-card"]');
expect(card).toBeNull();
});
});
it('mounts the hover card on focusin so keyboard users see the preview (WCAG 2.1.1)', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockImplementation((url: string) => {
if (String(url).endsWith('/relationships')) {
return Promise.resolve({ status: 200, ok: true, json: () => Promise.resolve([]) });
}
return Promise.resolve({
status: 200,
ok: true,
json: () =>
Promise.resolve({
id: PERSON_ID,
firstName: 'Auguste',
lastName: 'Raddatz',
displayName: 'Auguste Raddatz',
personType: 'PERSON',
familyMember: true
})
});
})
);
render(TranscriptionReadView, {
blocks: [mentionBlock],
onParagraphClick: () => {}
});
const link = document.querySelector('a.person-mention')!;
link.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
await vi.waitFor(() => {
const card = document.querySelector('[data-testid="person-hover-card"]');
expect(card).not.toBeNull();
});
});
it('unmounts the hover card on focusout', async () => {
render(TranscriptionReadView, {
blocks: [mentionBlock],
onParagraphClick: () => {}
});
const link = document.querySelector('a.person-mention')!;
link.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
await vi.waitFor(() => {
// the card mounts even in 404 → loading → null path; assert it cleans up on blur
});
link.dispatchEvent(new FocusEvent('focusout', { bubbles: true }));
await vi.waitFor(() => {
const card = document.querySelector('[data-testid="person-hover-card"]');
expect(card).toBeNull();
});
});
it('lets ctrl-click and meta-click fall through so users can open in a new tab', async () => {
render(TranscriptionReadView, {
blocks: [mentionBlock],
onParagraphClick: () => {}
});
const link = document.querySelector('a.person-mention')! as HTMLAnchorElement;
// ctrl-click (Linux/Win "open in new tab")
const ctrlClick = new MouseEvent('click', { bubbles: true, cancelable: true, ctrlKey: true });
const ctrlPrevented = !link.dispatchEvent(ctrlClick);
expect(ctrlPrevented).toBe(false);
// meta-click (macOS "open in new tab")
const metaClick = new MouseEvent('click', { bubbles: true, cancelable: true, metaKey: true });
const metaPrevented = !link.dispatchEvent(metaClick);
expect(metaPrevented).toBe(false);
});
it('lets middle-click fall through so users can open in a background tab', async () => {
render(TranscriptionReadView, {
blocks: [mentionBlock],
onParagraphClick: () => {}
});
const link = document.querySelector('a.person-mention')! as HTMLAnchorElement;
// button === 1 is middle mouse button
const middleClick = new MouseEvent('click', { bubbles: true, cancelable: true, button: 1 });
const prevented = !link.dispatchEvent(middleClick);
expect(prevented).toBe(false);
});
it('degrades to plain unlinked text when the person fetch returns 404', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ status: 404, ok: false, json: vi.fn() }));
render(TranscriptionReadView, {
blocks: [mentionBlock],
onParagraphClick: () => {}
});
const link = document.querySelector('a.person-mention')!;
link.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
await vi.waitFor(() => {
// Anchor is marked as deleted so subsequent hovers/clicks treat it as plain text
const stillLink = document.querySelector('a.person-mention')!;
expect(stillLink.getAttribute('data-person-deleted')).toBe('true');
});
// 404 → no card mounted
const card = document.querySelector('[data-testid="person-hover-card"]');
expect(card).toBeNull();
});
});

View File

@@ -0,0 +1,128 @@
import { describe, it, expect } from 'vitest';
import { BlockConflictResolvedError, mergeBlockOnConflict } from './blockConflictMerge';
import type { PersonMention, TranscriptionBlockData } from '$lib/types';
const baseBlock: TranscriptionBlockData = {
id: 'b1',
annotationId: 'a1',
documentId: 'd1',
text: 'old text from server',
label: null,
sortOrder: 0,
version: 7,
source: 'MANUAL',
reviewed: false,
mentionedPersons: []
};
describe('mergeBlockOnConflict', () => {
it('keeps the local unsaved text — never overwritten by server text (B12b)', () => {
const merged = mergeBlockOnConflict({
serverBlock: { ...baseBlock, text: 'server-side text' },
localText: 'transcriber unsaved input',
localMentions: []
});
expect(merged.text).toBe('transcriber unsaved input');
});
it('takes server-side displayName for personIds present on both sides (rename win)', () => {
const localMentions: PersonMention[] = [
{ personId: 'p-aug', displayName: 'Auguste Raddatz' } // stale: server renamed her
];
const serverMentions: PersonMention[] = [
{ personId: 'p-aug', displayName: 'Augusta Raddatz' } // post-rename
];
const merged = mergeBlockOnConflict({
serverBlock: { ...baseBlock, mentionedPersons: serverMentions },
localText: '@Augusta Raddatz',
localMentions
});
expect(merged.mentionedPersons).toEqual([
{ personId: 'p-aug', displayName: 'Augusta Raddatz' }
]);
});
it('keeps local-only mentions added since last save', () => {
const localMentions: PersonMention[] = [
{ personId: 'p-anna', displayName: 'Anna Schmidt' } // typed since last save
];
const merged = mergeBlockOnConflict({
serverBlock: { ...baseBlock, mentionedPersons: [] },
localText: '@Anna Schmidt',
localMentions
});
expect(merged.mentionedPersons).toContainEqual({
personId: 'p-anna',
displayName: 'Anna Schmidt'
});
});
it('returns a union of personIds when local and server diverge', () => {
const localMentions: PersonMention[] = [{ personId: 'p-anna', displayName: 'Anna Schmidt' }];
const serverMentions: PersonMention[] = [{ personId: 'p-aug', displayName: 'Augusta Raddatz' }];
const merged = mergeBlockOnConflict({
serverBlock: { ...baseBlock, mentionedPersons: serverMentions },
localText: '@Augusta Raddatz und @Anna Schmidt',
localMentions
});
expect(merged.mentionedPersons).toHaveLength(2);
expect(merged.mentionedPersons).toContainEqual({
personId: 'p-aug',
displayName: 'Augusta Raddatz'
});
expect(merged.mentionedPersons).toContainEqual({
personId: 'p-anna',
displayName: 'Anna Schmidt'
});
});
it('carries server version forward so the next save sends the latest revision', () => {
const merged = mergeBlockOnConflict({
serverBlock: { ...baseBlock, version: 42 },
localText: 'x',
localMentions: []
});
expect(merged.version).toBe(42);
});
it('carries server-only mention array through when local has none', () => {
const merged = mergeBlockOnConflict({
serverBlock: {
...baseBlock,
mentionedPersons: [
{ personId: 'p-aug', displayName: 'Augusta Raddatz' },
{ personId: 'p-anna', displayName: 'Anna Schmidt' }
]
},
localText: 'x',
localMentions: []
});
expect(merged.mentionedPersons).toHaveLength(2);
});
it('carries other server fields (sortOrder, reviewed, updatedAt) forward', () => {
const merged = mergeBlockOnConflict({
serverBlock: {
...baseBlock,
sortOrder: 9,
reviewed: true,
updatedAt: '2026-04-29T10:00:00Z'
},
localText: 'x',
localMentions: []
});
expect(merged.sortOrder).toBe(9);
expect(merged.reviewed).toBe(true);
expect(merged.updatedAt).toBe('2026-04-29T10:00:00Z');
});
});
describe('BlockConflictResolvedError', () => {
it('is an Error with code = CONFLICT_RESOLVED', () => {
const err = new BlockConflictResolvedError('block-1');
expect(err).toBeInstanceOf(Error);
expect(err.code).toBe('CONFLICT_RESOLVED');
expect(err.name).toBe('BlockConflictResolvedError');
expect(err.message).toContain('block-1');
});
});

View File

@@ -0,0 +1,50 @@
import type { PersonMention, TranscriptionBlockData } from '$lib/types';
/**
* Sentinel thrown by saveBlockWithConflictRetry after a 409 rename-mid-edit
* has been merged into local state. Surfaces to the autosave hook as an
* error (so the UI shows the retry indicator), but distinguishable from a
* genuine network failure via the code. Carries the merged block snapshot
* on its `merged` property so the caller can update local state without
* a second roundtrip.
*/
export class BlockConflictResolvedError extends Error {
readonly code = 'CONFLICT_RESOLVED' as const;
merged?: TranscriptionBlockData;
constructor(blockId: string) {
super(
`Block ${blockId} was rebased onto the latest server snapshot — retry to save the merged result`
);
this.name = 'BlockConflictResolvedError';
}
}
type MergeArgs = {
serverBlock: TranscriptionBlockData;
localText: string;
localMentions: PersonMention[];
};
/**
* Resolves a 409-Conflict from the server by combining the latest server
* snapshot with the transcriber's unsaved local edits (B12b).
*
* Rules:
* - The transcriber's typed text always wins — never overwrite their input.
* - Server is the source of truth for the displayName of any person it
* knows about; renames that just landed on the server replace stale local
* names by personId.
* - Local-only mentions added since the last save are preserved.
* - All non-mention fields (version, sortOrder, reviewed, updatedAt, ...)
* come from the server snapshot so the next save sends the current
* revision and matches the latest persisted state.
*/
export function mergeBlockOnConflict(args: MergeArgs): TranscriptionBlockData {
const serverIds = new Set(args.serverBlock.mentionedPersons.map((m) => m.personId));
const localOnly = args.localMentions.filter((m) => !serverIds.has(m.personId));
return {
...args.serverBlock,
text: args.localText,
mentionedPersons: [...args.serverBlock.mentionedPersons, ...localOnly]
};
}

View File

@@ -0,0 +1,152 @@
import { describe, it, expect, vi } from 'vitest';
import { saveBlockWithConflictRetry } from './saveBlockWithConflictRetry';
import { BlockConflictResolvedError } from './blockConflictMerge';
import type { PersonMention } from '$lib/types';
const DOC = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
const BLK = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb';
const SERVER_BLOCK_AFTER_RENAME = {
id: BLK,
annotationId: 'cccccccc-cccc-cccc-cccc-cccccccccccc',
documentId: DOC,
text: 'old text from server',
label: null,
sortOrder: 0,
version: 7,
source: 'MANUAL' as const,
reviewed: false,
mentionedPersons: [{ personId: 'p-aug', displayName: 'Augusta Raddatz' }]
};
function mkResponse(status: number, body?: unknown): Response {
return new Response(body === undefined ? null : JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' }
});
}
describe('saveBlockWithConflictRetry', () => {
it('returns the server-saved block on a successful PUT', async () => {
const updated = { ...SERVER_BLOCK_AFTER_RENAME, text: 'persisted text' };
const fetchImpl = vi.fn().mockResolvedValueOnce(mkResponse(200, updated));
const result = await saveBlockWithConflictRetry({
fetchImpl: fetchImpl as unknown as typeof fetch,
documentId: DOC,
blockId: BLK,
text: 'persisted text',
mentionedPersons: []
});
expect(result).toEqual(updated);
expect(fetchImpl).toHaveBeenCalledTimes(1);
expect(fetchImpl).toHaveBeenCalledWith(
`/api/documents/${DOC}/transcription-blocks/${BLK}`,
expect.objectContaining({
method: 'PUT',
body: JSON.stringify({ text: 'persisted text', mentionedPersons: [] })
})
);
});
it('throws BlockConflictResolvedError carrying the merged block on 409', async () => {
const fetchImpl = vi
.fn()
.mockResolvedValueOnce(mkResponse(409))
.mockResolvedValueOnce(mkResponse(200, SERVER_BLOCK_AFTER_RENAME));
const localMentions: PersonMention[] = [{ personId: 'p-aug', displayName: 'Auguste Raddatz' }];
await expect(
saveBlockWithConflictRetry({
fetchImpl: fetchImpl as unknown as typeof fetch,
documentId: DOC,
blockId: BLK,
text: 'transcriber unsaved input',
mentionedPersons: localMentions
})
).rejects.toThrow(BlockConflictResolvedError);
expect(fetchImpl).toHaveBeenCalledTimes(2);
// First call PUT, second is the GET refetch.
expect(fetchImpl.mock.calls[0]?.[1]?.method).toBe('PUT');
expect(fetchImpl.mock.calls[1]?.[1]).toBeUndefined();
});
it('attaches the merged block to err.merged so callers can update local state', async () => {
const fetchImpl = vi
.fn()
.mockResolvedValueOnce(mkResponse(409))
.mockResolvedValueOnce(mkResponse(200, SERVER_BLOCK_AFTER_RENAME));
const localMentions: PersonMention[] = [
{ personId: 'p-aug', displayName: 'Auguste Raddatz' } // stale displayName
];
try {
await saveBlockWithConflictRetry({
fetchImpl: fetchImpl as unknown as typeof fetch,
documentId: DOC,
blockId: BLK,
text: 'transcriber unsaved input',
mentionedPersons: localMentions
});
throw new Error('expected throw');
} catch (err) {
expect(err).toBeInstanceOf(BlockConflictResolvedError);
const merged = (err as BlockConflictResolvedError).merged!;
// Local text wins.
expect(merged.text).toBe('transcriber unsaved input');
// Server displayName wins for shared personId.
expect(merged.mentionedPersons).toEqual([
{ personId: 'p-aug', displayName: 'Augusta Raddatz' }
]);
// Server version carried forward.
expect(merged.version).toBe(7);
}
});
it('throws BlockConflictResolvedError without merged when refetch fails', async () => {
const fetchImpl = vi
.fn()
.mockResolvedValueOnce(mkResponse(409))
.mockResolvedValueOnce(mkResponse(500));
await expect(
saveBlockWithConflictRetry({
fetchImpl: fetchImpl as unknown as typeof fetch,
documentId: DOC,
blockId: BLK,
text: 'x',
mentionedPersons: []
})
).rejects.toMatchObject({ code: 'CONFLICT_RESOLVED', merged: undefined });
});
it('throws Save failed for any other non-OK response', async () => {
const fetchImpl = vi.fn().mockResolvedValueOnce(mkResponse(500));
await expect(
saveBlockWithConflictRetry({
fetchImpl: fetchImpl as unknown as typeof fetch,
documentId: DOC,
blockId: BLK,
text: 'x',
mentionedPersons: []
})
).rejects.toThrow('Save failed');
});
it('rejects ids that are not UUIDs (path-injection guard)', async () => {
const fetchImpl = vi.fn();
await expect(
saveBlockWithConflictRetry({
fetchImpl: fetchImpl as unknown as typeof fetch,
documentId: DOC,
blockId: '../../etc/passwd',
text: 'x',
mentionedPersons: []
})
).rejects.toThrow(/Invalid id/);
expect(fetchImpl).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,63 @@
import type { PersonMention, TranscriptionBlockData } from '$lib/types';
import {
BlockConflictResolvedError,
mergeBlockOnConflict
} from '$lib/document/transcription/blockConflictMerge';
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
type Args = {
fetchImpl: typeof fetch;
documentId: string;
blockId: string;
text: string;
mentionedPersons: PersonMention[];
};
/**
* Persists a transcription block edit, with built-in handling for the
* rename-mid-edit conflict (B12b).
*
* - 200/204 → resolves with the server's updated block.
* - 409 → refetches the latest server block, merges it with the
* transcriber's unsaved input via mergeBlockOnConflict, and
* throws BlockConflictResolvedError carrying the merged
* snapshot. The caller is responsible for updating local
* state with `err.merged` before surfacing the error.
* - other → throws Error('Save failed').
*
* Validates both ids against the UUID pattern before any fetch fires
* (Sina #5505 — defence-in-depth path-injection guard).
*/
export async function saveBlockWithConflictRetry(args: Args): Promise<TranscriptionBlockData> {
const { fetchImpl, documentId, blockId, text, mentionedPersons } = args;
if (!UUID_RE.test(documentId) || !UUID_RE.test(blockId)) {
throw new Error(`Invalid id for save: doc=${documentId} block=${blockId}`);
}
const url = `/api/documents/${documentId}/transcription-blocks/${blockId}`;
const res = await fetchImpl(url, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, mentionedPersons })
});
if (res.status === 409) {
const fresh = await fetchImpl(url);
if (!fresh.ok) {
throw new BlockConflictResolvedError(blockId);
}
const serverBlock = (await fresh.json()) as TranscriptionBlockData;
const merged = mergeBlockOnConflict({
serverBlock,
localText: text,
localMentions: mentionedPersons
});
const err = new BlockConflictResolvedError(blockId);
(err as BlockConflictResolvedError & { merged: TranscriptionBlockData }).merged = merged;
throw err;
}
if (!res.ok) throw new Error('Save failed');
return (await res.json()) as TranscriptionBlockData;
}

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

@@ -0,0 +1,205 @@
import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest';
import type { PersonMention } from '$lib/types';
const mockSaveFn =
vi.fn<(blockId: string, text: string, mentionedPersons: PersonMention[]) => Promise<void>>();
const NO_MENTIONS: PersonMention[] = [];
const { createBlockAutoSave } = await import('./useBlockAutoSave.svelte');
describe('createBlockAutoSave', () => {
beforeEach(() => {
vi.useFakeTimers();
mockSaveFn.mockClear();
mockSaveFn.mockResolvedValue(undefined);
});
afterEach(() => {
vi.useRealTimers();
});
it('getSaveState returns idle initially', () => {
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
expect(as.getSaveState('block-1')).toBe('idle');
});
it('debounce coalesces multiple changes — saves once after 1500ms', async () => {
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
as.handleTextChange('block-1', 'text 1', NO_MENTIONS);
as.handleTextChange('block-1', 'text 2', NO_MENTIONS);
as.handleTextChange('block-1', 'text 3', NO_MENTIONS);
await vi.advanceTimersByTimeAsync(1500);
expect(mockSaveFn).toHaveBeenCalledTimes(1);
expect(mockSaveFn).toHaveBeenCalledWith('block-1', 'text 3', NO_MENTIONS);
});
it('handles concurrent blocks independently', async () => {
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
as.handleTextChange('block-1', 'hello', NO_MENTIONS);
as.handleTextChange('block-2', 'world', NO_MENTIONS);
await vi.advanceTimersByTimeAsync(1500);
expect(mockSaveFn).toHaveBeenCalledTimes(2);
});
it('sets save state to saving then saved on success', async () => {
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
as.handleTextChange('block-1', 'text', NO_MENTIONS);
vi.advanceTimersByTime(1500);
expect(as.getSaveState('block-1')).toBe('saving');
await Promise.resolve();
expect(as.getSaveState('block-1')).toBe('saved');
});
it('sets save state to error on save failure', async () => {
mockSaveFn.mockRejectedValue(new Error('save failed'));
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
as.handleTextChange('block-1', 'text', NO_MENTIONS);
await vi.advanceTimersByTimeAsync(1500);
expect(as.getSaveState('block-1')).toBe('error');
});
it('handleRetry saves with provided current text', async () => {
mockSaveFn.mockRejectedValueOnce(new Error('first fails'));
mockSaveFn.mockResolvedValueOnce(undefined);
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
as.handleTextChange('block-1', 'original', NO_MENTIONS);
await vi.advanceTimersByTimeAsync(1500);
expect(as.getSaveState('block-1')).toBe('error');
await as.handleRetry('block-1', 'original', NO_MENTIONS);
expect(mockSaveFn).toHaveBeenCalledTimes(2);
expect(as.getSaveState('block-1')).toBe('saved');
});
it('preserves the in-flight text + mentionedPersons across a save failure (B12)', async () => {
// Hold the second saveFn so we can observe the saving→saved transition
// (Tester #5506 §5).
let resolveSecond!: () => void;
mockSaveFn.mockRejectedValueOnce(new Error('boom'));
mockSaveFn.mockReturnValueOnce(new Promise<void>((r) => (resolveSecond = r)));
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
const mentions: PersonMention[] = [{ personId: 'p-aug', displayName: 'Auguste Raddatz' }];
as.handleTextChange('block-1', '@Auguste Raddatz hi', mentions);
await vi.advanceTimersByTimeAsync(1500);
expect(as.getSaveState('block-1')).toBe('error');
// Retry without re-passing the data — the hook resends the preserved payload.
const retryPromise = as.handleRetry('block-1', 'should-not-be-used', []);
// Yield once so executeSave runs synchronously up to the saveFn await.
await Promise.resolve();
expect(as.getSaveState('block-1')).toBe('saving');
expect(mockSaveFn).toHaveBeenLastCalledWith('block-1', '@Auguste Raddatz hi', mentions);
resolveSecond();
await retryPromise;
expect(as.getSaveState('block-1')).toBe('saved');
});
it('clearBlock removes all state for a block', () => {
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
as.handleTextChange('block-1', 'text', NO_MENTIONS);
as.clearBlock('block-1');
expect(as.getSaveState('block-1')).toBe('idle');
});
it('destroy clears all pending timers so no save occurs', async () => {
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
as.handleTextChange('block-1', 'text', NO_MENTIONS);
as.destroy();
await vi.advanceTimersByTimeAsync(2000);
expect(mockSaveFn).not.toHaveBeenCalled();
});
});
describe('flushOnUnload', () => {
let mockFetch: Mock;
beforeEach(() => {
vi.useFakeTimers();
mockSaveFn.mockClear();
mockSaveFn.mockResolvedValue(undefined);
mockFetch = vi.fn().mockResolvedValue(new Response(null, { status: 200 }));
vi.stubGlobal('fetch', mockFetch);
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
it('sends a PUT request with keepalive:true for each pending block', () => {
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
as.handleTextChange('block-1', 'hello', NO_MENTIONS);
as.handleTextChange('block-2', 'world', NO_MENTIONS);
as.flushOnUnload();
expect(mockFetch).toHaveBeenCalledTimes(2);
expect(mockFetch).toHaveBeenCalledWith(
'/api/documents/doc-1/transcription-blocks/block-1',
expect.objectContaining({
method: 'PUT',
keepalive: true,
body: JSON.stringify({ text: 'hello', mentionedPersons: [] })
})
);
expect(mockFetch).toHaveBeenCalledWith(
'/api/documents/doc-1/transcription-blocks/block-2',
expect.objectContaining({
method: 'PUT',
keepalive: true,
body: JSON.stringify({ text: 'world', mentionedPersons: [] })
})
);
});
it('does not call navigator.sendBeacon', () => {
const sendBeaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true);
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
as.handleTextChange('block-1', 'text', NO_MENTIONS);
as.flushOnUnload();
expect(sendBeaconSpy).not.toHaveBeenCalled();
});
it('does nothing when there are no pending edits', () => {
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
as.flushOnUnload();
expect(mockFetch).not.toHaveBeenCalled();
});
it('cancels the debounce timer so saveFn is not also called', async () => {
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
as.handleTextChange('block-1', 'text', NO_MENTIONS);
as.flushOnUnload();
await vi.advanceTimersByTimeAsync(2000);
expect(mockSaveFn).not.toHaveBeenCalled();
});
it('does not send fetch if debounce already fired and pendingTexts is empty', async () => {
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
as.handleTextChange('block-1', 'text', NO_MENTIONS);
await vi.advanceTimersByTimeAsync(1500);
mockFetch.mockClear();
as.flushOnUnload();
expect(mockFetch).not.toHaveBeenCalled();
});
it('flushes the pending mentionedPersons sidecar alongside text', () => {
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
const mentions: PersonMention[] = [{ personId: 'p-1', displayName: 'Auguste Raddatz' }];
as.handleTextChange('block-1', '@Auguste Raddatz', mentions);
as.flushOnUnload();
expect(mockFetch).toHaveBeenCalledWith(
'/api/documents/doc-1/transcription-blocks/block-1',
expect.objectContaining({
body: JSON.stringify({ text: '@Auguste Raddatz', mentionedPersons: mentions })
})
);
});
});

View File

@@ -0,0 +1,150 @@
import { SvelteMap } from 'svelte/reactivity';
import type { PersonMention } from '$lib/types';
export type SaveState = 'idle' | 'saving' | 'saved' | 'fading' | 'error';
type Options = {
saveFn: (blockId: string, text: string, mentionedPersons: PersonMention[]) => Promise<void>;
documentId: string;
};
export function createBlockAutoSave({ saveFn, documentId }: Options) {
const saveStates = new SvelteMap<string, SaveState>();
const debounceTimers = new SvelteMap<string, ReturnType<typeof setTimeout>>();
const pendingTexts = new SvelteMap<string, string>();
const pendingMentions = new SvelteMap<string, PersonMention[]>();
const fadeTimers: ReturnType<typeof setTimeout>[] = [];
function getSaveState(blockId: string): SaveState {
return saveStates.get(blockId) ?? 'idle';
}
function setSaveState(blockId: string, state: SaveState) {
saveStates.set(blockId, state);
}
async function executeSave(blockId: string): Promise<void> {
const text = pendingTexts.get(blockId);
if (text === undefined) return;
const mentions = pendingMentions.get(blockId) ?? [];
pendingTexts.delete(blockId);
pendingMentions.delete(blockId);
setSaveState(blockId, 'saving');
try {
await saveFn(blockId, text, mentions);
setSaveState(blockId, 'saved');
scheduleSavedFade(blockId);
} catch {
// Preserve in-flight payload so the user can retry without re-typing.
pendingTexts.set(blockId, text);
pendingMentions.set(blockId, mentions);
setSaveState(blockId, 'error');
}
}
function scheduleSavedFade(blockId: string): void {
const t1 = setTimeout(() => {
if (getSaveState(blockId) === 'saved') {
setSaveState(blockId, 'fading');
const t2 = setTimeout(() => {
if (getSaveState(blockId) === 'fading') {
setSaveState(blockId, 'idle');
}
}, 300);
fadeTimers.push(t2);
}
}, 2000);
fadeTimers.push(t1);
}
function scheduleDebounce(blockId: string): void {
clearDebounce(blockId);
const timer = setTimeout(() => {
debounceTimers.delete(blockId);
executeSave(blockId);
}, 1500);
debounceTimers.set(blockId, timer);
}
function clearDebounce(blockId: string): void {
const existing = debounceTimers.get(blockId);
if (existing !== undefined) {
clearTimeout(existing);
debounceTimers.delete(blockId);
}
}
function handleTextChange(
blockId: string,
text: string,
mentionedPersons: PersonMention[]
): void {
pendingTexts.set(blockId, text);
pendingMentions.set(blockId, mentionedPersons);
scheduleDebounce(blockId);
}
function handleBlur(): void {
for (const [blockId] of [...debounceTimers]) {
clearDebounce(blockId);
executeSave(blockId);
}
}
async function handleRetry(
blockId: string,
currentText: string,
currentMentions: PersonMention[]
): Promise<void> {
const text = pendingTexts.get(blockId) ?? currentText;
const mentions = pendingMentions.get(blockId) ?? currentMentions;
pendingTexts.set(blockId, text);
pendingMentions.set(blockId, mentions);
await executeSave(blockId);
}
function clearBlock(blockId: string): void {
clearDebounce(blockId);
pendingTexts.delete(blockId);
pendingMentions.delete(blockId);
saveStates.delete(blockId);
}
function flushOnUnload(): void {
for (const [blockId, text] of pendingTexts) {
const mentions = pendingMentions.get(blockId) ?? [];
clearDebounce(blockId);
void fetch(`/api/documents/${documentId}/transcription-blocks/${blockId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, mentionedPersons: mentions }),
keepalive: true
});
pendingTexts.delete(blockId);
pendingMentions.delete(blockId);
}
}
function destroy(): void {
for (const timer of debounceTimers.values()) {
clearTimeout(timer);
}
debounceTimers.clear();
for (const timer of fadeTimers) {
clearTimeout(timer);
}
fadeTimers.length = 0;
}
return {
getSaveState,
handleTextChange,
handleBlur,
handleRetry,
clearBlock,
flushOnUnload,
destroy
};
}

View File

@@ -0,0 +1,169 @@
import { describe, it, expect, vi } from 'vitest';
import { createBlockDragDrop } from './useBlockDragDrop.svelte';
import type { TranscriptionBlockData } from '$lib/types';
function makeBlock(id: string, sortOrder: number): TranscriptionBlockData {
return {
id,
annotationId: `ann-${id}`,
documentId: 'doc-1',
text: '',
label: null,
sortOrder,
version: 1,
source: 'MANUAL',
reviewed: false,
mentionedPersons: []
};
}
/**
* Builds a DOM list, mocks getBoundingClientRect (60px per wrapper),
* drags `dragId` and drops it so dropTargetIdx === targetIdx, then
* triggers handlePointerUp. Returns the onReorder spy.
*/
function simulateDragDrop(
dragId: string,
targetIdx: number,
blocks: TranscriptionBlockData[]
): ReturnType<typeof vi.fn> {
const onReorder = vi.fn();
const dd = createBlockDragDrop({ getSortedBlocks: () => blocks, onReorder });
// Build DOM
const listEl = document.createElement('div');
const wrappers = blocks.map(() => {
const grip = document.createElement('div');
grip.setAttribute('data-drag-handle', '');
const wrapper = document.createElement('div');
wrapper.setAttribute('data-block-wrapper', '');
wrapper.appendChild(grip);
listEl.appendChild(wrapper);
return { grip, wrapper };
});
document.body.appendChild(listEl);
dd.setListElement(listEl);
// Mock bounding rects: each wrapper is 60px tall starting at y=0
wrappers.forEach(({ wrapper }, i) => {
vi.spyOn(wrapper, 'getBoundingClientRect').mockReturnValue({
top: i * 60,
height: 60,
bottom: (i + 1) * 60,
left: 0,
right: 100,
width: 100,
x: 0,
y: i * 60,
toJSON: () => ({})
} as DOMRect);
});
const dragIdx = blocks.findIndex((b) => b.id === dragId);
const { grip, wrapper: dragWrapper } = wrappers[dragIdx];
dragWrapper.setPointerCapture = vi.fn();
// Start drag
const downEvent = new PointerEvent('pointerdown', { clientY: dragIdx * 60, cancelable: true });
Object.defineProperty(downEvent, 'target', { value: grip });
dd.handleGripDown(downEvent as PointerEvent, dragId);
// Move pointer to achieve the desired targetIdx
// midpoint of wrapper[i] = i*60 + 30
// clientY just before midpoint[i] → target = i
// clientY past last midpoint → target = wrappers.length
let clientY: number;
if (targetIdx <= 0) {
clientY = 5; // before first midpoint (30)
} else if (targetIdx >= wrappers.length) {
clientY = wrappers.length * 60 + 10; // past all midpoints
} else {
clientY = targetIdx * 60 + 5; // just past top of wrapper[targetIdx], before its midpoint
}
const moveEvent = new PointerEvent('pointermove', { clientY });
dd.handlePointerMove(moveEvent as PointerEvent);
dd.handlePointerUp();
document.body.removeChild(listEl);
return onReorder;
}
describe('createBlockDragDrop', () => {
it('initial state — no drag in progress', () => {
const dd = createBlockDragDrop({ getSortedBlocks: () => [], onReorder: vi.fn() });
expect(dd.draggedBlockId).toBeNull();
expect(dd.dropTargetIdx).toBeNull();
expect(dd.dragOffsetY).toBe(0);
});
it('handleGripDown sets draggedBlockId when grip is hit', () => {
const dd = createBlockDragDrop({ getSortedBlocks: () => [], onReorder: vi.fn() });
const grip = document.createElement('div');
grip.setAttribute('data-drag-handle', '');
const wrapper = document.createElement('div');
wrapper.setAttribute('data-block-wrapper', '');
wrapper.appendChild(grip);
document.body.appendChild(wrapper);
const e = new PointerEvent('pointerdown', { clientY: 100, cancelable: true, bubbles: true });
Object.defineProperty(e, 'target', { value: grip });
wrapper.setPointerCapture = vi.fn();
dd.handleGripDown(e as PointerEvent, 'block-1');
expect(dd.draggedBlockId).toBe('block-1');
document.body.removeChild(wrapper);
});
it('handlePointerUp without active drag is a no-op', () => {
const onReorder = vi.fn();
const dd = createBlockDragDrop({ getSortedBlocks: () => [], onReorder });
dd.handlePointerUp();
expect(onReorder).not.toHaveBeenCalled();
});
it('handlePointerUp with null dropTargetIdx does not call onReorder', () => {
const onReorder = vi.fn();
const blocks = [makeBlock('b1', 0), makeBlock('b2', 1)];
const dd = createBlockDragDrop({ getSortedBlocks: () => blocks, onReorder });
const grip = document.createElement('div');
grip.setAttribute('data-drag-handle', '');
const wrapper = document.createElement('div');
wrapper.setAttribute('data-block-wrapper', '');
wrapper.appendChild(grip);
document.body.appendChild(wrapper);
wrapper.setPointerCapture = vi.fn();
const downEvent = new PointerEvent('pointerdown', { clientY: 50, cancelable: true });
Object.defineProperty(downEvent, 'target', { value: grip });
dd.handleGripDown(downEvent as PointerEvent, 'b1');
// dropTargetIdx is still null (no pointer move happened)
dd.handlePointerUp();
expect(onReorder).not.toHaveBeenCalled();
expect(dd.draggedBlockId).toBeNull();
document.body.removeChild(wrapper);
});
it('reorder: moves block from index 0 to end', () => {
const blocks = [makeBlock('b1', 0), makeBlock('b2', 1), makeBlock('b3', 2)];
const onReorder = simulateDragDrop('b1', 3, blocks);
expect(onReorder).toHaveBeenCalledWith(['b2', 'b3', 'b1']);
});
it('reorder: moves block from end to index 0', () => {
const blocks = [makeBlock('b1', 0), makeBlock('b2', 1), makeBlock('b3', 2)];
const onReorder = simulateDragDrop('b3', 0, blocks);
expect(onReorder).toHaveBeenCalledWith(['b3', 'b1', 'b2']);
});
it('reorder: moves block down by one position (tests insertAt = dropTargetIdx - 1)', () => {
const blocks = [makeBlock('b1', 0), makeBlock('b2', 1), makeBlock('b3', 2)];
// dragId=b1 (idx=0), targetIdx=2 → insertAt = 2-1 = 1 → [b2, b1, b3]
const onReorder = simulateDragDrop('b1', 2, blocks);
expect(onReorder).toHaveBeenCalledWith(['b2', 'b1', 'b3']);
});
});

View File

@@ -0,0 +1,88 @@
import type { TranscriptionBlockData } from '$lib/types';
type Options = {
getSortedBlocks: () => TranscriptionBlockData[];
onReorder: (blockIds: string[]) => void;
};
export function createBlockDragDrop({ getSortedBlocks, onReorder }: Options) {
let draggedBlockId = $state<string | null>(null);
let dropTargetIdx = $state<number | null>(null);
let dragOffsetY = $state(0);
// Internal mutable refs — not reactive
let dragStartY = 0;
let capturedEl: HTMLElement | null = null;
let listEl: HTMLElement | null = null;
function setListElement(el: HTMLElement | null): void {
listEl = el;
}
function handleGripDown(e: PointerEvent, blockId: string): void {
if (!(e.target as HTMLElement).closest('[data-drag-handle]')) return;
e.preventDefault();
draggedBlockId = blockId;
dragStartY = e.clientY;
dragOffsetY = 0;
capturedEl = (e.target as HTMLElement).closest('[data-block-wrapper]') as HTMLElement;
capturedEl?.setPointerCapture(e.pointerId);
}
function handlePointerMove(e: PointerEvent): void {
if (!draggedBlockId || !listEl) return;
dragOffsetY = e.clientY - dragStartY;
const sortedBlocks = getSortedBlocks();
const wrappers = Array.from(listEl.querySelectorAll('[data-block-wrapper]'));
const dragIdx = sortedBlocks.findIndex((b) => b.id === draggedBlockId);
let target: number | null = null;
for (let i = 0; i < wrappers.length; i++) {
const rect = wrappers[i].getBoundingClientRect();
if (e.clientY < rect.top + rect.height / 2) {
target = i;
break;
}
}
if (target === null) target = wrappers.length;
if (target === dragIdx || target === dragIdx + 1) target = null;
dropTargetIdx = target;
}
function handlePointerUp(): void {
if (!draggedBlockId) return;
if (dropTargetIdx !== null) {
const sorted = [...getSortedBlocks()];
const fromIdx = sorted.findIndex((b) => b.id === draggedBlockId);
if (fromIdx >= 0) {
const [moved] = sorted.splice(fromIdx, 1);
const insertAt = dropTargetIdx > fromIdx ? dropTargetIdx - 1 : dropTargetIdx;
sorted.splice(insertAt, 0, moved);
onReorder(sorted.map((b) => b.id));
}
}
draggedBlockId = null;
dropTargetIdx = null;
dragOffsetY = 0;
capturedEl = null;
}
return {
get draggedBlockId() {
return draggedBlockId;
},
get dropTargetIdx() {
return dropTargetIdx;
},
get dragOffsetY() {
return dragOffsetY;
},
setListElement,
handleGripDown,
handlePointerMove,
handlePointerUp
};
}