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:
@@ -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>
|
||||
Reference in New Issue
Block a user