From 9fe5b32a69276871569558984c76490671a809ff Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 14 Apr 2026 11:40:39 +0200 Subject: [PATCH] feat(annotations): add N/S/E/W edge midpoint handles to resize overlay Extends the 4-corner L-bracket handles with 4 tick-mark edge handles (short lines along each edge), enabling single-axis resize from any edge. Updates applyHandleDrag to route each handle to the correct axis. Co-Authored-By: Claude Sonnet 4.6 --- .../components/AnnotationEditOverlay.svelte | 32 +++++++++++++------ .../AnnotationEditOverlay.svelte.test.ts | 12 ++++--- .../components/AnnotationLayer.svelte.test.ts | 2 +- .../components/AnnotationShape.svelte.test.ts | 2 +- 4 files changed, 33 insertions(+), 15 deletions(-) diff --git a/frontend/src/lib/components/AnnotationEditOverlay.svelte b/frontend/src/lib/components/AnnotationEditOverlay.svelte index 58cff56e..9793287f 100644 --- a/frontend/src/lib/components/AnnotationEditOverlay.svelte +++ b/frontend/src/lib/components/AnnotationEditOverlay.svelte @@ -42,7 +42,7 @@ $effect(() => { return () => ro.disconnect(); }); -type HandleId = 'nw' | 'ne' | 'sw' | 'se'; +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; @@ -60,7 +60,8 @@ type DragState = { let dragState = $state(null); -// Corner handles only — 4 L-bracket shapes. Each `path` is relative to the corner centre (0,0). +// 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 }> >([ @@ -73,7 +74,17 @@ const handles = $derived< 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 } { @@ -92,21 +103,24 @@ function applyHandleDrag(handleId: HandleId, nx: number, ny: number, ds: DragSta w = ds.preDragWidth, h = ds.preDragHeight; - // Horizontal axis - if (handleId === 'nw' || handleId === 'sw') { + 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 { + } else if (movesRightEdge) { w = Math.max(MIN, Math.min(1 - x, w + nx)); } - // Vertical axis - if (handleId === 'nw' || handleId === 'ne') { + if (movesTopEdge) { const newY = Math.max(0, Math.min(y + h - MIN, y + ny)); h = h - (newY - y); y = newY; - } else { + } else if (movesBottomEdge) { h = Math.max(MIN, Math.min(1 - y, h + ny)); } diff --git a/frontend/src/lib/components/AnnotationEditOverlay.svelte.test.ts b/frontend/src/lib/components/AnnotationEditOverlay.svelte.test.ts index e6557342..e6af36e8 100644 --- a/frontend/src/lib/components/AnnotationEditOverlay.svelte.test.ts +++ b/frontend/src/lib/components/AnnotationEditOverlay.svelte.test.ts @@ -16,27 +16,31 @@ const annotation: Annotation = { }; describe('AnnotationEditOverlay', () => { - it('renders 4 corner handle elements', async () => { + it('renders 8 handle elements', async () => { render(AnnotationEditOverlay, { annotation }); const handles = document.querySelectorAll('[data-handle]'); - expect(handles).toHaveLength(4); + expect(handles).toHaveLength(8); }); - it('renders handles for all four corners', async () => { + it('renders handles for all four corners and four edge midpoints', 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(); + expect(document.querySelector('[data-handle="n"]')).not.toBeNull(); + expect(document.querySelector('[data-handle="s"]')).not.toBeNull(); + expect(document.querySelector('[data-handle="e"]')).not.toBeNull(); + expect(document.querySelector('[data-handle="w"]')).not.toBeNull(); }); it('each handle has a 44x44 hit area', async () => { render(AnnotationEditOverlay, { annotation }); const hitAreas = document.querySelectorAll('[data-handle-hit]'); - expect(hitAreas).toHaveLength(4); + expect(hitAreas).toHaveLength(8); 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 5a242288..5c78f3a2 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(4); + expect(handles).toHaveLength(8); }); 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 7c7058b8..f5ad754e 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(4); + expect(handles).toHaveLength(8); }); }); });