feat: add PDF annotation feature (#40)
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>
This commit is contained in:
@@ -1,8 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import type { PDFDocumentProxy, PDFPageProxy, RenderTask } from 'pdfjs-dist';
|
||||
import AnnotationLayer from './AnnotationLayer.svelte';
|
||||
|
||||
let { url }: { url: string } = $props();
|
||||
let {
|
||||
url,
|
||||
documentId = '',
|
||||
canAnnotate = false
|
||||
}: {
|
||||
url: string;
|
||||
documentId?: string;
|
||||
canAnnotate?: boolean;
|
||||
} = $props();
|
||||
|
||||
let pdfDoc = $state<PDFDocumentProxy | null>(null);
|
||||
let currentPage = $state(1);
|
||||
@@ -24,6 +33,22 @@ let textLayerInstance: { cancel: () => void } | null = null;
|
||||
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([
|
||||
@@ -134,6 +159,54 @@ async function prerender(doc: PDFDocumentProxy, pageNum: number) {
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -151,6 +224,12 @@ $effect(() => {
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (documentId) {
|
||||
loadAnnotations(documentId);
|
||||
}
|
||||
});
|
||||
|
||||
function prevPage() {
|
||||
if (currentPage > 1) currentPage -= 1;
|
||||
}
|
||||
@@ -274,6 +353,37 @@ function zoomOut() {
|
||||
</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 -->
|
||||
@@ -297,6 +407,13 @@ function zoomOut() {
|
||||
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}
|
||||
|
||||
Reference in New Issue
Block a user