Files
familienarchiv/frontend/src/lib/components/AnnotationEditOverlay.svelte
Marcel 2350b4f845
Some checks failed
CI / Unit & Component Tests (push) Failing after 1s
CI / Backend Unit Tests (push) Failing after 1s
CI / Unit & Component Tests (pull_request) Failing after 2s
CI / Backend Unit Tests (pull_request) Failing after 1s
fix(annotations): make resize overlay keyboard-interactive
- Add tabindex="0" so the SVG can receive DOM focus
- Auto-focus the SVG on mount so arrow keys work immediately after
  clicking an annotation to select it
- Show preview rect during keyboard nudging (not just pointer drag) by
  checking hasLiveChanges instead of only checking dragState
- Suppress default browser focus outline (outline: none) on the SVG

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 11:47:41 +02:00

325 lines
8.8 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { getContext } from 'svelte';
import type { Annotation } from '$lib/types';
import { m } from '$lib/paraglide/messages.js';
type UpdateAnnotationFn = (
id: string,
coords: { x: number; y: number; width: number; height: number }
) => Promise<void>;
const updateAnnotation: UpdateAnnotationFn =
getContext('annotationUpdate') ?? (() => Promise.resolve());
let { annotation }: { annotation: Annotation } = $props();
let liveX = $state<number>(0);
let liveY = $state<number>(0);
let liveWidth = $state<number>(0);
let liveHeight = $state<number>(0);
$effect(() => {
liveX = annotation.x;
liveY = annotation.y;
liveWidth = annotation.width;
liveHeight = annotation.height;
});
let svgEl = $state<SVGSVGElement | null>(null);
// Actual rendered pixel dimensions of the SVG — updated by ResizeObserver.
// Used as the viewBox so handles are always physically 16×16px regardless of annotation aspect ratio.
let svgWidth = $state(1);
let svgHeight = $state(1);
$effect(() => {
if (!svgEl) return;
const ro = new ResizeObserver(([entry]) => {
svgWidth = entry.contentRect.width || 1;
svgHeight = entry.contentRect.height || 1;
});
ro.observe(svgEl);
return () => ro.disconnect();
});
// Auto-focus the SVG when the overlay mounts so arrow keys work immediately.
$effect(() => {
svgEl?.focus({ preventScroll: true });
});
type HandleId = 'nw' | 'ne' | 'sw' | 'se' | 'n' | 's' | 'e' | 'w';
// L-bracket arm length in pixels. Each corner shows two short lines meeting at 90°.
const ARM = 10;
type DragState = {
type: 'handle' | 'move';
handleId?: HandleId;
startPointerX: number;
startPointerY: number;
preDragX: number;
preDragY: number;
preDragWidth: number;
preDragHeight: number;
};
let dragState = $state<DragState | null>(null);
// 8 handles: 4 L-bracket corners + 4 tick-mark edge midpoints.
// Each `path` is relative to the handle centre (0,0).
const handles = $derived<
Array<{ id: HandleId; cx: number; cy: number; cursor: string; path: string }>
>([
{ id: 'nw', cx: 0, cy: 0, cursor: 'nwse-resize', path: `M ${ARM},0 L 0,0 L 0,${ARM}` },
{ id: 'ne', cx: svgWidth, cy: 0, cursor: 'nesw-resize', path: `M ${-ARM},0 L 0,0 L 0,${ARM}` },
{ id: 'sw', cx: 0, cy: svgHeight, cursor: 'nesw-resize', path: `M ${ARM},0 L 0,0 L 0,${-ARM}` },
{
id: 'se',
cx: svgWidth,
cy: svgHeight,
cursor: 'nwse-resize',
path: `M ${-ARM},0 L 0,0 L 0,${-ARM}`
},
{ id: 'n', cx: svgWidth / 2, cy: 0, cursor: 'ns-resize', path: `M ${-ARM},0 L ${ARM},0` },
{
id: 's',
cx: svgWidth / 2,
cy: svgHeight,
cursor: 'ns-resize',
path: `M ${-ARM},0 L ${ARM},0`
},
{ id: 'e', cx: svgWidth, cy: svgHeight / 2, cursor: 'ew-resize', path: `M 0,${-ARM} L 0,${ARM}` },
{ id: 'w', cx: 0, cy: svgHeight / 2, cursor: 'ew-resize', path: `M 0,${-ARM} L 0,${ARM}` }
]);
function pixelToNorm(dx: number, dy: number): { nx: number; ny: number } {
if (!svgEl) return { nx: 0, ny: 0 };
const rect = svgEl.getBoundingClientRect();
return {
nx: (dx / rect.width) * annotation.width,
ny: (dy / rect.height) * annotation.height
};
}
function applyHandleDrag(handleId: HandleId, nx: number, ny: number, ds: DragState): void {
const MIN = 0.01;
let x = ds.preDragX,
y = ds.preDragY,
w = ds.preDragWidth,
h = ds.preDragHeight;
const movesLeftEdge = handleId === 'nw' || handleId === 'sw' || handleId === 'w';
const movesRightEdge = handleId === 'ne' || handleId === 'se' || handleId === 'e';
const movesTopEdge = handleId === 'nw' || handleId === 'ne' || handleId === 'n';
const movesBottomEdge = handleId === 'sw' || handleId === 'se' || handleId === 's';
if (movesLeftEdge) {
const newX = Math.max(0, Math.min(x + w - MIN, x + nx));
w = w - (newX - x);
x = newX;
} else if (movesRightEdge) {
w = Math.max(MIN, Math.min(1 - x, w + nx));
}
if (movesTopEdge) {
const newY = Math.max(0, Math.min(y + h - MIN, y + ny));
h = h - (newY - y);
y = newY;
} else if (movesBottomEdge) {
h = Math.max(MIN, Math.min(1 - y, h + ny));
}
liveX = x;
liveY = y;
liveWidth = w;
liveHeight = h;
}
function handlePointerDown(
event: PointerEvent,
type: 'handle' | 'move',
handleId?: HandleId
): void {
if (!event.isPrimary) return;
event.stopPropagation();
(event.currentTarget as Element).setPointerCapture(event.pointerId);
dragState = {
type,
handleId,
startPointerX: event.clientX,
startPointerY: event.clientY,
preDragX: liveX,
preDragY: liveY,
preDragWidth: liveWidth,
preDragHeight: liveHeight
};
}
function handlePointerMove(event: PointerEvent): void {
if (!dragState || !event.isPrimary) return;
const dx = event.clientX - dragState.startPointerX;
const dy = event.clientY - dragState.startPointerY;
const { nx, ny } = pixelToNorm(dx, dy);
if (dragState.type === 'move') {
liveX = Math.max(0, Math.min(1 - dragState.preDragWidth, dragState.preDragX + nx));
liveY = Math.max(0, Math.min(1 - dragState.preDragHeight, dragState.preDragY + ny));
} else if (dragState.handleId) {
applyHandleDrag(dragState.handleId, nx, ny, dragState);
}
}
async function handlePointerUp(event: PointerEvent): Promise<void> {
if (!dragState || !event.isPrimary) return;
const ds = dragState;
dragState = null;
if (
liveX === ds.preDragX &&
liveY === ds.preDragY &&
liveWidth === ds.preDragWidth &&
liveHeight === ds.preDragHeight
) {
return;
}
try {
await updateAnnotation(annotation.id, {
x: liveX,
y: liveY,
width: liveWidth,
height: liveHeight
});
} catch {
liveX = ds.preDragX;
liveY = ds.preDragY;
liveWidth = ds.preDragWidth;
liveHeight = ds.preDragHeight;
}
}
let keyDebounceTimer: ReturnType<typeof setTimeout> | null = null;
function handleKeyDown(event: KeyboardEvent): void {
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(event.key)) return;
event.preventDefault();
const STEP = event.shiftKey ? 0.05 : 0.005;
if (event.key === 'ArrowLeft') liveX = Math.max(0, liveX - STEP);
if (event.key === 'ArrowRight') liveX = Math.min(1 - liveWidth, liveX + STEP);
if (event.key === 'ArrowUp') liveY = Math.max(0, liveY - STEP);
if (event.key === 'ArrowDown') liveY = Math.min(1 - liveHeight, liveY + STEP);
if (keyDebounceTimer) clearTimeout(keyDebounceTimer);
keyDebounceTimer = setTimeout(async () => {
try {
await updateAnnotation(annotation.id, {
x: liveX,
y: liveY,
width: liveWidth,
height: liveHeight
});
} catch {
liveX = annotation.x;
liveY = annotation.y;
}
}, 300);
}
// Preview rect in pixel space (maps live normalized coords back to SVG pixel coordinates).
// Shown during pointer drag and during keyboard nudging (whenever live coords differ from stored).
let previewX = $derived(((liveX - annotation.x) / annotation.width) * svgWidth);
let previewY = $derived(((liveY - annotation.y) / annotation.height) * svgHeight);
let previewW = $derived((liveWidth / annotation.width) * svgWidth);
let previewH = $derived((liveHeight / annotation.height) * svgHeight);
let hasLiveChanges = $derived(
liveX !== annotation.x ||
liveY !== annotation.y ||
liveWidth !== annotation.width ||
liveHeight !== annotation.height
);
</script>
<div aria-live="polite" class="sr-only">
{m.annotation_edit_mode_active()}
</div>
<svg
bind:this={svgEl}
viewBox="0 0 {svgWidth} {svgHeight}"
role="application"
tabindex="0"
aria-label="Annotation resize handles"
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; touch-action: none; overflow: visible; outline: none;"
onpointermove={handlePointerMove}
onpointerup={handlePointerUp}
onkeydown={handleKeyDown}
>
<rect
data-move-area
role="none"
x="0"
y="0"
width={svgWidth}
height={svgHeight}
fill="transparent"
style="cursor: move; pointer-events: all;"
onpointerdown={(e) => handlePointerDown(e, 'move')}
/>
{#if dragState || hasLiveChanges}
<rect
x={previewX}
y={previewY}
width={previewW}
height={previewH}
fill="none"
stroke="#002850"
stroke-width="1.5"
stroke-dasharray="4 3"
pointer-events="none"
/>
{/if}
<!-- Dashed selection border — signals the annotation is in edit mode -->
<rect
x="0"
y="0"
width={svgWidth}
height={svgHeight}
fill="none"
stroke="#002850"
stroke-width="1"
stroke-dasharray="4 3"
opacity="0.6"
pointer-events="none"
/>
{#each handles as handle (handle.id)}
<g
data-handle={handle.id}
role="none"
transform="translate({handle.cx}, {handle.cy})"
style="cursor: {handle.cursor}; pointer-events: all;"
onpointerdown={(e) => handlePointerDown(e, 'handle', handle.id)}
>
<rect data-handle-hit x="-22" y="-22" width="44" height="44" fill="transparent" />
<path d={handle.path} fill="none" stroke="#002850" stroke-width="2" stroke-linecap="square" />
</g>
{/each}
</svg>
<style>
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
</style>