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>
198 lines
5.3 KiB
Svelte
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>
|