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 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-14 11:40:39 +02:00
parent fcc0efbf02
commit 9fe5b32a69
4 changed files with 33 additions and 15 deletions

View File

@@ -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<DragState | null>(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));
}

View File

@@ -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');

View File

@@ -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 () => {

View File

@@ -44,7 +44,7 @@ describe('AnnotationShape', () => {
});
const handles = document.querySelectorAll('[data-handle]');
expect(handles).toHaveLength(4);
expect(handles).toHaveLength(8);
});
});
});