fix(annotations): use pixel-space viewBox so handles stay square on non-square annotations

ResizeObserver binds actual SVG pixel dimensions; viewBox matches them so
16px handle squares and 44px hit areas are physically correct regardless of
the annotation's aspect ratio.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-14 11:03:15 +02:00
parent c610a3cc37
commit e7f88a4ea1

View File

@@ -27,6 +27,21 @@ $effect(() => {
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();
});
type HandleId = 'nw' | 'n' | 'ne' | 'w' | 'e' | 'sw' | 's' | 'se';
type DragState = {
@@ -42,16 +57,17 @@ type DragState = {
let dragState = $state<DragState | null>(null);
const handles: Array<{ id: HandleId; cx: number; cy: number; cursor: string }> = [
// Handle positions in pixel space — always physically square regardless of annotation shape.
const handles = $derived<Array<{ id: HandleId; cx: number; cy: number; cursor: string }>>([
{ id: 'nw', cx: 0, cy: 0, cursor: 'nwse-resize' },
{ id: 'n', cx: 50, cy: 0, cursor: 'ns-resize' },
{ id: 'ne', cx: 100, cy: 0, cursor: 'nesw-resize' },
{ id: 'w', cx: 0, cy: 50, cursor: 'ew-resize' },
{ id: 'e', cx: 100, cy: 50, cursor: 'ew-resize' },
{ id: 'sw', cx: 0, cy: 100, cursor: 'nesw-resize' },
{ id: 's', cx: 50, cy: 100, cursor: 'ns-resize' },
{ id: 'se', cx: 100, cy: 100, cursor: 'nwse-resize' }
];
{ id: 'n', cx: svgWidth / 2, cy: 0, cursor: 'ns-resize' },
{ id: 'ne', cx: svgWidth, cy: 0, cursor: 'nesw-resize' },
{ id: 'w', cx: 0, cy: svgHeight / 2, cursor: 'ew-resize' },
{ id: 'e', cx: svgWidth, cy: svgHeight / 2, cursor: 'ew-resize' },
{ id: 'sw', cx: 0, cy: svgHeight, cursor: 'nesw-resize' },
{ id: 's', cx: svgWidth / 2, cy: svgHeight, cursor: 'ns-resize' },
{ id: 'se', cx: svgWidth, cy: svgHeight, cursor: 'nwse-resize' }
]);
function pixelToNorm(dx: number, dy: number): { nx: number; ny: number } {
if (!svgEl) return { nx: 0, ny: 0 };
@@ -182,10 +198,11 @@ function handleKeyDown(event: KeyboardEvent): void {
}, 300);
}
let previewX = $derived(((liveX - annotation.x) / annotation.width) * 100);
let previewY = $derived(((liveY - annotation.y) / annotation.height) * 100);
let previewW = $derived((liveWidth / annotation.width) * 100);
let previewH = $derived((liveHeight / annotation.height) * 100);
// Preview rect in pixel space (maps live normalized coords back to SVG pixel coordinates)
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);
</script>
<div aria-live="polite" class="sr-only">
@@ -195,8 +212,7 @@ let previewH = $derived((liveHeight / annotation.height) * 100);
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<svg
bind:this={svgEl}
viewBox="0 0 100 100"
preserveAspectRatio="none"
viewBox="0 0 {svgWidth} {svgHeight}"
role="application"
aria-label="Annotation resize handles"
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; touch-action: none; overflow: visible;"
@@ -209,8 +225,8 @@ let previewH = $derived((liveHeight / annotation.height) * 100);
role="none"
x="0"
y="0"
width="100"
height="100"
width={svgWidth}
height={svgHeight}
fill="transparent"
style="cursor: move; pointer-events: all;"
onpointerdown={(e) => handlePointerDown(e, 'move')}