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:
Marcel
2026-03-23 23:27:21 +01:00
parent ca5726e7c3
commit b45ec744b2
27 changed files with 903 additions and 9 deletions

View File

@@ -0,0 +1,165 @@
<script lang="ts">
type Annotation = {
id: string;
documentId: string;
pageNumber: number;
x: number;
y: number;
width: number;
height: number;
color: string;
createdAt: string;
};
type DrawRect = {
x: number;
y: number;
width: number;
height: number;
};
let {
annotations = [],
canAnnotate,
color,
onDraw,
onDelete
}: {
annotations: Annotation[];
canAnnotate: boolean;
color: string;
onDraw: (rect: { x: number; y: number; width: number; height: number }) => void;
onDelete: (id: string) => void;
} = $props();
let drawStart = $state<{ x: number; y: number } | null>(null);
let drawRect = $state<DrawRect | null>(null);
function getNormalizedCoords(event: PointerEvent, element: HTMLElement): { x: number; y: number } {
const rect = element.getBoundingClientRect();
return {
x: (event.clientX - rect.left) / rect.width,
y: (event.clientY - rect.top) / rect.height
};
}
function handlePointerDown(event: PointerEvent) {
if (!canAnnotate) return;
const target = event.target as HTMLElement;
if (target.dataset.annotation !== undefined) return;
const container = event.currentTarget as HTMLElement;
container.setPointerCapture(event.pointerId);
const coords = getNormalizedCoords(event, container);
drawStart = coords;
drawRect = { x: coords.x, y: coords.y, width: 0, height: 0 };
}
function handlePointerMove(event: PointerEvent) {
if (!canAnnotate || !drawStart) return;
const container = event.currentTarget as HTMLElement;
const coords = getNormalizedCoords(event, container);
const x = Math.min(drawStart.x, coords.x);
const y = Math.min(drawStart.y, coords.y);
const width = Math.abs(coords.x - drawStart.x);
const height = Math.abs(coords.y - drawStart.y);
drawRect = { x, y, width, height };
}
function handlePointerUp(event: PointerEvent) {
if (!canAnnotate || !drawStart || !drawRect) return;
const container = event.currentTarget as HTMLElement;
const coords = getNormalizedCoords(event, container);
const x = Math.min(drawStart.x, coords.x);
const y = Math.min(drawStart.y, coords.y);
const width = Math.abs(coords.x - drawStart.x);
const height = Math.abs(coords.y - drawStart.y);
if (width > 0.01 && height > 0.01) {
onDraw({ x, y, width, height });
}
drawStart = null;
drawRect = null;
}
const containerStyle = $derived(
`position: absolute; top: 0; left: 0; width: 100%; height: 100%;${canAnnotate ? ' cursor: crosshair;' : ''}`
);
</script>
<div
style={containerStyle}
role="presentation"
onpointerdown={handlePointerDown}
onpointermove={handlePointerMove}
onpointerup={handlePointerUp}
>
{#each annotations as annotation (annotation.id)}
<div
data-testid="annotation-{annotation.id}"
data-annotation
style="
position: absolute;
left: {annotation.x * 100}%;
top: {annotation.y * 100}%;
width: {annotation.width * 100}%;
height: {annotation.height * 100}%;
background-color: {annotation.color};
opacity: 0.3;
pointer-events: {canAnnotate ? 'auto' : 'none'};
"
>
{#if canAnnotate}
<button
aria-label="Annotation löschen"
onclick={(e) => {
e.stopPropagation();
onDelete(annotation.id);
}}
style="
position: absolute;
top: -8px;
right: -8px;
width: 16px;
height: 16px;
background-color: #ef4444;
color: white;
border: none;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
line-height: 1;
padding: 0;
pointer-events: auto;
">×</button
>
{/if}
</div>
{/each}
{#if drawRect && drawRect.width > 0}
<div
style="
position: absolute;
left: {drawRect.x * 100}%;
top: {drawRect.y * 100}%;
width: {drawRect.width * 100}%;
height: {drawRect.height * 100}%;
border: 2px dashed {color};
opacity: 0.3;
pointer-events: none;
"
></div>
{/if}
</div>

View File

@@ -0,0 +1,74 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import AnnotationLayer from './AnnotationLayer.svelte';
afterEach(cleanup);
type Annotation = {
id: string;
documentId: string;
pageNumber: number;
x: number;
y: number;
width: number;
height: number;
color: string;
createdAt: string;
};
function makeAnnotation(id = 'ann-1'): Annotation {
return {
id,
documentId: 'doc-1',
pageNumber: 1,
x: 0.1,
y: 0.1,
width: 0.3,
height: 0.2,
color: '#ff0000',
createdAt: new Date().toISOString()
};
}
describe('AnnotationLayer', () => {
it('renders a colored element for each annotation', async () => {
render(AnnotationLayer, {
annotations: [makeAnnotation('ann-1'), makeAnnotation('ann-2')],
canAnnotate: false,
color: '#ff0000',
onDraw: () => {},
onDelete: () => {}
});
await expect.element(page.getByTestId('annotation-ann-1')).toBeInTheDocument();
await expect.element(page.getByTestId('annotation-ann-2')).toBeInTheDocument();
});
it('shows a delete button for each annotation when canAnnotate is true', async () => {
render(AnnotationLayer, {
annotations: [makeAnnotation('ann-1')],
canAnnotate: true,
color: '#ff0000',
onDraw: () => {},
onDelete: () => {}
});
await expect
.element(page.getByRole('button', { name: /annotation löschen/i }))
.toBeInTheDocument();
});
it('does not show delete buttons when canAnnotate is false', async () => {
render(AnnotationLayer, {
annotations: [makeAnnotation('ann-1')],
canAnnotate: false,
color: '#ff0000',
onDraw: () => {},
onDelete: () => {}
});
expect(page.getByRole('button', { name: /annotation löschen/i }).query()).toBeNull();
});
});

View File

@@ -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}