- OcrTrigger component rendered in the transcription empty state when the document has a file and user has write permission - Review checkmark toggle on each TranscriptionBlock (turquoise when reviewed, muted outline when not). Calls PUT .../review to toggle. - TranscriptionBlockData type: added source + reviewed fields - +page.svelte: triggerOcr() and reviewToggle() functions wired up - Paraglide translations (de/en/es) for review toggle + reviewed count All 687 frontend tests pass. Refs #226, #230 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
360 lines
10 KiB
Svelte
360 lines
10 KiB
Svelte
<script lang="ts">
|
|
import { m } from '$lib/paraglide/messages.js';
|
|
import { SvelteMap } from 'svelte/reactivity';
|
|
import TranscriptionBlock from './TranscriptionBlock.svelte';
|
|
import OcrTrigger from './OcrTrigger.svelte';
|
|
import type { TranscriptionBlockData } from '$lib/types';
|
|
|
|
type SaveState = 'idle' | 'saving' | 'saved' | 'fading' | 'error';
|
|
|
|
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) => Promise<void>;
|
|
onDeleteBlock: (blockId: string) => Promise<void>;
|
|
onReviewToggle: (blockId: string) => Promise<void>;
|
|
onTriggerOcr?: (scriptType: string) => void;
|
|
};
|
|
|
|
let {
|
|
documentId,
|
|
blocks,
|
|
canComment,
|
|
currentUserId,
|
|
activeAnnotationId = null,
|
|
storedScriptType = '',
|
|
canRunOcr = false,
|
|
onBlockFocus,
|
|
onSaveBlock,
|
|
onDeleteBlock,
|
|
onReviewToggle,
|
|
onTriggerOcr
|
|
}: Props = $props();
|
|
|
|
let activeBlockId: string | null = $state(null);
|
|
|
|
// 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;
|
|
});
|
|
let saveStates = new SvelteMap<string, SaveState>();
|
|
let debounceTimers = new SvelteMap<string, ReturnType<typeof setTimeout>>();
|
|
let pendingTexts = new SvelteMap<string, string>();
|
|
let sortedBlocks = $derived([...blocks].sort((a, b) => a.sortOrder - b.sortOrder));
|
|
let hasBlocks = $derived(blocks.length > 0);
|
|
|
|
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) {
|
|
const text = pendingTexts.get(blockId);
|
|
if (text === undefined) return;
|
|
|
|
pendingTexts.delete(blockId);
|
|
setSaveState(blockId, 'saving');
|
|
|
|
try {
|
|
await onSaveBlock(blockId, text);
|
|
setSaveState(blockId, 'saved');
|
|
scheduleSavedFade(blockId);
|
|
} catch {
|
|
setSaveState(blockId, 'error');
|
|
}
|
|
}
|
|
|
|
function scheduleSavedFade(blockId: string) {
|
|
setTimeout(() => {
|
|
if (getSaveState(blockId) === 'saved') {
|
|
setSaveState(blockId, 'fading');
|
|
setTimeout(() => {
|
|
if (getSaveState(blockId) === 'fading') {
|
|
setSaveState(blockId, 'idle');
|
|
}
|
|
}, 300);
|
|
}
|
|
}, 2000);
|
|
}
|
|
|
|
function scheduleDebounce(blockId: string) {
|
|
clearDebounce(blockId);
|
|
const timer = setTimeout(() => {
|
|
debounceTimers.delete(blockId);
|
|
executeSave(blockId);
|
|
}, 1500);
|
|
debounceTimers.set(blockId, timer);
|
|
}
|
|
|
|
function clearDebounce(blockId: string) {
|
|
const existing = debounceTimers.get(blockId);
|
|
if (existing !== undefined) {
|
|
clearTimeout(existing);
|
|
debounceTimers.delete(blockId);
|
|
}
|
|
}
|
|
|
|
function flushAllPending() {
|
|
for (const [blockId] of debounceTimers) {
|
|
clearDebounce(blockId);
|
|
executeSave(blockId);
|
|
}
|
|
}
|
|
|
|
function handleTextChange(blockId: string, text: string) {
|
|
pendingTexts.set(blockId, text);
|
|
scheduleDebounce(blockId);
|
|
}
|
|
|
|
function handleFocus(blockId: string) {
|
|
activeBlockId = blockId;
|
|
onBlockFocus(blockId);
|
|
}
|
|
|
|
function handleBlur() {
|
|
flushAllPending();
|
|
}
|
|
|
|
async function handleRetry(blockId: string) {
|
|
const block = blocks.find((b) => b.id === blockId);
|
|
if (!block) return;
|
|
|
|
const pending = pendingTexts.get(blockId);
|
|
const text = pending ?? block.text;
|
|
pendingTexts.set(blockId, text);
|
|
await executeSave(blockId);
|
|
}
|
|
|
|
function handleDelete(blockId: string) {
|
|
clearDebounce(blockId);
|
|
pendingTexts.delete(blockId);
|
|
saveStates.delete(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();
|
|
// Update blocks with new sort orders from server
|
|
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));
|
|
}
|
|
|
|
// ── Pointer-based drag and drop ──────────────────────────────────────────
|
|
|
|
let draggedBlockId: string | null = $state(null);
|
|
let dropTargetIdx: number | null = $state(null);
|
|
let dragOffsetY: number = $state(0);
|
|
let dragStartY = 0;
|
|
let capturedEl: HTMLElement | null = null;
|
|
let listEl: HTMLElement | null = null;
|
|
|
|
function handleGripDown(e: PointerEvent, blockId: string) {
|
|
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) {
|
|
if (!draggedBlockId || !listEl) return;
|
|
dragOffsetY = e.clientY - dragStartY;
|
|
|
|
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() {
|
|
if (!draggedBlockId) return;
|
|
|
|
if (dropTargetIdx !== null) {
|
|
const sorted = [...sortedBlocks];
|
|
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);
|
|
reorder(sorted.map((b) => b.id));
|
|
}
|
|
}
|
|
|
|
draggedBlockId = null;
|
|
dropTargetIdx = null;
|
|
dragOffsetY = 0;
|
|
capturedEl = null;
|
|
}
|
|
|
|
function flushViaBeacon() {
|
|
for (const [blockId, text] of pendingTexts) {
|
|
clearDebounce(blockId);
|
|
const url = `/api/documents/${documentId}/transcription-blocks/${blockId}`;
|
|
const body = JSON.stringify({ text });
|
|
navigator.sendBeacon(url, new Blob([body], { type: 'application/json' }));
|
|
pendingTexts.delete(blockId);
|
|
}
|
|
}
|
|
|
|
$effect(() => {
|
|
function onBeforeUnload() {
|
|
flushViaBeacon();
|
|
}
|
|
|
|
window.addEventListener('beforeunload', onBeforeUnload);
|
|
|
|
return () => {
|
|
window.removeEventListener('beforeunload', onBeforeUnload);
|
|
for (const timer of debounceTimers.values()) {
|
|
clearTimeout(timer);
|
|
}
|
|
};
|
|
});
|
|
</script>
|
|
|
|
<div class="flex h-full flex-col overflow-y-auto bg-surface p-4">
|
|
{#if hasBlocks}
|
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
<div
|
|
class="flex flex-col gap-3"
|
|
bind:this={listEl}
|
|
onpointermove={handlePointerMove}
|
|
onpointerup={handlePointerUp}
|
|
>
|
|
{#each sortedBlocks as block, i (block.id)}
|
|
{#if 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={handleBlur}
|
|
onpointerdown={(e) => handleGripDown(e, block.id)}
|
|
class="relative transition-all duration-150 {draggedBlockId === block.id ? 'z-10 rounded-lg shadow-xl ring-2 ring-turquoise/40' : ''}"
|
|
style={draggedBlockId === block.id ? `transform: translateY(${dragOffsetY}px) scale(1.02); opacity: 0.9;` : ''}
|
|
>
|
|
<TranscriptionBlock
|
|
blockId={block.id}
|
|
documentId={documentId}
|
|
blockNumber={i + 1}
|
|
text={block.text}
|
|
label={block.label}
|
|
active={activeBlockId === block.id}
|
|
reviewed={block.reviewed ?? false}
|
|
saveState={getSaveState(block.id)}
|
|
canComment={canComment}
|
|
currentUserId={currentUserId}
|
|
onTextChange={(text) => handleTextChange(block.id, text)}
|
|
onFocus={() => handleFocus(block.id)}
|
|
onDeleteClick={() => handleDelete(block.id)}
|
|
onRetry={() => handleRetry(block.id)}
|
|
onReviewToggle={() => onReviewToggle(block.id)}
|
|
onMoveUp={() => handleMoveUp(block.id)}
|
|
onMoveDown={() => handleMoveDown(block.id)}
|
|
isFirst={i === 0}
|
|
isLast={i === sortedBlocks.length - 1}
|
|
/>
|
|
</div>
|
|
{/each}
|
|
|
|
{#if 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>
|
|
</div>
|
|
{:else}
|
|
<div class="flex flex-1 flex-col items-center justify-center px-6 py-12 text-center">
|
|
<svg
|
|
class="mb-4 h-16 w-16 text-ink-3"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
stroke-width="1"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
|
|
/>
|
|
</svg>
|
|
|
|
{#if canRunOcr && onTriggerOcr}
|
|
<p class="mb-6 max-w-xs text-sm leading-relaxed text-ink-3">
|
|
{m.transcription_empty_title()}
|
|
</p>
|
|
<div class="w-full max-w-xs">
|
|
<OcrTrigger
|
|
existingBlockCount={0}
|
|
storedScriptType={storedScriptType}
|
|
onTrigger={onTriggerOcr}
|
|
/>
|
|
</div>
|
|
<p class="mt-4 text-xs text-ink-3">
|
|
{m.transcription_empty_desc()}
|
|
</p>
|
|
{:else}
|
|
<p class="max-w-xs text-sm leading-relaxed text-ink-3">
|
|
{m.transcription_empty_cta()}
|
|
</p>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|