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:
@@ -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>
|
||||
|
||||
136
frontend/src/lib/components/AnnotationShape.svelte
Normal file
136
frontend/src/lib/components/AnnotationShape.svelte
Normal 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>
|
||||
@@ -49,4 +49,5 @@ export type Annotation = {
|
||||
color: string;
|
||||
createdAt: string;
|
||||
fileHash?: string | null;
|
||||
polygon?: [number, number][] | null;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user