276 lines
7.9 KiB
Svelte
276 lines
7.9 KiB
Svelte
<script lang="ts">
|
|
import { onMount, setContext } from 'svelte';
|
|
import { createPdfRenderer } from '$lib/hooks/usePdfRenderer.svelte';
|
|
import PdfControls from './PdfControls.svelte';
|
|
import AnnotationLayer from './AnnotationLayer.svelte';
|
|
import type { Annotation } from '$lib/types';
|
|
import { m } from '$lib/paraglide/messages.js';
|
|
import { parseBackendError, getErrorMessage } from '$lib/errors';
|
|
|
|
type DrawRect = { x: number; y: number; width: number; height: number; pageNumber: number };
|
|
|
|
let {
|
|
url,
|
|
documentId = '',
|
|
transcribeMode = false,
|
|
blockNumbers = {},
|
|
annotationReloadKey = 0,
|
|
activeAnnotationId = $bindable<string | null>(null),
|
|
onAnnotationClick,
|
|
onTranscriptionDraw,
|
|
documentFileHash,
|
|
annotationsDimmed = false,
|
|
flashAnnotationId = null
|
|
}: {
|
|
url: string;
|
|
documentId?: string;
|
|
transcribeMode?: boolean;
|
|
blockNumbers?: Record<string, number>;
|
|
annotationReloadKey?: number;
|
|
activeAnnotationId?: string | null;
|
|
onAnnotationClick?: (id: string) => void;
|
|
onTranscriptionDraw?: (rect: DrawRect) => void;
|
|
documentFileHash?: string | null;
|
|
annotationsDimmed?: boolean;
|
|
flashAnnotationId?: string | null;
|
|
} = $props();
|
|
|
|
const renderer = createPdfRenderer();
|
|
|
|
// Canvas and text layer container refs — bound via bind:this
|
|
let canvasEl = $state<HTMLCanvasElement | null>(null);
|
|
let textLayerEl = $state<HTMLDivElement | null>(null);
|
|
|
|
let annotations = $state<Annotation[]>([]);
|
|
let showAnnotations = $state(true);
|
|
let annotationUpdateError = $state<string | null>(null);
|
|
|
|
const TRANSCRIPTION_COLOR = '#00C7B1';
|
|
|
|
const visibleAnnotations = $derived(
|
|
annotations.filter((a) => !a.fileHash || !documentFileHash || a.fileHash === documentFileHash)
|
|
);
|
|
const outdatedCount = $derived(annotations.length - visibleAnnotations.length);
|
|
|
|
onMount(async () => {
|
|
await renderer.init();
|
|
});
|
|
|
|
// Wire DOM elements to the renderer after they mount
|
|
$effect(() => {
|
|
if (canvasEl && textLayerEl) {
|
|
renderer.setElements(canvasEl, textLayerEl);
|
|
}
|
|
});
|
|
|
|
$effect(() => {
|
|
if (renderer.pdfjsReady && url) {
|
|
renderer.loadDocument(url);
|
|
}
|
|
});
|
|
|
|
$effect(() => {
|
|
// Read scale and currentPage synchronously so Svelte tracks them as dependencies.
|
|
if (renderer.isLoaded && renderer.currentPage && renderer.scale > 0) {
|
|
renderer.renderCurrentPage().then(() => renderer.prerender());
|
|
}
|
|
});
|
|
|
|
$effect(() => {
|
|
if (documentId && annotationReloadKey >= 0) {
|
|
loadAnnotations(documentId);
|
|
}
|
|
});
|
|
|
|
$effect(() => {
|
|
if (transcribeMode) showAnnotations = true;
|
|
});
|
|
|
|
// Scroll-sync: when activeAnnotationId changes, navigate to its page
|
|
let prevActiveAnnotationId: string | null = null;
|
|
$effect(() => {
|
|
const id = activeAnnotationId;
|
|
if (!id || id === prevActiveAnnotationId || !renderer.isLoaded) {
|
|
prevActiveAnnotationId = id;
|
|
return;
|
|
}
|
|
prevActiveAnnotationId = id;
|
|
|
|
const ann = annotations.find((a) => a.id === id);
|
|
if (!ann) return;
|
|
|
|
if (ann.pageNumber !== renderer.currentPage) {
|
|
renderer.goToPage(ann.pageNumber);
|
|
}
|
|
|
|
requestAnimationFrame(() => {
|
|
requestAnimationFrame(() => {
|
|
const el = document.querySelector(`[data-testid="annotation-${id}"]`);
|
|
el?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
});
|
|
});
|
|
});
|
|
|
|
async function loadAnnotations(docId: string) {
|
|
if (!docId) return;
|
|
try {
|
|
const res = await fetch(`/api/documents/${docId}/annotations`);
|
|
if (res.ok) {
|
|
annotations = await res.json();
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
async function updateAnnotation(
|
|
annotationId: string,
|
|
coords: { x: number; y: number; width: number; height: number }
|
|
) {
|
|
if (!documentId) return;
|
|
const res = await fetch(`/api/documents/${documentId}/annotations/${annotationId}`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(coords)
|
|
});
|
|
if (!res.ok) {
|
|
const err = await parseBackendError(res);
|
|
const msg = getErrorMessage(err?.code ?? 'ANNOTATION_UPDATE_FAILED');
|
|
annotationUpdateError = msg;
|
|
setTimeout(() => (annotationUpdateError = null), 4000);
|
|
throw new Error(msg);
|
|
}
|
|
const updated = await res.json();
|
|
annotations = annotations.map((a) => (a.id === annotationId ? updated : a));
|
|
}
|
|
|
|
setContext('annotationUpdate', updateAnnotation);
|
|
|
|
async function handleDraw(rect: { x: number; y: number; width: number; height: number }) {
|
|
if (!documentId || !transcribeMode) return;
|
|
await onTranscriptionDraw?.({ ...rect, pageNumber: renderer.currentPage });
|
|
await loadAnnotations(documentId);
|
|
}
|
|
|
|
function handleAnnotationClick(id: string) {
|
|
activeAnnotationId = id;
|
|
onAnnotationClick?.(id);
|
|
}
|
|
</script>
|
|
|
|
{#if !url}
|
|
<div class="flex h-full w-full items-center justify-center bg-pdf-bg text-ink-3">
|
|
<p class="font-sans text-sm">Keine Datei vorhanden</p>
|
|
</div>
|
|
{:else if renderer.error}
|
|
<div class="flex h-full w-full flex-col items-center justify-center gap-3 bg-pdf-bg text-ink-3">
|
|
<p class="font-sans text-sm text-red-400">Fehler beim Laden der PDF</p>
|
|
<a
|
|
href={url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
class="font-sans text-xs text-primary underline hover:text-ink-2"
|
|
>
|
|
Direkt öffnen
|
|
</a>
|
|
</div>
|
|
{:else}
|
|
<div class="flex h-full w-full flex-col bg-pdf-bg">
|
|
{#if outdatedCount > 0}
|
|
<div
|
|
class="flex shrink-0 items-center gap-2 border-b border-amber-500/30 bg-amber-500/10 px-4 py-2"
|
|
data-testid="annotation-outdated-notice"
|
|
>
|
|
<svg
|
|
class="h-4 w-4 shrink-0 text-amber-400"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
|
|
/>
|
|
</svg>
|
|
<span class="font-sans text-xs text-amber-300">{m.annotation_outdated_notice()}</span>
|
|
</div>
|
|
{/if}
|
|
{#if annotationUpdateError}
|
|
<div
|
|
class="flex shrink-0 items-center gap-2 border-b border-red-500/30 bg-red-500/10 px-4 py-2"
|
|
aria-live="assertive"
|
|
role="alert"
|
|
>
|
|
<svg
|
|
class="h-4 w-4 shrink-0 text-red-400"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
>
|
|
<circle cx="12" cy="12" r="10" />
|
|
<line x1="15" y1="9" x2="9" y2="15" />
|
|
<line x1="9" y1="9" x2="15" y2="15" />
|
|
</svg>
|
|
<span class="font-sans text-xs text-red-300">{annotationUpdateError}</span>
|
|
</div>
|
|
{/if}
|
|
|
|
<PdfControls
|
|
currentPage={renderer.currentPage}
|
|
totalPages={renderer.totalPages}
|
|
isLoaded={renderer.isLoaded}
|
|
showAnnotations={showAnnotations}
|
|
annotationCount={annotations.length}
|
|
onPrev={() => renderer.prevPage()}
|
|
onNext={() => renderer.nextPage()}
|
|
onZoomIn={() => renderer.zoomIn()}
|
|
onZoomOut={() => renderer.zoomOut()}
|
|
onToggleAnnotations={() => (showAnnotations = !showAnnotations)}
|
|
/>
|
|
|
|
<!-- PDF canvas area -->
|
|
<div class="relative flex-1 overflow-auto">
|
|
{#if renderer.loading}
|
|
<div class="flex h-full items-center justify-center">
|
|
<div
|
|
class="h-8 w-8 animate-spin rounded-full border-2 border-white/20 border-t-white"
|
|
></div>
|
|
</div>
|
|
{:else}
|
|
<div class="flex min-h-full items-start justify-center p-4">
|
|
<div
|
|
class="pdf-page relative shadow-xl"
|
|
data-page-number={renderer.currentPage}
|
|
style="position: relative"
|
|
>
|
|
<canvas bind:this={canvasEl}></canvas>
|
|
<div
|
|
bind:this={textLayerEl}
|
|
class="textLayer"
|
|
style="position: absolute; top: 0; left: 0; overflow: hidden; pointer-events: none; line-height: 1;"
|
|
></div>
|
|
{#if showAnnotations}
|
|
<AnnotationLayer
|
|
annotations={visibleAnnotations.filter(
|
|
(a) => a.pageNumber === renderer.currentPage
|
|
)}
|
|
canDraw={transcribeMode}
|
|
color={TRANSCRIPTION_COLOR}
|
|
blockNumbers={blockNumbers}
|
|
activeAnnotationId={activeAnnotationId}
|
|
dimmed={annotationsDimmed}
|
|
flashAnnotationId={flashAnnotationId}
|
|
onDraw={handleDraw}
|
|
onAnnotationClick={handleAnnotationClick}
|
|
/>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{/if}
|