Files
familienarchiv/frontend/src/lib/document/annotation/AnnotationEditOverlay.svelte
Marcel 567612761d refactor: move lib-root files to lib/shared/ and finalize domain structure
- Move api.server.ts, errors.ts, types.ts, utils.ts, relativeTime.ts to lib/shared/
- Move person relationship components to lib/person/relationship/
- Move Stammbaum components to lib/person/genealogy/
- Move HelpPopover to lib/shared/primitives/
- Update all import paths across routes, specs, and lib files
- Update vi.mock() paths in server-project test files
- Remove now-empty legacy directories (components/, hooks/, server/, etc.)
- Update vite.config.ts coverage include paths for new structure
- Update frontend/CLAUDE.md to reflect domain-based lib/ layout

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 14:53:31 +02:00

350 lines
9.4 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/shared/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 (err) {
console.error('annotation drag update failed', err);
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 (err) {
console.error('annotation keyboard update failed', err);
liveX = annotation.x;
liveY = annotation.y;
liveWidth = annotation.width;
liveHeight = annotation.height;
}
}, 300);
}
const directionLabels: Record<HandleId, string> = {
nw: 'NW',
ne: 'NE',
sw: 'SW',
se: 'SE',
n: 'N',
s: 'S',
e: 'E',
w: 'W'
};
// 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={m.annotation_resize_area()}
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; touch-action: none; overflow: visible;"
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="button"
tabindex="0"
aria-label={m.annotation_resize_handle({ direction: directionLabels[handle.id] })}
transform="translate({handle.cx}, {handle.cy})"
style="cursor: {handle.cursor}; pointer-events: all;"
onpointerdown={(e) => handlePointerDown(e, 'handle', handle.id)}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') e.preventDefault();
}}
>
<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>
svg[role='application']:focus-visible {
outline: 2px solid #002850;
outline-offset: 2px;
}
.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>