Some checks failed
CI / E2E Tests (pull_request) Has been cancelled
CI / Unit & Component Tests (push) Successful in 2m30s
CI / Backend Unit Tests (push) Successful in 2m15s
CI / E2E Tests (push) Successful in 22m47s
CI / Unit & Component Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
- Add aria-label="Kommentare anzeigen" to annotation container div so
getByRole('button', { name: /annotation löschen/i }) no longer
matches the container (its name was previously inherited from the
child delete button, causing the test to click the wrong element)
- Wrap the server-side comments fetch in a .catch and try/catch so a
network error or non-JSON response never crashes the document load
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
219 lines
5.7 KiB
Svelte
219 lines
5.7 KiB
Svelte
<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,
|
||
commentCounts,
|
||
onAnnotationClick
|
||
}: {
|
||
annotations: Annotation[];
|
||
canAnnotate: boolean;
|
||
color: string;
|
||
onDraw: (rect: { x: number; y: number; width: number; height: number }) => void;
|
||
onDelete: (id: string) => void;
|
||
commentCounts?: Record<string, number>;
|
||
onAnnotationClick?: (id: string) => void;
|
||
} = $props();
|
||
|
||
let drawStart = $state<{ x: number; y: number } | null>(null);
|
||
let drawRect = $state<DrawRect | null>(null);
|
||
|
||
function hexToRgba(hex: string, alpha: number): string {
|
||
const r = parseInt(hex.slice(1, 3), 16);
|
||
const g = parseInt(hex.slice(3, 5), 16);
|
||
const b = parseInt(hex.slice(5, 7), 16);
|
||
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||
}
|
||
|
||
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;
|
||
|
||
if ((event.target as HTMLElement).closest('[data-annotation]')) 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;
|
||
}
|
||
|
||
let hoveredId = $state<string | null>(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
|
||
role="button"
|
||
tabindex="0"
|
||
aria-label="Kommentare anzeigen"
|
||
onclick={() => onAnnotationClick?.(annotation.id)}
|
||
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') onAnnotationClick?.(annotation.id); }}
|
||
onmouseenter={() => (hoveredId = annotation.id)}
|
||
onmouseleave={() => (hoveredId = null)}
|
||
style="
|
||
position: absolute;
|
||
left: {annotation.x * 100}%;
|
||
top: {annotation.y * 100}%;
|
||
width: {annotation.width * 100}%;
|
||
height: {annotation.height * 100}%;
|
||
background-color: {hexToRgba(annotation.color, hoveredId === annotation.id ? 0.5 : 0.3)};
|
||
box-shadow: {hoveredId === annotation.id ? `inset 0 0 0 2px ${hexToRgba(annotation.color, 0.8)}` : 'none'};
|
||
pointer-events: auto;
|
||
transition: background-color 0.15s ease, box-shadow 0.15s ease;
|
||
{onAnnotationClick && !canAnnotate ? 'cursor: pointer;' : ''}
|
||
"
|
||
>
|
||
{#if canAnnotate}
|
||
<button
|
||
aria-label="Annotation löschen"
|
||
onclick={(e) => {
|
||
e.stopPropagation();
|
||
const count = commentCounts?.[annotation.id] ?? 0;
|
||
if (count > 0) {
|
||
const msg =
|
||
count === 1
|
||
? 'Diese Annotation hat 1 Kommentar. Beim Löschen wird er ebenfalls entfernt. Fortfahren?'
|
||
: `Diese Annotation hat ${count} Kommentare. Beim Löschen werden sie ebenfalls entfernt. Fortfahren?`;
|
||
if (!window.confirm(msg)) return;
|
||
}
|
||
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}
|
||
{#if (commentCounts?.[annotation.id] ?? 0) > 0}
|
||
<div
|
||
style="
|
||
position: absolute;
|
||
bottom: -10px;
|
||
right: -10px;
|
||
background-color: #002850;
|
||
color: white;
|
||
font-size: 11px;
|
||
font-family: sans-serif;
|
||
font-weight: 600;
|
||
padding: 2px 6px;
|
||
border-radius: 999px;
|
||
min-width: 20px;
|
||
text-align: center;
|
||
white-space: nowrap;
|
||
pointer-events: none;
|
||
line-height: 18px;
|
||
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
|
||
"
|
||
>
|
||
{commentCounts?.[annotation.id]}
|
||
</div>
|
||
{/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>
|