Comments only render inside TranscriptionEditView, so a deep-link into a document with existing reviewed transcriptions landed the user in read mode with no comment element in the DOM — the scroll target silently missed. scrollToCommentFromQuery now takes a setPanelMode callback and calls it with 'edit' whenever both query params are present. The page's own transcribe-mode $effect checks a skipInitialPanelMode flag the deep-link flow sets, so its default-panel-mode logic doesn't race against the explicit override. Two new helper tests pin the contract: panel mode is forced to 'edit' both when transcribe mode is off (entering fresh) and when it is already on (same-page notification click). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
489 lines
15 KiB
Svelte
489 lines
15 KiB
Svelte
<script lang="ts">
|
|
import { onMount, onDestroy, tick } from 'svelte';
|
|
import { m } from '$lib/paraglide/messages.js';
|
|
import { page } from '$app/state';
|
|
import { replaceState } from '$app/navigation';
|
|
import DocumentTopBar from '$lib/components/DocumentTopBar.svelte';
|
|
import DocumentViewer from '$lib/components/DocumentViewer.svelte';
|
|
import TranscriptionEditView from '$lib/components/TranscriptionEditView.svelte';
|
|
import TranscriptionReadView from '$lib/components/TranscriptionReadView.svelte';
|
|
import TranscriptionPanelHeader from '$lib/components/TranscriptionPanelHeader.svelte';
|
|
import type { TranscriptionBlockData } from '$lib/types';
|
|
import { getErrorMessage } from '$lib/errors';
|
|
import { translateOcrProgress } from '$lib/ocr/translateOcrProgress';
|
|
import { createFileLoader } from '$lib/hooks/useFileLoader.svelte';
|
|
import { scrollToCommentFromQuery } from '$lib/utils/deepLinkScroll';
|
|
|
|
let { data } = $props();
|
|
|
|
const doc = $derived(data.document);
|
|
const canWrite = $derived(data.canWrite ?? false);
|
|
const currentUserId = $derived((data.user?.id as string | undefined) ?? null);
|
|
|
|
// ── File loading ──────────────────────────────────────────────────────────────
|
|
|
|
const fileLoader = createFileLoader();
|
|
|
|
$effect(() => {
|
|
if (doc?.id && doc?.filePath) {
|
|
fileLoader.loadFile(`/api/documents/${doc.id}/file`);
|
|
}
|
|
});
|
|
|
|
onDestroy(() => fileLoader.destroy());
|
|
|
|
// ── Mode state ───────────────────────────────────────────────────────────────
|
|
|
|
let transcribeMode = $state(false);
|
|
let panelMode = $state<'read' | 'edit'>('read');
|
|
let activeAnnotationId = $state<string | null>(null);
|
|
let highlightBlockId = $state<string | null>(null);
|
|
let flashAnnotationId = $state<string | null>(null);
|
|
let pdfStripExpanded = $state(false);
|
|
// Flag set by the deep-link helper so the transcribe-mode $effect does not
|
|
// overwrite the panelMode it picked (e.g. forcing 'edit' on notification
|
|
// click-through). One-shot: consumed after the effect's loadBlocks resolves.
|
|
let skipInitialPanelMode = $state(false);
|
|
|
|
const prefersReducedMotion = $derived(
|
|
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
|
);
|
|
|
|
// ── Transcription blocks ─────────────────────────────────────────────────────
|
|
|
|
let transcriptionBlocks = $state<TranscriptionBlockData[]>([]);
|
|
let annotationReloadKey = $state(0);
|
|
|
|
const blockNumbers = $derived(
|
|
Object.fromEntries(
|
|
[...transcriptionBlocks]
|
|
.sort((a, b) => a.sortOrder - b.sortOrder)
|
|
.map((b, i) => [b.annotationId, i + 1])
|
|
)
|
|
);
|
|
|
|
const hasBlocks = $derived(transcriptionBlocks.length > 0);
|
|
|
|
const lastEditedAt = $derived.by(() => {
|
|
if (transcriptionBlocks.length === 0) return null;
|
|
const dates = transcriptionBlocks
|
|
.filter((b) => b.updatedAt)
|
|
.map((b) => new Date(b.updatedAt!).getTime());
|
|
if (dates.length === 0) return null;
|
|
return new Date(Math.max(...dates)).toISOString();
|
|
});
|
|
|
|
async function loadTranscriptionBlocks() {
|
|
if (!doc?.id) return;
|
|
try {
|
|
const res = await fetch(`/api/documents/${doc.id}/transcription-blocks`);
|
|
if (res.ok) {
|
|
transcriptionBlocks = await res.json();
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to load transcription blocks:', e);
|
|
}
|
|
}
|
|
|
|
async function saveBlock(blockId: string, text: string) {
|
|
const res = await fetch(`/api/documents/${doc.id}/transcription-blocks/${blockId}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ text })
|
|
});
|
|
if (!res.ok) throw new Error('Save failed');
|
|
const updated = await res.json();
|
|
transcriptionBlocks = transcriptionBlocks.map((b) => (b.id === blockId ? updated : b));
|
|
}
|
|
|
|
async function deleteBlock(blockId: string) {
|
|
const res = await fetch(`/api/documents/${doc.id}/transcription-blocks/${blockId}`, {
|
|
method: 'DELETE'
|
|
});
|
|
if (!res.ok) throw new Error('Delete failed');
|
|
transcriptionBlocks = transcriptionBlocks.filter((b) => b.id !== blockId);
|
|
annotationReloadKey++;
|
|
}
|
|
|
|
async function reviewToggle(blockId: string) {
|
|
const res = await fetch(`/api/documents/${doc.id}/transcription-blocks/${blockId}/review`, {
|
|
method: 'PUT'
|
|
});
|
|
if (!res.ok) return;
|
|
const updated = await res.json();
|
|
transcriptionBlocks = transcriptionBlocks.map((b) => (b.id === blockId ? updated : b));
|
|
}
|
|
|
|
async function toggleTrainingLabel(label: string, enrolled: boolean) {
|
|
const res = await fetch(`/api/documents/${doc.id}/training-labels`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ label, enrolled })
|
|
});
|
|
if (!res.ok) throw new Error('Failed to update training label');
|
|
}
|
|
|
|
let ocrRunning = $state(false);
|
|
let ocrProgressMessage = $state('');
|
|
let ocrErrorMessage = $state('');
|
|
let ocrPollTimer = $state<ReturnType<typeof setInterval> | null>(null);
|
|
let ocrSkippedPages = $state(0);
|
|
|
|
async function triggerOcr(scriptType: string, useExistingAnnotations: boolean = false) {
|
|
ocrRunning = true;
|
|
ocrErrorMessage = '';
|
|
try {
|
|
const res = await fetch(`/api/documents/${doc.id}/ocr`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ scriptType, useExistingAnnotations })
|
|
});
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
pollOcrJob(data.jobId);
|
|
} else {
|
|
ocrRunning = false;
|
|
const body = await res.json().catch(() => null);
|
|
const code = (body as { code?: string } | null)?.code;
|
|
ocrErrorMessage = code ? getErrorMessage(code) : m.ocr_status_error();
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to trigger OCR:', e);
|
|
ocrRunning = false;
|
|
ocrErrorMessage = m.ocr_status_error();
|
|
}
|
|
}
|
|
|
|
function pollOcrJob(jobId: string) {
|
|
if (ocrPollTimer) clearInterval(ocrPollTimer);
|
|
ocrPollTimer = setInterval(async () => {
|
|
try {
|
|
const res = await fetch(`/api/ocr/jobs/${jobId}`);
|
|
if (!res.ok) return;
|
|
const job = await res.json();
|
|
const rawCode = job.progressMessage ?? '';
|
|
const progress = translateOcrProgress(rawCode);
|
|
ocrProgressMessage = progress.message;
|
|
if (progress.skippedPages !== undefined) ocrSkippedPages = progress.skippedPages;
|
|
if (job.status === 'DONE' || job.status === 'FAILED') {
|
|
if (ocrPollTimer) clearInterval(ocrPollTimer);
|
|
ocrPollTimer = null;
|
|
setTimeout(() => {
|
|
ocrRunning = false;
|
|
ocrProgressMessage = '';
|
|
ocrSkippedPages = 0;
|
|
}, 1000);
|
|
if (job.status === 'FAILED') {
|
|
ocrErrorMessage = m.ocr_status_error();
|
|
}
|
|
await loadTranscriptionBlocks();
|
|
annotationReloadKey++;
|
|
panelMode = transcriptionBlocks.length > 0 ? 'read' : 'edit';
|
|
}
|
|
} catch {
|
|
// polling is best-effort
|
|
}
|
|
}, 2000);
|
|
}
|
|
|
|
async function createBlockFromDraw(rect: {
|
|
x: number;
|
|
y: number;
|
|
width: number;
|
|
height: number;
|
|
pageNumber: number;
|
|
}) {
|
|
try {
|
|
const res = await fetch(`/api/documents/${doc.id}/transcription-blocks`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
pageNumber: rect.pageNumber,
|
|
x: rect.x,
|
|
y: rect.y,
|
|
width: rect.width,
|
|
height: rect.height,
|
|
text: '',
|
|
label: null
|
|
})
|
|
});
|
|
if (res.ok) {
|
|
const created = (await res.json()) as TranscriptionBlockData;
|
|
transcriptionBlocks = [...transcriptionBlocks, created];
|
|
activeAnnotationId = created.annotationId;
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to create transcription block:', e);
|
|
}
|
|
}
|
|
|
|
function handleBlockFocus(blockId: string) {
|
|
const block = transcriptionBlocks.find((b) => b.id === blockId);
|
|
if (block) {
|
|
activeAnnotationId = block.annotationId;
|
|
}
|
|
}
|
|
|
|
async function handleAnnotationClick(annotationId: string) {
|
|
activeAnnotationId = annotationId;
|
|
|
|
if (!transcribeMode) {
|
|
transcribeMode = true;
|
|
await loadTranscriptionBlocks();
|
|
}
|
|
|
|
// In read mode, highlight the matching paragraph
|
|
const block = transcriptionBlocks.find((b) => b.annotationId === annotationId);
|
|
if (block) {
|
|
highlightBlockId = block.id;
|
|
setTimeout(
|
|
() => {
|
|
highlightBlockId = null;
|
|
},
|
|
prefersReducedMotion ? 2000 : 1500
|
|
);
|
|
}
|
|
|
|
// Wait for DOM to render, then scroll to the matching block
|
|
const scrollBehavior = prefersReducedMotion ? 'instant' : 'smooth';
|
|
requestAnimationFrame(() => {
|
|
if (block) {
|
|
const el = document.querySelector(`[data-block-id="${block.id}"]`);
|
|
el?.scrollIntoView({ behavior: scrollBehavior, block: 'nearest' });
|
|
}
|
|
});
|
|
}
|
|
|
|
function handleParagraphClick(annotationId: string) {
|
|
activeAnnotationId = annotationId;
|
|
flashAnnotationId = annotationId;
|
|
pdfStripExpanded = true;
|
|
setTimeout(
|
|
() => {
|
|
flashAnnotationId = null;
|
|
},
|
|
prefersReducedMotion ? 2000 : 1500
|
|
);
|
|
}
|
|
|
|
async function checkOcrStatus() {
|
|
if (!doc?.id) return;
|
|
try {
|
|
const res = await fetch(`/api/documents/${doc.id}/ocr-status`);
|
|
if (!res.ok) return;
|
|
const status = await res.json();
|
|
if ((status.status === 'PENDING' || status.status === 'RUNNING') && status.jobId) {
|
|
ocrRunning = true;
|
|
pollOcrJob(status.jobId);
|
|
}
|
|
} catch {
|
|
// best-effort
|
|
}
|
|
}
|
|
|
|
// Load blocks and check OCR status when transcribe mode is entered
|
|
$effect(() => {
|
|
if (transcribeMode) {
|
|
loadTranscriptionBlocks().then(() => {
|
|
if (skipInitialPanelMode) {
|
|
skipInitialPanelMode = false;
|
|
} else {
|
|
panelMode = transcriptionBlocks.length > 0 ? 'read' : 'edit';
|
|
}
|
|
});
|
|
checkOcrStatus();
|
|
}
|
|
});
|
|
|
|
// ── Navigation / init ─────────────────────────────────────────────────────────
|
|
|
|
let navHeight = $state(0);
|
|
|
|
async function waitForPanelRender(): Promise<void> {
|
|
await tick();
|
|
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
|
}
|
|
|
|
onMount(() => {
|
|
navHeight = document.querySelector('header')?.getBoundingClientRect().height ?? 0;
|
|
|
|
if (doc?.id) {
|
|
localStorage.setItem(
|
|
'familienarchiv.lastVisited',
|
|
JSON.stringify({ id: doc.id, title: doc.title ?? '' })
|
|
);
|
|
}
|
|
|
|
scrollToCommentFromQuery(new URL(page.url), {
|
|
transcribeMode,
|
|
setTranscribeMode: (v) => (transcribeMode = v),
|
|
setPanelMode: (m) => {
|
|
skipInitialPanelMode = true;
|
|
panelMode = m;
|
|
},
|
|
loadBlocks: loadTranscriptionBlocks,
|
|
setActiveAnnotationId: (id) => (activeAnnotationId = id),
|
|
flashAnnotation: (annotationId) => {
|
|
flashAnnotationId = annotationId;
|
|
setTimeout(() => (flashAnnotationId = null), prefersReducedMotion ? 2000 : 1500);
|
|
},
|
|
prefersReducedMotion,
|
|
afterTick: waitForPanelRender,
|
|
getElement: (id) => document.getElementById(id),
|
|
onStripUrl: () => replaceState(page.url.pathname, page.state ?? {})
|
|
}).catch((e) => console.error('deep-link scroll failed', e));
|
|
|
|
function onKeyDown(e: KeyboardEvent) {
|
|
if (e.key === 'Escape' && transcribeMode) {
|
|
transcribeMode = false;
|
|
}
|
|
}
|
|
document.addEventListener('keydown', onKeyDown);
|
|
return () => {
|
|
document.removeEventListener('keydown', onKeyDown);
|
|
if (ocrPollTimer) clearInterval(ocrPollTimer);
|
|
};
|
|
});
|
|
</script>
|
|
|
|
<svelte:head>
|
|
<title>{doc.title || doc.originalFilename || 'Dokument'}</title>
|
|
</svelte:head>
|
|
|
|
<div
|
|
class="fixed right-0 bottom-0 left-0 z-40 flex flex-col overflow-hidden bg-surface"
|
|
style="top: {navHeight}px"
|
|
data-hydrated
|
|
>
|
|
<DocumentTopBar
|
|
doc={doc}
|
|
canWrite={canWrite}
|
|
fileUrl={fileLoader.fileUrl}
|
|
bind:transcribeMode={transcribeMode}
|
|
/>
|
|
|
|
<div class="relative flex-1 overflow-hidden {transcribeMode ? 'flex flex-col md:flex-row' : ''}">
|
|
<div
|
|
class={transcribeMode
|
|
? `relative flex-1 overflow-hidden md:min-h-0 ${panelMode === 'read' ? (pdfStripExpanded ? 'max-h-[50vh] min-h-[50vh] md:max-h-none md:min-h-0' : 'max-h-[70px] min-h-[70px] md:max-h-none md:min-h-0') : 'min-h-[40vh]'} transition-[min-height,max-height] duration-300`
|
|
: 'absolute inset-0'}
|
|
>
|
|
<DocumentViewer
|
|
doc={doc}
|
|
fileUrl={fileLoader.fileUrl}
|
|
isLoading={fileLoader.isLoading}
|
|
error={fileLoader.fileError}
|
|
transcribeMode={transcribeMode && !ocrRunning}
|
|
blockNumbers={blockNumbers}
|
|
annotationReloadKey={annotationReloadKey}
|
|
annotationsDimmed={transcribeMode && panelMode === 'read'}
|
|
flashAnnotationId={flashAnnotationId}
|
|
bind:activeAnnotationId={activeAnnotationId}
|
|
onAnnotationClick={handleAnnotationClick}
|
|
onTranscriptionDraw={createBlockFromDraw}
|
|
/>
|
|
</div>
|
|
|
|
{#if transcribeMode && panelMode === 'read'}
|
|
<button
|
|
type="button"
|
|
onclick={() => (pdfStripExpanded = !pdfStripExpanded)}
|
|
class="flex h-7 w-full shrink-0 items-center justify-center border-t border-line bg-muted text-xs font-semibold text-ink-2 md:hidden"
|
|
aria-label={pdfStripExpanded ? m.scan_collapse() : m.scan_expand()}
|
|
>
|
|
<svg
|
|
class="mr-1 h-3 w-3 transition-transform duration-200 {pdfStripExpanded ? 'rotate-180' : ''}"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2.5"
|
|
aria-hidden="true"
|
|
>
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
|
|
</svg>
|
|
{pdfStripExpanded ? m.scan_collapse() : m.scan_expand()}
|
|
</button>
|
|
{/if}
|
|
|
|
{#if transcribeMode}
|
|
<div
|
|
class="flex min-h-0 flex-1 shrink-0 flex-col border-t border-line md:w-[400px] md:flex-none md:border-t-0 md:border-l lg:w-[480px]"
|
|
>
|
|
<TranscriptionPanelHeader
|
|
mode={panelMode}
|
|
hasBlocks={hasBlocks}
|
|
blockCount={transcriptionBlocks.length}
|
|
lastEditedAt={lastEditedAt}
|
|
onModeChange={(newMode) => (panelMode = newMode)}
|
|
onClose={() => (transcribeMode = false)}
|
|
/>
|
|
<div class="flex-1 overflow-y-auto">
|
|
{#if ocrErrorMessage}
|
|
<div class="mx-4 mt-4 rounded-sm border border-red-200 bg-red-50 px-4 py-3">
|
|
<p class="text-sm text-red-700">{ocrErrorMessage}</p>
|
|
</div>
|
|
{/if}
|
|
{#if ocrRunning}
|
|
<div class="flex flex-1 flex-col items-center justify-center px-6 py-12 text-center">
|
|
<svg
|
|
class="mb-4 h-8 w-8 animate-spin text-brand-mint"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
>
|
|
<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>
|
|
<p class="text-xs font-bold tracking-widest text-gray-400 uppercase">
|
|
{m.ocr_progress_heading()}
|
|
</p>
|
|
<p class="mt-2 text-sm text-ink-2">
|
|
{ocrProgressMessage}
|
|
</p>
|
|
{#if ocrSkippedPages > 0}
|
|
<p class="mt-1 text-xs text-amber-600">
|
|
{ocrSkippedPages} Seiten übersprungen
|
|
</p>
|
|
{/if}
|
|
</div>
|
|
{:else if panelMode === 'read'}
|
|
<TranscriptionReadView
|
|
blocks={transcriptionBlocks}
|
|
highlightBlockId={highlightBlockId}
|
|
onParagraphClick={handleParagraphClick}
|
|
/>
|
|
{:else}
|
|
<TranscriptionEditView
|
|
documentId={doc.id}
|
|
blocks={transcriptionBlocks}
|
|
canComment={canWrite}
|
|
currentUserId={currentUserId}
|
|
activeAnnotationId={activeAnnotationId}
|
|
storedScriptType={doc.scriptType ?? ''}
|
|
canRunOcr={canWrite && !!doc.filePath}
|
|
canWrite={canWrite}
|
|
trainingLabels={doc.trainingLabels ?? []}
|
|
onBlockFocus={handleBlockFocus}
|
|
onSaveBlock={saveBlock}
|
|
onDeleteBlock={deleteBlock}
|
|
onReviewToggle={reviewToggle}
|
|
onTriggerOcr={triggerOcr}
|
|
onToggleTrainingLabel={toggleTrainingLabel}
|
|
/>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|