refactor(annotations): replace 8-square handles with 4 corner L-brackets
- 4 corner-only handles (nw/ne/sw/se), no edge midpoints - Each handle renders as two short perpendicular lines meeting at the corner (10px arms, navy, square linecap) — no fill, no box - Thin dashed selection border added to SVG overlay to signal edit mode - Simplify applyHandleDrag to remove dead n/s/e/w branches Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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<DragState | null>(null);
|
||||
|
||||
// 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: 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}
|
||||
|
||||
<!-- Dashed selection border — signals the annotation is in edit mode -->
|
||||
<rect
|
||||
x="0"
|
||||
y="0"
|
||||
width={svgWidth}
|
||||
height={svgHeight}
|
||||
fill="none"
|
||||
stroke="#002850"
|
||||
stroke-width="1"
|
||||
stroke-dasharray="4 3"
|
||||
opacity="0.6"
|
||||
pointer-events="none"
|
||||
/>
|
||||
|
||||
{#each handles as handle (handle.id)}
|
||||
<g
|
||||
data-handle={handle.id}
|
||||
@@ -255,7 +278,7 @@ let previewH = $derived((liveHeight / annotation.height) * svgHeight);
|
||||
onpointerdown={(e) => handlePointerDown(e, 'handle', handle.id)}
|
||||
>
|
||||
<rect data-handle-hit x="-22" y="-22" width="44" height="44" fill="transparent" />
|
||||
<rect x="-8" y="-8" width="16" height="16" fill="white" stroke="#002850" stroke-width="2" />
|
||||
<path d={handle.path} fill="none" stroke="#002850" stroke-width="2" stroke-linecap="square" />
|
||||
</g>
|
||||
{/each}
|
||||
</svg>
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -44,7 +44,7 @@ describe('AnnotationShape', () => {
|
||||
});
|
||||
|
||||
const handles = document.querySelectorAll('[data-handle]');
|
||||
expect(handles).toHaveLength(8);
|
||||
expect(handles).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user