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