diff --git a/frontend/src/lib/components/AnnotationEditOverlay.svelte b/frontend/src/lib/components/AnnotationEditOverlay.svelte index 93f1403c..58cff56e 100644 --- a/frontend/src/lib/components/AnnotationEditOverlay.svelte +++ b/frontend/src/lib/components/AnnotationEditOverlay.svelte @@ -42,7 +42,10 @@ $effect(() => { return () => ro.disconnect(); }); -type HandleId = 'nw' | 'n' | 'ne' | 'w' | 'e' | 'sw' | 's' | 'se'; +type HandleId = 'nw' | 'ne' | 'sw' | 'se'; + +// L-bracket arm length in pixels. Each corner shows two short lines meeting at 90°. +const ARM = 10; type DragState = { type: 'handle' | 'move'; @@ -57,16 +60,20 @@ type DragState = { let dragState = $state(null); -// Handle positions in pixel space — always physically square regardless of annotation shape. -const handles = $derived>([ - { id: 'nw', cx: 0, cy: 0, 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' } +// Corner handles only — 4 L-bracket shapes. Each `path` is relative to the corner 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}` + } ]); function pixelToNorm(dx: number, dy: number): { nx: number; ny: number } { @@ -85,19 +92,21 @@ function applyHandleDrag(handleId: HandleId, nx: number, ny: number, ds: DragSta w = ds.preDragWidth, h = ds.preDragHeight; - if (['nw', 'w', 'sw'].includes(handleId)) { + // Horizontal axis + if (handleId === 'nw' || handleId === 'sw') { const newX = Math.max(0, Math.min(x + w - MIN, x + nx)); w = w - (newX - x); x = newX; - } else if (['ne', 'e', 'se'].includes(handleId)) { + } else { w = Math.max(MIN, Math.min(1 - x, w + nx)); } - if (['nw', 'n', 'ne'].includes(handleId)) { + // Vertical axis + if (handleId === 'nw' || handleId === 'ne') { const newY = Math.max(0, Math.min(y + h - MIN, y + ny)); h = h - (newY - y); y = newY; - } else if (['sw', 's', 'se'].includes(handleId)) { + } else { h = Math.max(MIN, Math.min(1 - y, h + ny)); } @@ -246,6 +255,20 @@ let previewH = $derived((liveHeight / annotation.height) * svgHeight); /> {/if} + + + {#each handles as handle (handle.id)} handlePointerDown(e, 'handle', handle.id)} > - + {/each} diff --git a/frontend/src/lib/components/AnnotationEditOverlay.svelte.test.ts b/frontend/src/lib/components/AnnotationEditOverlay.svelte.test.ts index 1afa47eb..e6557342 100644 --- a/frontend/src/lib/components/AnnotationEditOverlay.svelte.test.ts +++ b/frontend/src/lib/components/AnnotationEditOverlay.svelte.test.ts @@ -16,18 +16,27 @@ const annotation: Annotation = { }; describe('AnnotationEditOverlay', () => { - it('renders 8 handle elements', async () => { + it('renders 4 corner handle elements', async () => { render(AnnotationEditOverlay, { annotation }); const handles = document.querySelectorAll('[data-handle]'); - expect(handles).toHaveLength(8); + expect(handles).toHaveLength(4); + }); + + it('renders handles for all four corners', async () => { + render(AnnotationEditOverlay, { annotation }); + + expect(document.querySelector('[data-handle="nw"]')).not.toBeNull(); + expect(document.querySelector('[data-handle="ne"]')).not.toBeNull(); + expect(document.querySelector('[data-handle="sw"]')).not.toBeNull(); + expect(document.querySelector('[data-handle="se"]')).not.toBeNull(); }); it('each handle has a 44x44 hit area', async () => { render(AnnotationEditOverlay, { annotation }); const hitAreas = document.querySelectorAll('[data-handle-hit]'); - expect(hitAreas).toHaveLength(8); + expect(hitAreas).toHaveLength(4); hitAreas.forEach((el) => { expect(el.getAttribute('width')).toBe('44'); expect(el.getAttribute('height')).toBe('44'); diff --git a/frontend/src/lib/components/AnnotationLayer.svelte.test.ts b/frontend/src/lib/components/AnnotationLayer.svelte.test.ts index 5c78f3a2..5a242288 100644 --- a/frontend/src/lib/components/AnnotationLayer.svelte.test.ts +++ b/frontend/src/lib/components/AnnotationLayer.svelte.test.ts @@ -87,7 +87,7 @@ describe('AnnotationLayer', () => { }); const handles = document.querySelectorAll('[data-handle]'); - expect(handles).toHaveLength(8); + expect(handles).toHaveLength(4); }); it('passes isResizable=false when annotation has a polygon', async () => { diff --git a/frontend/src/lib/components/AnnotationShape.svelte.test.ts b/frontend/src/lib/components/AnnotationShape.svelte.test.ts index f5ad754e..7c7058b8 100644 --- a/frontend/src/lib/components/AnnotationShape.svelte.test.ts +++ b/frontend/src/lib/components/AnnotationShape.svelte.test.ts @@ -44,7 +44,7 @@ describe('AnnotationShape', () => { }); const handles = document.querySelectorAll('[data-handle]'); - expect(handles).toHaveLength(8); + expect(handles).toHaveLength(4); }); }); });