- 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>
350 lines
9.4 KiB
Svelte
350 lines
9.4 KiB
Svelte
<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>
|