Production code referenced $lib/shared/services/confirm.svelte under two spellings — 4 files with the .js extension and one without. Standardise on the no-extension form to match Svelte 5 rune-module convention and the source file basename (confirm.svelte.ts). Why this matters: vitest browser mode's @vitest/browser-playwright resolves both spellings to the same module URL but registers a separate Playwright route per spelling. The route-cleanup logic only unregisters the latest, leaving an orphan that crashes the next session with "[birpc] rpc is closed, cannot call resolveManualMock". Fixed upstream in vitest PR #10267 (merged, not yet released). Normalising the spelling removes the trigger from our side. Refs: #553. Companion test-file changes follow in the next commit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
369 lines
12 KiB
Svelte
369 lines
12 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/document/DocumentTopBar.svelte';
|
|
import DocumentViewer from '$lib/document/DocumentViewer.svelte';
|
|
import TranscriptionEditView from '$lib/document/transcription/TranscriptionEditView.svelte';
|
|
import TranscriptionReadView from '$lib/document/transcription/TranscriptionReadView.svelte';
|
|
import TranscriptionPanelHeader from '$lib/document/transcription/TranscriptionPanelHeader.svelte';
|
|
import { createOcrJob } from '$lib/ocr/useOcrJob.svelte';
|
|
import { createTranscriptionBlocks } from '$lib/document/transcription/useTranscriptionBlocks.svelte';
|
|
import { createFileLoader } from '$lib/document/viewer/useFileLoader.svelte';
|
|
import { scrollToCommentFromQuery } from '$lib/shared/utils/deepLinkScroll';
|
|
import { getConfirmService } from '$lib/shared/services/confirm.svelte';
|
|
|
|
let { data } = $props();
|
|
|
|
const { confirm } = getConfirmService();
|
|
|
|
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 ─────────────────────────────────────────────────────
|
|
|
|
const transcription = createTranscriptionBlocks({
|
|
documentId: () => doc?.id ?? ''
|
|
});
|
|
|
|
async function handleAnnotationDeleteRequest(annotationId: string) {
|
|
const confirmed = await confirm({
|
|
title: m.transcription_block_delete_confirm(),
|
|
destructive: true
|
|
});
|
|
if (!confirmed) return;
|
|
await transcription.deleteAnnotation(annotationId);
|
|
}
|
|
|
|
const ocrJob = createOcrJob({
|
|
documentId: () => doc?.id ?? '',
|
|
onJobFinished: async () => {
|
|
await transcription.load();
|
|
transcription.bumpAnnotationReloadKey();
|
|
panelMode = transcription.hasBlocks ? 'read' : 'edit';
|
|
}
|
|
});
|
|
|
|
async function triggerOcr(scriptType: string, useExistingAnnotations: boolean = false) {
|
|
await ocrJob.triggerOcr(scriptType, useExistingAnnotations);
|
|
}
|
|
|
|
async function createBlockFromDraw(rect: {
|
|
x: number;
|
|
y: number;
|
|
width: number;
|
|
height: number;
|
|
pageNumber: number;
|
|
}) {
|
|
const created = await transcription.createFromDraw(rect);
|
|
if (created) {
|
|
activeAnnotationId = created.annotationId;
|
|
}
|
|
}
|
|
|
|
function handleBlockFocus(blockId: string) {
|
|
const block = transcription.blocks.find((b) => b.id === blockId);
|
|
if (block) {
|
|
activeAnnotationId = block.annotationId;
|
|
}
|
|
}
|
|
|
|
async function handleAnnotationClick(annotationId: string) {
|
|
activeAnnotationId = annotationId;
|
|
|
|
if (!transcribeMode) {
|
|
transcribeMode = true;
|
|
await transcription.load();
|
|
}
|
|
|
|
// In read mode, highlight the matching paragraph
|
|
const block = transcription.findByAnnotationId(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
|
|
);
|
|
}
|
|
|
|
// Load blocks and check OCR status when transcribe mode is entered
|
|
$effect(() => {
|
|
if (transcribeMode) {
|
|
transcription.load().then(() => {
|
|
if (skipInitialPanelMode) {
|
|
skipInitialPanelMode = false;
|
|
} else {
|
|
panelMode = transcription.hasBlocks ? 'read' : 'edit';
|
|
}
|
|
});
|
|
ocrJob.checkStatus();
|
|
}
|
|
});
|
|
|
|
// ── 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 ?? '' })
|
|
);
|
|
}
|
|
|
|
if (page.url.searchParams.get('task') === 'transcribe') {
|
|
transcribeMode = true;
|
|
tick()
|
|
.then(() => {
|
|
const closeBtn = document.querySelector<HTMLElement>('[data-testid="panel-close"]');
|
|
closeBtn?.scrollIntoView({
|
|
behavior: prefersReducedMotion ? 'instant' : 'smooth',
|
|
block: 'nearest'
|
|
});
|
|
closeBtn?.focus({ preventScroll: true });
|
|
replaceState(page.url.pathname, page.state ?? {});
|
|
})
|
|
.catch((e) => console.error('task deep-link failed', e));
|
|
}
|
|
|
|
scrollToCommentFromQuery(new URL(page.url), {
|
|
transcribeMode,
|
|
setTranscribeMode: (v) => (transcribeMode = v),
|
|
setPanelMode: (m) => {
|
|
skipInitialPanelMode = true;
|
|
panelMode = m;
|
|
},
|
|
loadBlocks: () => transcription.load(),
|
|
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);
|
|
ocrJob.destroy();
|
|
};
|
|
});
|
|
</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}
|
|
inferredRelationship={data.inferredRelationship}
|
|
geschichten={data.geschichten ?? []}
|
|
canBlogWrite={data.canBlogWrite ?? false}
|
|
/>
|
|
|
|
<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 && !ocrJob.running}
|
|
blockNumbers={transcription.blockNumbers}
|
|
annotationReloadKey={transcription.annotationReloadKey}
|
|
annotationsDimmed={transcribeMode && panelMode === 'read'}
|
|
flashAnnotationId={flashAnnotationId}
|
|
bind:activeAnnotationId={activeAnnotationId}
|
|
onAnnotationClick={handleAnnotationClick}
|
|
onTranscriptionDraw={createBlockFromDraw}
|
|
onDeleteAnnotationRequest={handleAnnotationDeleteRequest}
|
|
/>
|
|
</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={transcription.hasBlocks}
|
|
blockCount={transcription.blocks.length}
|
|
lastEditedAt={transcription.lastEditedAt}
|
|
onModeChange={(newMode) => (panelMode = newMode)}
|
|
onClose={() => (transcribeMode = false)}
|
|
/>
|
|
<div class="flex-1 overflow-y-auto">
|
|
{#if ocrJob.errorMessage}
|
|
<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">{ocrJob.errorMessage}</p>
|
|
</div>
|
|
{/if}
|
|
{#if ocrJob.running}
|
|
<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">
|
|
{ocrJob.progressMessage}
|
|
</p>
|
|
{#if ocrJob.skippedPages > 0}
|
|
<p class="mt-1 text-xs text-amber-600">
|
|
{ocrJob.skippedPages} Seiten übersprungen
|
|
</p>
|
|
{/if}
|
|
</div>
|
|
{:else if panelMode === 'read'}
|
|
<TranscriptionReadView
|
|
blocks={transcription.blocks}
|
|
highlightBlockId={highlightBlockId}
|
|
onParagraphClick={handleParagraphClick}
|
|
/>
|
|
{:else}
|
|
<TranscriptionEditView
|
|
documentId={doc.id}
|
|
blocks={transcription.blocks}
|
|
canComment={canWrite}
|
|
currentUserId={currentUserId}
|
|
activeAnnotationId={activeAnnotationId}
|
|
storedScriptType={doc.scriptType ?? ''}
|
|
canRunOcr={canWrite && !!doc.filePath}
|
|
canWrite={canWrite}
|
|
trainingLabels={doc.trainingLabels ?? []}
|
|
onBlockFocus={handleBlockFocus}
|
|
onSaveBlock={transcription.save}
|
|
onDeleteBlock={transcription.delete}
|
|
onReviewToggle={transcription.reviewToggle}
|
|
onMarkAllReviewed={transcription.markAllReviewed}
|
|
onTriggerOcr={triggerOcr}
|
|
onToggleTrainingLabel={transcription.toggleTrainingLabel}
|
|
/>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|