Files
familienarchiv/frontend/src/lib/components/AnnotationLayer.svelte
Marcel 81b14e5026 feat(ui): add bidirectional scroll-sync with flash animations
Paragraph click flashes the PDF annotation outline (1.5s fade).
Annotation click highlights the paragraph with a background flash.
Both directions scroll the target into view.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 11:25:23 +02:00

198 lines
5.3 KiB
Svelte

<script lang="ts">
import type { Annotation } from '$lib/types';
type DrawRect = {
x: number;
y: number;
width: number;
height: number;
};
let {
annotations = [],
canDraw,
color,
blockNumbers = {},
activeAnnotationId = null,
dimmed = false,
flashAnnotationId = null,
onDraw,
onAnnotationClick
}: {
annotations: Annotation[];
canDraw: boolean;
color: string;
blockNumbers?: Record<string, number>;
activeAnnotationId?: string | null;
dimmed?: boolean;
flashAnnotationId?: string | null;
onDraw: (rect: DrawRect) => void;
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 (!canDraw) 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 (!canDraw || !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 (!canDraw || !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%;${canDraw ? ' cursor: crosshair; touch-action: none;' : ''}`
);
</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
class:annotation-flash={flashAnnotationId === annotation.id}
role="button"
tabindex="0"
aria-label="Block anzeigen"
onclick={() => onAnnotationClick?.(annotation.id)}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') onAnnotationClick?.(annotation.id);
}}
onpointerenter={() => (hoveredId = annotation.id)}
onpointerleave={() => (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, dimmed ? 0.3 : (hoveredId === annotation.id || annotation.id === activeAnnotationId ? 0.5 : 0.3))};
box-shadow: {dimmed ? 'none' : (annotation.id === activeAnnotationId ? `inset 0 0 0 2px ${hexToRgba(annotation.color, 0.8)}` : hoveredId === annotation.id ? `inset 0 0 0 2px ${hexToRgba(annotation.color, 0.8)}` : 'none')};
opacity: {dimmed ? 1 : (activeAnnotationId && annotation.id !== activeAnnotationId ? 0.3 : 1)};
pointer-events: auto;
cursor: pointer;
transition: background-color 0.15s ease, box-shadow 0.15s ease, opacity 0.3s ease;
"
>
{#if !dimmed && blockNumbers[annotation.id]}
<div
style="
position: absolute;
top: -8px;
left: -8px;
width: 20px;
height: 20px;
border-radius: 50%;
background-color: {annotation.color};
color: white;
font-size: 11px;
font-family: sans-serif;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
"
>
{blockNumbers[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>
<style>
@keyframes annotation-flash-anim {
0% {
outline: 3px solid rgba(0, 199, 177, 0.8);
outline-offset: 0px;
}
100% {
outline: 3px solid rgba(0, 199, 177, 0);
outline-offset: 2px;
}
}
.annotation-flash {
animation: annotation-flash-anim 1.5s ease-out;
}
</style>