Files
familienarchiv/frontend/src/lib/document/viewer/PdfViewer.svelte
Marcel b00ffc358f fix(pdf-viewer): replace vi.mock(pdfjs-dist) with injected libLoader prop
Removes both vi.mock('pdfjs-dist', factory) and
vi.mock('pdfjs-dist/build/pdf.worker.min.mjs?url', factory) from
PdfViewer.svelte.spec.ts — the ManualMockedModule registrations that were
racing with vitest-browser-playwright's birpc teardown channel.

PdfViewer.svelte now accepts an optional libLoader prop (typed as
Parameters<typeof createPdfRenderer>[0]) that is passed untracked to
createPdfRenderer(). Tests supply a vi.fn() fake loader directly as a prop;
production code uses the default loader that imports the real pdfjs-dist.
The birpc route handler for pdfjs-dist is never registered, so no teardown
race is possible. Fixes #535.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 09:55:28 +02:00

280 lines
8.4 KiB
Svelte

<script lang="ts">
import { onMount, setContext, untrack } from 'svelte';
import { createPdfRenderer } from '$lib/document/viewer/usePdfRenderer.svelte';
import PdfControls from './PdfControls.svelte';
import AnnotationLayer from '$lib/document/annotation/AnnotationLayer.svelte';
import type { Annotation } from '$lib/shared/types';
import { m } from '$lib/paraglide/messages.js';
import { parseBackendError, getErrorMessage } from '$lib/shared/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,
onDeleteAnnotationRequest,
documentFileHash,
annotationsDimmed = false,
flashAnnotationId = null,
libLoader = undefined
}: {
url: string;
documentId?: string;
transcribeMode?: boolean;
blockNumbers?: Record<string, number>;
annotationReloadKey?: number;
activeAnnotationId?: string | null;
onAnnotationClick?: (id: string) => void;
onTranscriptionDraw?: (rect: DrawRect) => void;
onDeleteAnnotationRequest?: (annotationId: string) => void;
documentFileHash?: string | null;
annotationsDimmed?: boolean;
flashAnnotationId?: string | null;
libLoader?: Parameters<typeof createPdfRenderer>[0];
} = $props();
const renderer = untrack(() => createPdfRenderer(libLoader));
// 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();
});
$effect(() => {
if (renderer.pdfjsReady && url) {
renderer.loadDocument(url);
}
});
// Wire DOM elements to the renderer and trigger rendering.
// canvasEl is read synchronously so Svelte tracks it as a dependency:
// when the canvas reappears after the loading spinner (loading → false),
// this effect re-fires and renders the already-loaded PDF.
$effect(() => {
if (!canvasEl || !textLayerEl) return;
renderer.setElements(canvasEl, textLayerEl);
// Also track currentPage and scale so page-nav / zoom re-renders work.
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}
onDeleteRequest={onDeleteAnnotationRequest}
/>
{/if}
</div>
</div>
{/if}
</div>
</div>
{/if}