feat(frontend): extract AnnotationShape component with polygon support

- AnnotationShape.svelte: renders a single annotation as either a
  rectangle or a polygon-clipped div (via CSS clip-path: polygon())
- AnnotationLayer.svelte: refactored to delegate rendering to
  AnnotationShape, keeping draw logic and hover state management
- Annotation type: added optional polygon field ([number, number][] | null)
- Polygon coordinates are converted from page-normalized to
  bounding-box-relative percentages for clip-path

All 687 existing frontend tests pass.

Refs #227

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-12 15:30:27 +02:00
parent 6737bd6db5
commit cf8dc3559f
3 changed files with 147 additions and 80 deletions

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import type { Annotation } from '$lib/types';
import AnnotationShape from './AnnotationShape.svelte';
type DrawRect = {
x: number;
@@ -33,13 +34,6 @@ let {
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 {
@@ -109,58 +103,18 @@ const containerStyle = $derived(
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"
<AnnotationShape
annotation={annotation}
isHovered={hoveredId === annotation.id}
isActive={annotation.id === activeAnnotationId}
faded={!dimmed && !!activeAnnotationId && annotation.id !== activeAnnotationId}
dimmed={dimmed}
blockNumber={blockNumbers[annotation.id]}
isFlashing={flashAnnotationId === annotation.id}
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}
@@ -178,27 +132,3 @@ const containerStyle = $derived(
></div>
{/if}
</div>
<style>
@keyframes annotation-flash-anim {
0% {
outline: 3px solid color-mix(in srgb, var(--color-turquoise) 80%, transparent);
outline-offset: 0px;
}
100% {
outline: 3px solid color-mix(in srgb, var(--color-turquoise) 0%, transparent);
outline-offset: 2px;
}
}
.annotation-flash {
animation: annotation-flash-anim 1.5s ease-out;
}
@media (prefers-reduced-motion: reduce) {
.annotation-flash {
animation: none;
outline: 3px solid color-mix(in srgb, var(--color-turquoise) 80%, transparent);
}
}
</style>

View File

@@ -0,0 +1,136 @@
<script lang="ts">
import type { Annotation } from '$lib/types';
let {
annotation,
isHovered,
isActive,
faded = false,
dimmed = false,
blockNumber = undefined,
isFlashing = false,
onclick,
onpointerenter,
onpointerleave
}: {
annotation: Annotation;
isHovered: boolean;
isActive: boolean;
faded?: boolean;
dimmed?: boolean;
blockNumber?: number | undefined;
isFlashing?: boolean;
onclick: () => void;
onpointerenter: () => void;
onpointerleave: () => void;
} = $props();
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})`;
}
let clipPath = $derived.by(() => {
if (!annotation.polygon || annotation.polygon.length !== 4) return 'none';
const points = annotation.polygon
.map(([px, py]) => {
const cx = ((px - annotation.x) / annotation.width) * 100;
const cy = ((py - annotation.y) / annotation.height) * 100;
return `${cx}% ${cy}%`;
})
.join(', ');
return `polygon(${points})`;
});
let bgAlpha = $derived(dimmed ? 0.3 : isHovered || isActive ? 0.5 : 0.3);
let boxShadow = $derived.by(() => {
if (dimmed) return 'none';
if (isActive || isHovered) return `inset 0 0 0 2px ${hexToRgba(annotation.color, 0.8)}`;
return 'none';
});
let opacity = $derived(dimmed ? 1 : faded ? 0.3 : 1);
let shapeStyle = $derived(
`position: absolute;` +
` left: ${annotation.x * 100}%;` +
` top: ${annotation.y * 100}%;` +
` width: ${annotation.width * 100}%;` +
` height: ${annotation.height * 100}%;` +
` background-color: ${hexToRgba(annotation.color, bgAlpha)};` +
` box-shadow: ${boxShadow};` +
` opacity: ${opacity};` +
` pointer-events: auto;` +
` cursor: pointer;` +
` transition: background-color 0.15s ease, box-shadow 0.15s ease, opacity 0.3s ease;` +
(clipPath !== 'none' ? ` clip-path: ${clipPath};` : '')
);
</script>
<div
data-testid="annotation-{annotation.id}"
data-annotation
class:annotation-flash={isFlashing}
role="button"
tabindex="0"
aria-label="Block anzeigen"
onclick={onclick}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') onclick();
}}
onpointerenter={onpointerenter}
onpointerleave={onpointerleave}
style={shapeStyle}
>
{#if !dimmed && blockNumber}
<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);
"
>
{blockNumber}
</div>
{/if}
</div>
<style>
@keyframes annotation-flash-anim {
0% {
outline: 3px solid color-mix(in srgb, var(--color-turquoise) 80%, transparent);
outline-offset: 0px;
}
100% {
outline: 3px solid color-mix(in srgb, var(--color-turquoise) 0%, transparent);
outline-offset: 2px;
}
}
.annotation-flash {
animation: annotation-flash-anim 1.5s ease-out;
}
@media (prefers-reduced-motion: reduce) {
.annotation-flash {
animation: none;
outline: 3px solid color-mix(in srgb, var(--color-turquoise) 80%, transparent);
}
}
</style>

View File

@@ -49,4 +49,5 @@ export type Annotation = {
color: string;
createdAt: string;
fileHash?: string | null;
polygon?: [number, number][] | null;
};