feat(annotations): add AnnotationEditOverlay component with resize handles and drag
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
259
frontend/src/lib/components/AnnotationEditOverlay.svelte
Normal file
259
frontend/src/lib/components/AnnotationEditOverlay.svelte
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { getContext } from 'svelte';
|
||||||
|
import type { Annotation } from '$lib/types';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
|
type UpdateAnnotationFn = (
|
||||||
|
id: string,
|
||||||
|
coords: { x: number; y: number; width: number; height: number }
|
||||||
|
) => Promise<void>;
|
||||||
|
|
||||||
|
const updateAnnotation: UpdateAnnotationFn =
|
||||||
|
getContext('annotationUpdate') ?? (() => Promise.resolve());
|
||||||
|
|
||||||
|
let { annotation }: { annotation: Annotation } = $props();
|
||||||
|
|
||||||
|
let liveX = $state<number>(0);
|
||||||
|
let liveY = $state<number>(0);
|
||||||
|
let liveWidth = $state<number>(0);
|
||||||
|
let liveHeight = $state<number>(0);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
liveX = annotation.x;
|
||||||
|
liveY = annotation.y;
|
||||||
|
liveWidth = annotation.width;
|
||||||
|
liveHeight = annotation.height;
|
||||||
|
});
|
||||||
|
|
||||||
|
let svgEl = $state<SVGSVGElement | null>(null);
|
||||||
|
|
||||||
|
type HandleId = 'nw' | 'n' | 'ne' | 'w' | 'e' | 'sw' | 's' | 'se';
|
||||||
|
|
||||||
|
type DragState = {
|
||||||
|
type: 'handle' | 'move';
|
||||||
|
handleId?: HandleId;
|
||||||
|
startPointerX: number;
|
||||||
|
startPointerY: number;
|
||||||
|
preDragX: number;
|
||||||
|
preDragY: number;
|
||||||
|
preDragWidth: number;
|
||||||
|
preDragHeight: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
let dragState = $state<DragState | null>(null);
|
||||||
|
|
||||||
|
const handles: Array<{ id: HandleId; cx: number; cy: number; cursor: string }> = [
|
||||||
|
{ id: 'nw', cx: 0, cy: 0, cursor: 'nwse-resize' },
|
||||||
|
{ id: 'n', cx: 50, cy: 0, cursor: 'ns-resize' },
|
||||||
|
{ id: 'ne', cx: 100, cy: 0, cursor: 'nesw-resize' },
|
||||||
|
{ id: 'w', cx: 0, cy: 50, cursor: 'ew-resize' },
|
||||||
|
{ id: 'e', cx: 100, cy: 50, cursor: 'ew-resize' },
|
||||||
|
{ id: 'sw', cx: 0, cy: 100, cursor: 'nesw-resize' },
|
||||||
|
{ id: 's', cx: 50, cy: 100, cursor: 'ns-resize' },
|
||||||
|
{ id: 'se', cx: 100, cy: 100, cursor: 'nwse-resize' }
|
||||||
|
];
|
||||||
|
|
||||||
|
function pixelToNorm(dx: number, dy: number): { nx: number; ny: number } {
|
||||||
|
if (!svgEl) return { nx: 0, ny: 0 };
|
||||||
|
const rect = svgEl.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
nx: (dx / rect.width) * annotation.width,
|
||||||
|
ny: (dy / rect.height) * annotation.height
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyHandleDrag(handleId: HandleId, nx: number, ny: number, ds: DragState): void {
|
||||||
|
const MIN = 0.01;
|
||||||
|
let x = ds.preDragX,
|
||||||
|
y = ds.preDragY,
|
||||||
|
w = ds.preDragWidth,
|
||||||
|
h = ds.preDragHeight;
|
||||||
|
|
||||||
|
if (['nw', 'w', 'sw'].includes(handleId)) {
|
||||||
|
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)) {
|
||||||
|
w = Math.max(MIN, Math.min(1 - x, w + nx));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['nw', 'n', 'ne'].includes(handleId)) {
|
||||||
|
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)) {
|
||||||
|
h = Math.max(MIN, Math.min(1 - y, h + ny));
|
||||||
|
}
|
||||||
|
|
||||||
|
liveX = x;
|
||||||
|
liveY = y;
|
||||||
|
liveWidth = w;
|
||||||
|
liveHeight = h;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerDown(
|
||||||
|
event: PointerEvent,
|
||||||
|
type: 'handle' | 'move',
|
||||||
|
handleId?: HandleId
|
||||||
|
): void {
|
||||||
|
if (!event.isPrimary) return;
|
||||||
|
event.stopPropagation();
|
||||||
|
(event.currentTarget as Element).setPointerCapture(event.pointerId);
|
||||||
|
dragState = {
|
||||||
|
type,
|
||||||
|
handleId,
|
||||||
|
startPointerX: event.clientX,
|
||||||
|
startPointerY: event.clientY,
|
||||||
|
preDragX: liveX,
|
||||||
|
preDragY: liveY,
|
||||||
|
preDragWidth: liveWidth,
|
||||||
|
preDragHeight: liveHeight
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerMove(event: PointerEvent): void {
|
||||||
|
if (!dragState || !event.isPrimary) return;
|
||||||
|
const dx = event.clientX - dragState.startPointerX;
|
||||||
|
const dy = event.clientY - dragState.startPointerY;
|
||||||
|
const { nx, ny } = pixelToNorm(dx, dy);
|
||||||
|
|
||||||
|
if (dragState.type === 'move') {
|
||||||
|
liveX = Math.max(0, Math.min(1 - dragState.preDragWidth, dragState.preDragX + nx));
|
||||||
|
liveY = Math.max(0, Math.min(1 - dragState.preDragHeight, dragState.preDragY + ny));
|
||||||
|
} else if (dragState.handleId) {
|
||||||
|
applyHandleDrag(dragState.handleId, nx, ny, dragState);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePointerUp(event: PointerEvent): Promise<void> {
|
||||||
|
if (!dragState || !event.isPrimary) return;
|
||||||
|
const ds = dragState;
|
||||||
|
dragState = null;
|
||||||
|
|
||||||
|
if (
|
||||||
|
liveX === ds.preDragX &&
|
||||||
|
liveY === ds.preDragY &&
|
||||||
|
liveWidth === ds.preDragWidth &&
|
||||||
|
liveHeight === ds.preDragHeight
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateAnnotation(annotation.id, {
|
||||||
|
x: liveX,
|
||||||
|
y: liveY,
|
||||||
|
width: liveWidth,
|
||||||
|
height: liveHeight
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
liveX = ds.preDragX;
|
||||||
|
liveY = ds.preDragY;
|
||||||
|
liveWidth = ds.preDragWidth;
|
||||||
|
liveHeight = ds.preDragHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let keyDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
function handleKeyDown(event: KeyboardEvent): void {
|
||||||
|
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(event.key)) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const STEP = event.shiftKey ? 0.05 : 0.005;
|
||||||
|
|
||||||
|
if (event.key === 'ArrowLeft') liveX = Math.max(0, liveX - STEP);
|
||||||
|
if (event.key === 'ArrowRight') liveX = Math.min(1 - liveWidth, liveX + STEP);
|
||||||
|
if (event.key === 'ArrowUp') liveY = Math.max(0, liveY - STEP);
|
||||||
|
if (event.key === 'ArrowDown') liveY = Math.min(1 - liveHeight, liveY + STEP);
|
||||||
|
|
||||||
|
if (keyDebounceTimer) clearTimeout(keyDebounceTimer);
|
||||||
|
keyDebounceTimer = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
await updateAnnotation(annotation.id, {
|
||||||
|
x: liveX,
|
||||||
|
y: liveY,
|
||||||
|
width: liveWidth,
|
||||||
|
height: liveHeight
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
liveX = annotation.x;
|
||||||
|
liveY = annotation.y;
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
let previewX = $derived(((liveX - annotation.x) / annotation.width) * 100);
|
||||||
|
let previewY = $derived(((liveY - annotation.y) / annotation.height) * 100);
|
||||||
|
let previewW = $derived((liveWidth / annotation.width) * 100);
|
||||||
|
let previewH = $derived((liveHeight / annotation.height) * 100);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div aria-live="polite" class="sr-only">
|
||||||
|
{m.annotation_edit_mode_active()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||||
|
<svg
|
||||||
|
bind:this={svgEl}
|
||||||
|
viewBox="0 0 100 100"
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
role="application"
|
||||||
|
aria-label="Annotation resize handles"
|
||||||
|
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; touch-action: none; overflow: visible;"
|
||||||
|
onpointermove={handlePointerMove}
|
||||||
|
onpointerup={handlePointerUp}
|
||||||
|
onkeydown={handleKeyDown}
|
||||||
|
>
|
||||||
|
<rect
|
||||||
|
data-move-area
|
||||||
|
role="none"
|
||||||
|
x="0"
|
||||||
|
y="0"
|
||||||
|
width="100"
|
||||||
|
height="100"
|
||||||
|
fill="transparent"
|
||||||
|
style="cursor: move; pointer-events: all;"
|
||||||
|
onpointerdown={(e) => handlePointerDown(e, 'move')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if dragState}
|
||||||
|
<rect
|
||||||
|
x={previewX}
|
||||||
|
y={previewY}
|
||||||
|
width={previewW}
|
||||||
|
height={previewH}
|
||||||
|
fill="none"
|
||||||
|
stroke="#002850"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-dasharray="4 3"
|
||||||
|
pointer-events="none"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#each handles as handle (handle.id)}
|
||||||
|
<g
|
||||||
|
data-handle={handle.id}
|
||||||
|
role="none"
|
||||||
|
transform="translate({handle.cx}, {handle.cy})"
|
||||||
|
style="cursor: {handle.cursor}; pointer-events: all;"
|
||||||
|
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" />
|
||||||
|
</g>
|
||||||
|
{/each}
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border-width: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render } from 'vitest-browser-svelte';
|
||||||
|
import AnnotationEditOverlay from './AnnotationEditOverlay.svelte';
|
||||||
|
import type { Annotation } from '$lib/types';
|
||||||
|
|
||||||
|
const annotation: Annotation = {
|
||||||
|
id: 'ann-1',
|
||||||
|
documentId: 'doc-1',
|
||||||
|
pageNumber: 1,
|
||||||
|
x: 0.1,
|
||||||
|
y: 0.2,
|
||||||
|
width: 0.3,
|
||||||
|
height: 0.4,
|
||||||
|
color: '#00c7b1',
|
||||||
|
createdAt: '2026-01-01T00:00:00Z'
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('AnnotationEditOverlay', () => {
|
||||||
|
it('renders 8 handle elements', async () => {
|
||||||
|
render(AnnotationEditOverlay, { annotation });
|
||||||
|
|
||||||
|
const handles = document.querySelectorAll('[data-handle]');
|
||||||
|
expect(handles).toHaveLength(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('each handle has a 44x44 hit area', async () => {
|
||||||
|
render(AnnotationEditOverlay, { annotation });
|
||||||
|
|
||||||
|
const hitAreas = document.querySelectorAll('[data-handle-hit]');
|
||||||
|
expect(hitAreas).toHaveLength(8);
|
||||||
|
hitAreas.forEach((el) => {
|
||||||
|
expect(el.getAttribute('width')).toBe('44');
|
||||||
|
expect(el.getAttribute('height')).toBe('44');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a move area covering the full box', async () => {
|
||||||
|
render(AnnotationEditOverlay, { annotation });
|
||||||
|
|
||||||
|
const moveArea = document.querySelector('[data-move-area]');
|
||||||
|
expect(moveArea).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders an aria-live region for screen reader announcement', async () => {
|
||||||
|
render(AnnotationEditOverlay, { annotation });
|
||||||
|
|
||||||
|
const liveRegion = document.querySelector('[aria-live="polite"]');
|
||||||
|
expect(liveRegion).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user