Backend: - Add ANNOTATE_ALL permission - Add ANNOTATION_NOT_FOUND and ANNOTATION_OVERLAP error codes - V10 migration: document_annotations table with page/rect/color/owner - DocumentAnnotation entity, AnnotationRepository, CreateAnnotationDTO - AnnotationService: overlap detection (rectangle intersection), ownership enforcement on delete - AnnotationController: GET (authenticated), POST/DELETE (ANNOTATE_ALL) - 15 new tests (AnnotationServiceTest, AnnotationControllerTest) — TDD red/green Frontend: - AnnotationLayer.svelte: pointer-event drawing, colored rect overlays, delete buttons - PdfViewer.svelte: annotate toggle, color picker, loads/saves/deletes annotations via API - Disabled annotate button with tooltip for users without ANNOTATE_ALL - canAnnotate exposed from layout server, passed to PdfViewer - errors.ts + de/en/es translations for new error codes - 3 new unit tests for AnnotationLayer — TDD red/green Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
423 lines
10 KiB
Svelte
423 lines
10 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from 'svelte';
|
|
import type { PDFDocumentProxy, PDFPageProxy, RenderTask } from 'pdfjs-dist';
|
|
import AnnotationLayer from './AnnotationLayer.svelte';
|
|
|
|
let {
|
|
url,
|
|
documentId = '',
|
|
canAnnotate = false
|
|
}: {
|
|
url: string;
|
|
documentId?: string;
|
|
canAnnotate?: boolean;
|
|
} = $props();
|
|
|
|
let pdfDoc = $state<PDFDocumentProxy | null>(null);
|
|
let currentPage = $state(1);
|
|
let totalPages = $state(0);
|
|
let scale = $state(1.5);
|
|
let loading = $state(false);
|
|
let error = $state<string | null>(null);
|
|
|
|
// Canvas and text layer container refs — bound via bind:this, not reactive state
|
|
let canvasEl = $state<HTMLCanvasElement | null>(null);
|
|
let textLayerEl = $state<HTMLDivElement | null>(null);
|
|
|
|
// Internal mutable refs for in-flight tasks — NOT $state to avoid reactive loops
|
|
let renderTask: RenderTask | null = null;
|
|
let textLayerInstance: { cancel: () => void } | null = null;
|
|
|
|
// Holds the dynamically-loaded pdfjs module (browser-only)
|
|
// Not $state — we use pdfjsReady as the reactive trigger instead
|
|
let pdfjsLib: typeof import('pdfjs-dist') | null = null;
|
|
let pdfjsReady = $state(false);
|
|
|
|
type Annotation = {
|
|
id: string;
|
|
documentId: string;
|
|
pageNumber: number;
|
|
x: number;
|
|
y: number;
|
|
width: number;
|
|
height: number;
|
|
color: string;
|
|
createdAt: string;
|
|
};
|
|
|
|
let annotations = $state<Annotation[]>([]);
|
|
let annotateMode = $state(false);
|
|
let annotateColor = $state('#ffff00');
|
|
|
|
onMount(async () => {
|
|
// Dynamic import keeps pdfjs out of the SSR bundle entirely
|
|
const [lib, { default: workerUrl }] = await Promise.all([
|
|
import('pdfjs-dist'),
|
|
import('pdfjs-dist/build/pdf.worker.min.mjs?url')
|
|
]);
|
|
lib.GlobalWorkerOptions.workerSrc = workerUrl;
|
|
pdfjsLib = lib;
|
|
pdfjsReady = true;
|
|
});
|
|
|
|
async function loadDocument(src: string) {
|
|
if (!pdfjsLib) return;
|
|
loading = true;
|
|
error = null;
|
|
pdfDoc = null;
|
|
currentPage = 1;
|
|
totalPages = 0;
|
|
|
|
try {
|
|
const loadingTask = pdfjsLib.getDocument(src);
|
|
const doc = await loadingTask.promise;
|
|
pdfDoc = doc;
|
|
totalPages = doc.numPages;
|
|
} catch (e) {
|
|
error = e instanceof Error ? e.message : 'Failed to load PDF';
|
|
} finally {
|
|
loading = false;
|
|
}
|
|
}
|
|
|
|
async function renderPage(doc: PDFDocumentProxy, pageNum: number) {
|
|
if (!pdfjsLib || !canvasEl || !textLayerEl) return;
|
|
|
|
// Cancel any in-flight render
|
|
if (renderTask) {
|
|
renderTask.cancel();
|
|
renderTask = null;
|
|
}
|
|
if (textLayerInstance) {
|
|
textLayerInstance.cancel();
|
|
textLayerInstance = null;
|
|
}
|
|
|
|
let page: PDFPageProxy;
|
|
try {
|
|
page = await doc.getPage(pageNum);
|
|
} catch {
|
|
return;
|
|
}
|
|
|
|
const dpr = window.devicePixelRatio || 1;
|
|
const viewport = page.getViewport({ scale: scale * dpr });
|
|
|
|
const canvas = canvasEl;
|
|
const ctx = canvas.getContext('2d');
|
|
if (!ctx) return;
|
|
|
|
canvas.width = viewport.width;
|
|
canvas.height = viewport.height;
|
|
canvas.style.width = `${viewport.width / dpr}px`;
|
|
canvas.style.height = `${viewport.height / dpr}px`;
|
|
|
|
const task = page.render({ canvas, canvasContext: ctx, viewport });
|
|
renderTask = task;
|
|
|
|
try {
|
|
await task.promise;
|
|
} catch (e: unknown) {
|
|
if (
|
|
typeof e === 'object' &&
|
|
e !== null &&
|
|
'name' in e &&
|
|
(e as { name: string }).name === 'RenderingCancelledException'
|
|
)
|
|
return;
|
|
return;
|
|
}
|
|
renderTask = null;
|
|
|
|
// Text layer
|
|
const textDiv = textLayerEl;
|
|
textDiv.innerHTML = '';
|
|
textDiv.style.width = `${viewport.width / dpr}px`;
|
|
textDiv.style.height = `${viewport.height / dpr}px`;
|
|
|
|
const tl = new pdfjsLib.TextLayer({
|
|
textContentSource: page.streamTextContent(),
|
|
container: textDiv,
|
|
viewport
|
|
});
|
|
textLayerInstance = tl;
|
|
try {
|
|
await tl.render();
|
|
} catch {
|
|
// cancelled
|
|
}
|
|
}
|
|
|
|
async function prerender(doc: PDFDocumentProxy, pageNum: number) {
|
|
const neighbors = [pageNum - 1, pageNum + 1].filter((n) => n >= 1 && n <= doc.numPages);
|
|
for (const n of neighbors) {
|
|
try {
|
|
await doc.getPage(n);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
}
|
|
|
|
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 handleAnnotationDraw(rect: { x: number; y: number; width: number; height: number }) {
|
|
if (!documentId) return;
|
|
try {
|
|
const res = await fetch(`/api/documents/${documentId}/annotations`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
pageNumber: currentPage,
|
|
x: rect.x,
|
|
y: rect.y,
|
|
width: rect.width,
|
|
height: rect.height,
|
|
color: annotateColor
|
|
})
|
|
});
|
|
if (res.ok) {
|
|
const created: Annotation = await res.json();
|
|
annotations = [...annotations, created];
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
async function handleAnnotationDelete(annotationId: string) {
|
|
if (!documentId) return;
|
|
try {
|
|
const res = await fetch(`/api/documents/${documentId}/annotations/${annotationId}`, {
|
|
method: 'DELETE'
|
|
});
|
|
if (res.ok) {
|
|
annotations = annotations.filter((a) => a.id !== annotationId);
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
$effect(() => {
|
|
if (pdfjsReady && url) {
|
|
loadDocument(url);
|
|
}
|
|
});
|
|
|
|
$effect(() => {
|
|
// Read scale synchronously so Svelte tracks it as a dependency.
|
|
// Without this, zoom changes don't re-trigger the effect because
|
|
// scale is only read inside the async renderPage call.
|
|
if (pdfDoc && currentPage && scale > 0) {
|
|
renderPage(pdfDoc, currentPage).then(() => {
|
|
if (pdfDoc) prerender(pdfDoc, currentPage);
|
|
});
|
|
}
|
|
});
|
|
|
|
$effect(() => {
|
|
if (documentId) {
|
|
loadAnnotations(documentId);
|
|
}
|
|
});
|
|
|
|
function prevPage() {
|
|
if (currentPage > 1) currentPage -= 1;
|
|
}
|
|
|
|
function nextPage() {
|
|
if (pdfDoc && currentPage < pdfDoc.numPages) currentPage += 1;
|
|
}
|
|
|
|
function zoomIn() {
|
|
scale += 0.25;
|
|
}
|
|
|
|
function zoomOut() {
|
|
if (scale > 0.5) scale -= 0.25;
|
|
}
|
|
</script>
|
|
|
|
{#if !url}
|
|
<div class="flex h-full w-full items-center justify-center bg-[#2A2A2A] text-gray-400">
|
|
<p class="font-sans text-sm">Keine Datei vorhanden</p>
|
|
</div>
|
|
{:else if error}
|
|
<div
|
|
class="flex h-full w-full flex-col items-center justify-center gap-3 bg-[#2A2A2A] text-gray-300"
|
|
>
|
|
<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-brand-mint underline hover:opacity-80"
|
|
>
|
|
Direkt öffnen
|
|
</a>
|
|
</div>
|
|
{:else}
|
|
<div class="flex h-full w-full flex-col bg-[#2A2A2A]">
|
|
<!-- Controls -->
|
|
<div
|
|
class="flex shrink-0 items-center justify-between gap-2 border-b border-white/10 px-4 py-2"
|
|
>
|
|
<!-- Page navigation -->
|
|
<div class="flex items-center gap-2">
|
|
<button
|
|
onclick={prevPage}
|
|
disabled={currentPage <= 1}
|
|
aria-label="Zurück"
|
|
class="rounded p-1 text-gray-300 transition hover:bg-white/10 disabled:opacity-40"
|
|
>
|
|
<svg
|
|
class="h-4 w-4"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
>
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
|
|
</svg>
|
|
</button>
|
|
|
|
{#if totalPages > 0}
|
|
<span class="font-sans text-xs text-gray-300 tabular-nums">
|
|
{currentPage} / {totalPages}
|
|
</span>
|
|
{/if}
|
|
|
|
<button
|
|
onclick={nextPage}
|
|
disabled={!pdfDoc || currentPage >= totalPages}
|
|
aria-label="Weiter"
|
|
class="rounded p-1 text-gray-300 transition hover:bg-white/10 disabled:opacity-40"
|
|
>
|
|
<svg
|
|
class="h-4 w-4"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
>
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Zoom controls -->
|
|
<div class="flex items-center gap-1">
|
|
<button
|
|
onclick={zoomOut}
|
|
aria-label="Verkleinern"
|
|
class="rounded p-1 text-gray-300 transition hover:bg-white/10"
|
|
>
|
|
<svg
|
|
class="h-4 w-4"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
>
|
|
<circle cx="11" cy="11" r="8" /><path
|
|
stroke-linecap="round"
|
|
d="M21 21l-4.35-4.35M8 11h6"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
<button
|
|
onclick={zoomIn}
|
|
aria-label="Vergrößern"
|
|
class="rounded p-1 text-gray-300 transition hover:bg-white/10"
|
|
>
|
|
<svg
|
|
class="h-4 w-4"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
>
|
|
<circle cx="11" cy="11" r="8" /><path
|
|
stroke-linecap="round"
|
|
d="M21 21l-4.35-4.35M11 8v6M8 11h6"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Annotate controls -->
|
|
{#if canAnnotate}
|
|
<div class="flex items-center gap-1">
|
|
<button
|
|
onclick={() => (annotateMode = !annotateMode)}
|
|
aria-label={annotateMode ? 'Annotieren beenden' : 'Annotieren'}
|
|
class="rounded px-2 py-1 font-sans text-xs text-gray-300 transition hover:bg-white/10 {annotateMode ? 'bg-white/20' : ''}"
|
|
>
|
|
{annotateMode ? 'Fertig' : 'Annotieren'}
|
|
</button>
|
|
{#if annotateMode}
|
|
<input
|
|
type="color"
|
|
bind:value={annotateColor}
|
|
aria-label="Farbe wählen"
|
|
class="h-6 w-6 cursor-pointer rounded border-0 bg-transparent p-0"
|
|
title="Farbe wählen"
|
|
/>
|
|
{/if}
|
|
</div>
|
|
{:else}
|
|
<button
|
|
disabled
|
|
title="Sie benötigen die Berechtigung ANNOTATE_ALL zum Annotieren"
|
|
class="cursor-not-allowed rounded px-2 py-1 font-sans text-xs text-gray-500"
|
|
aria-label="Annotieren (keine Berechtigung)"
|
|
>
|
|
Annotieren
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- PDF canvas area -->
|
|
<div class="relative flex-1 overflow-auto">
|
|
{#if 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={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>
|
|
<AnnotationLayer
|
|
annotations={annotations.filter((a) => a.pageNumber === currentPage)}
|
|
canAnnotate={annotateMode}
|
|
color={annotateColor}
|
|
onDraw={handleAnnotationDraw}
|
|
onDelete={handleAnnotationDelete}
|
|
/>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{/if}
|