refactor: move document transcription, annotation, viewer sub-packages
- transcription/: TranscriptionBlock, Column, EditView, PanelHeader, ReadView, Section + transcriptionMarkers, blockConflictMerge, saveBlockWithConflictRetry + useBlockAutoSave, useBlockDragDrop hooks - annotation/: AnnotationLayer, AnnotationShape, AnnotationEditOverlay - viewer/: PdfViewer, PdfControls + useFileLoader, usePdfRenderer hooks Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,349 @@
|
||||
<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);
|
||||
|
||||
// Actual rendered pixel dimensions of the SVG — updated by ResizeObserver.
|
||||
// Used as the viewBox so handles are always physically 16×16px regardless of annotation aspect ratio.
|
||||
let svgWidth = $state(1);
|
||||
let svgHeight = $state(1);
|
||||
|
||||
$effect(() => {
|
||||
if (!svgEl) return;
|
||||
const ro = new ResizeObserver(([entry]) => {
|
||||
svgWidth = entry.contentRect.width || 1;
|
||||
svgHeight = entry.contentRect.height || 1;
|
||||
});
|
||||
ro.observe(svgEl);
|
||||
return () => ro.disconnect();
|
||||
});
|
||||
|
||||
// Auto-focus the SVG when the overlay mounts so arrow keys work immediately.
|
||||
$effect(() => {
|
||||
svgEl?.focus({ preventScroll: true });
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
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);
|
||||
|
||||
// 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 }>
|
||||
>([
|
||||
{ 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}`
|
||||
},
|
||||
{ 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 } {
|
||||
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;
|
||||
|
||||
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 if (movesRightEdge) {
|
||||
w = Math.max(MIN, Math.min(1 - x, w + nx));
|
||||
}
|
||||
|
||||
if (movesTopEdge) {
|
||||
const newY = Math.max(0, Math.min(y + h - MIN, y + ny));
|
||||
h = h - (newY - y);
|
||||
y = newY;
|
||||
} else if (movesBottomEdge) {
|
||||
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 (err) {
|
||||
console.error('annotation drag update failed', err);
|
||||
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 (err) {
|
||||
console.error('annotation keyboard update failed', err);
|
||||
liveX = annotation.x;
|
||||
liveY = annotation.y;
|
||||
liveWidth = annotation.width;
|
||||
liveHeight = annotation.height;
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
const directionLabels: Record<HandleId, string> = {
|
||||
nw: 'NW',
|
||||
ne: 'NE',
|
||||
sw: 'SW',
|
||||
se: 'SE',
|
||||
n: 'N',
|
||||
s: 'S',
|
||||
e: 'E',
|
||||
w: 'W'
|
||||
};
|
||||
|
||||
// Preview rect in pixel space (maps live normalized coords back to SVG pixel coordinates).
|
||||
// Shown during pointer drag and during keyboard nudging (whenever live coords differ from stored).
|
||||
let previewX = $derived(((liveX - annotation.x) / annotation.width) * svgWidth);
|
||||
let previewY = $derived(((liveY - annotation.y) / annotation.height) * svgHeight);
|
||||
let previewW = $derived((liveWidth / annotation.width) * svgWidth);
|
||||
let previewH = $derived((liveHeight / annotation.height) * svgHeight);
|
||||
let hasLiveChanges = $derived(
|
||||
liveX !== annotation.x ||
|
||||
liveY !== annotation.y ||
|
||||
liveWidth !== annotation.width ||
|
||||
liveHeight !== annotation.height
|
||||
);
|
||||
</script>
|
||||
|
||||
<div aria-live="polite" class="sr-only">
|
||||
{m.annotation_edit_mode_active()}
|
||||
</div>
|
||||
|
||||
<svg
|
||||
bind:this={svgEl}
|
||||
viewBox="0 0 {svgWidth} {svgHeight}"
|
||||
role="application"
|
||||
tabindex="0"
|
||||
aria-label={m.annotation_resize_area()}
|
||||
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={svgWidth}
|
||||
height={svgHeight}
|
||||
fill="transparent"
|
||||
style="cursor: move; pointer-events: all;"
|
||||
onpointerdown={(e) => handlePointerDown(e, 'move')}
|
||||
/>
|
||||
|
||||
{#if dragState || hasLiveChanges}
|
||||
<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}
|
||||
|
||||
<!-- 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}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label={m.annotation_resize_handle({ direction: directionLabels[handle.id] })}
|
||||
transform="translate({handle.cx}, {handle.cy})"
|
||||
style="cursor: {handle.cursor}; pointer-events: all;"
|
||||
onpointerdown={(e) => handlePointerDown(e, 'handle', handle.id)}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<rect data-handle-hit x="-22" y="-22" width="44" height="44" fill="transparent" />
|
||||
<path d={handle.path} fill="none" stroke="#002850" stroke-width="2" stroke-linecap="square" />
|
||||
</g>
|
||||
{/each}
|
||||
</svg>
|
||||
|
||||
<style>
|
||||
svg[role='application']:focus-visible {
|
||||
outline: 2px solid #002850;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.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,71 @@
|
||||
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('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(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();
|
||||
});
|
||||
|
||||
it('SVG root has tabindex="0" so it can receive keyboard focus', async () => {
|
||||
render(AnnotationEditOverlay, { annotation });
|
||||
|
||||
const svg = document.querySelector('svg[role="application"]');
|
||||
expect(svg).not.toBeNull();
|
||||
expect(svg!.getAttribute('tabindex')).toBe('0');
|
||||
});
|
||||
});
|
||||
139
frontend/src/lib/document/annotation/AnnotationLayer.svelte
Normal file
139
frontend/src/lib/document/annotation/AnnotationLayer.svelte
Normal file
@@ -0,0 +1,139 @@
|
||||
<script lang="ts">
|
||||
import type { Annotation } from '$lib/types';
|
||||
import AnnotationShape from './AnnotationShape.svelte';
|
||||
|
||||
type DrawRect = {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
let {
|
||||
annotations = [],
|
||||
canDraw,
|
||||
color,
|
||||
blockNumbers = {},
|
||||
activeAnnotationId = null,
|
||||
dimmed = false,
|
||||
flashAnnotationId = null,
|
||||
onDraw,
|
||||
onAnnotationClick,
|
||||
onDeleteRequest
|
||||
}: {
|
||||
annotations: Annotation[];
|
||||
canDraw: boolean;
|
||||
color: string;
|
||||
blockNumbers?: Record<string, number>;
|
||||
activeAnnotationId?: string | null;
|
||||
dimmed?: boolean;
|
||||
flashAnnotationId?: string | null;
|
||||
onDraw: (rect: DrawRect) => void;
|
||||
onAnnotationClick?: (id: string) => void;
|
||||
onDeleteRequest?: (annotationId: string) => void;
|
||||
} = $props();
|
||||
|
||||
let drawStart = $state<{ x: number; y: number } | null>(null);
|
||||
let drawRect = $state<DrawRect | null>(null);
|
||||
|
||||
function getNormalizedCoords(event: PointerEvent, element: HTMLElement): { x: number; y: number } {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return {
|
||||
x: (event.clientX - rect.left) / rect.width,
|
||||
y: (event.clientY - rect.top) / rect.height
|
||||
};
|
||||
}
|
||||
|
||||
function handlePointerDown(event: PointerEvent) {
|
||||
if (!canDraw) return;
|
||||
|
||||
if ((event.target as HTMLElement).closest('[data-annotation]')) return;
|
||||
|
||||
const container = event.currentTarget as HTMLElement;
|
||||
container.setPointerCapture(event.pointerId);
|
||||
|
||||
const coords = getNormalizedCoords(event, container);
|
||||
drawStart = coords;
|
||||
drawRect = { x: coords.x, y: coords.y, width: 0, height: 0 };
|
||||
}
|
||||
|
||||
function handlePointerMove(event: PointerEvent) {
|
||||
if (!canDraw || !drawStart) return;
|
||||
|
||||
const container = event.currentTarget as HTMLElement;
|
||||
const coords = getNormalizedCoords(event, container);
|
||||
|
||||
const x = Math.min(drawStart.x, coords.x);
|
||||
const y = Math.min(drawStart.y, coords.y);
|
||||
const width = Math.abs(coords.x - drawStart.x);
|
||||
const height = Math.abs(coords.y - drawStart.y);
|
||||
|
||||
drawRect = { x, y, width, height };
|
||||
}
|
||||
|
||||
function handlePointerUp(event: PointerEvent) {
|
||||
if (!canDraw || !drawStart || !drawRect) return;
|
||||
|
||||
const container = event.currentTarget as HTMLElement;
|
||||
const coords = getNormalizedCoords(event, container);
|
||||
|
||||
const x = Math.min(drawStart.x, coords.x);
|
||||
const y = Math.min(drawStart.y, coords.y);
|
||||
const width = Math.abs(coords.x - drawStart.x);
|
||||
const height = Math.abs(coords.y - drawStart.y);
|
||||
|
||||
if (width > 0.01 && height > 0.01) {
|
||||
onDraw({ x, y, width, height });
|
||||
}
|
||||
|
||||
drawStart = null;
|
||||
drawRect = null;
|
||||
}
|
||||
|
||||
let hoveredId = $state<string | null>(null);
|
||||
|
||||
const containerStyle = $derived(
|
||||
`position: absolute; top: 0; left: 0; width: 100%; height: 100%;${canDraw ? ' cursor: crosshair; touch-action: none;' : ''}`
|
||||
);
|
||||
</script>
|
||||
|
||||
<div
|
||||
style={containerStyle}
|
||||
role="presentation"
|
||||
onpointerdown={handlePointerDown}
|
||||
onpointermove={handlePointerMove}
|
||||
onpointerup={handlePointerUp}
|
||||
>
|
||||
{#each annotations as annotation (annotation.id)}
|
||||
<AnnotationShape
|
||||
annotation={annotation}
|
||||
isHovered={hoveredId === annotation.id}
|
||||
isActive={annotation.id === activeAnnotationId}
|
||||
isResizable={canDraw && annotation.id === activeAnnotationId && !annotation.polygon}
|
||||
faded={!dimmed && !!activeAnnotationId && annotation.id !== activeAnnotationId}
|
||||
dimmed={dimmed}
|
||||
blockNumber={blockNumbers[annotation.id]}
|
||||
isFlashing={flashAnnotationId === annotation.id}
|
||||
showDelete={canDraw}
|
||||
onDeleteRequest={() => onDeleteRequest?.(annotation.id)}
|
||||
onclick={() => onAnnotationClick?.(annotation.id)}
|
||||
onpointerenter={() => (hoveredId = annotation.id)}
|
||||
onpointerleave={() => (hoveredId = null)}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
{#if drawRect && drawRect.width > 0}
|
||||
<div
|
||||
style="
|
||||
position: absolute;
|
||||
left: {drawRect.x * 100}%;
|
||||
top: {drawRect.y * 100}%;
|
||||
width: {drawRect.width * 100}%;
|
||||
height: {drawRect.height * 100}%;
|
||||
border: 2px dashed {color};
|
||||
opacity: 0.3;
|
||||
pointer-events: none;
|
||||
"
|
||||
></div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,125 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
|
||||
import AnnotationLayer from './AnnotationLayer.svelte';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
type Annotation = {
|
||||
id: string;
|
||||
documentId: string;
|
||||
pageNumber: number;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
color: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
function makeAnnotation(id = 'ann-1', color = '#00C7B1'): Annotation {
|
||||
return {
|
||||
id,
|
||||
documentId: 'doc-1',
|
||||
pageNumber: 1,
|
||||
x: 0.1,
|
||||
y: 0.1,
|
||||
width: 0.3,
|
||||
height: 0.2,
|
||||
color,
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
describe('AnnotationLayer', () => {
|
||||
it('renders a colored element for each annotation', async () => {
|
||||
render(AnnotationLayer, {
|
||||
annotations: [makeAnnotation('ann-1'), makeAnnotation('ann-2')],
|
||||
canDraw: false,
|
||||
color: '#00C7B1',
|
||||
onDraw: () => {}
|
||||
});
|
||||
|
||||
await expect.element(page.getByTestId('annotation-ann-1')).toBeInTheDocument();
|
||||
await expect.element(page.getByTestId('annotation-ann-2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('has crosshair cursor when canDraw is true', async () => {
|
||||
render(AnnotationLayer, {
|
||||
annotations: [],
|
||||
canDraw: true,
|
||||
color: '#00C7B1',
|
||||
onDraw: () => {}
|
||||
});
|
||||
|
||||
const container = document.querySelector('[role="presentation"]')!;
|
||||
expect(container.getAttribute('style')).toContain('cursor: crosshair');
|
||||
});
|
||||
|
||||
it('does not have crosshair cursor when canDraw is false', async () => {
|
||||
render(AnnotationLayer, {
|
||||
annotations: [],
|
||||
canDraw: false,
|
||||
color: '#00C7B1',
|
||||
onDraw: () => {}
|
||||
});
|
||||
|
||||
const container = document.querySelector('[role="presentation"]')!;
|
||||
expect(container.getAttribute('style')).not.toContain('cursor: crosshair');
|
||||
});
|
||||
|
||||
it('dims non-active annotations when activeAnnotationId is set', async () => {
|
||||
render(AnnotationLayer, {
|
||||
annotations: [makeAnnotation('ann-1'), makeAnnotation('ann-2')],
|
||||
canDraw: false,
|
||||
color: '#00C7B1',
|
||||
activeAnnotationId: 'ann-1',
|
||||
onDraw: () => {}
|
||||
});
|
||||
|
||||
const active = page.getByTestId('annotation-ann-1').element();
|
||||
const dimmed = page.getByTestId('annotation-ann-2').element();
|
||||
expect(active.style.opacity).toBe('1');
|
||||
expect(dimmed.style.opacity).toBe('0.3');
|
||||
});
|
||||
|
||||
it('shows all annotations at full opacity when no activeAnnotationId', async () => {
|
||||
render(AnnotationLayer, {
|
||||
annotations: [makeAnnotation('ann-1'), makeAnnotation('ann-2')],
|
||||
canDraw: false,
|
||||
color: '#00C7B1',
|
||||
onDraw: () => {}
|
||||
});
|
||||
|
||||
const el1 = page.getByTestId('annotation-ann-1').element();
|
||||
const el2 = page.getByTestId('annotation-ann-2').element();
|
||||
expect(el1.style.opacity).toBe('1');
|
||||
expect(el2.style.opacity).toBe('1');
|
||||
});
|
||||
|
||||
it('does not show delete button when annotation is not hovered or active', async () => {
|
||||
render(AnnotationLayer, {
|
||||
annotations: [makeAnnotation('ann-1')],
|
||||
canDraw: true,
|
||||
color: '#00C7B1',
|
||||
onDraw: () => {}
|
||||
});
|
||||
|
||||
await expect.element(page.getByTestId('annotation-ann-1')).toBeInTheDocument();
|
||||
expect(page.getByTestId('annotation-delete-ann-1').query()).toBeNull();
|
||||
});
|
||||
|
||||
it('does not show delete button when canDraw is false even if annotation is active', async () => {
|
||||
render(AnnotationLayer, {
|
||||
annotations: [makeAnnotation('ann-1')],
|
||||
canDraw: false,
|
||||
color: '#00C7B1',
|
||||
activeAnnotationId: 'ann-1',
|
||||
onDraw: () => {}
|
||||
});
|
||||
|
||||
await expect.element(page.getByTestId('annotation-ann-1')).toBeInTheDocument();
|
||||
expect(page.getByTestId('annotation-delete-ann-1').query()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import AnnotationLayer from './AnnotationLayer.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.1,
|
||||
color: '#00c7b1',
|
||||
createdAt: '2026-01-01T00:00:00Z'
|
||||
};
|
||||
|
||||
const polygonAnnotation: Annotation = {
|
||||
...annotation,
|
||||
id: 'ann-poly',
|
||||
polygon: [
|
||||
[0.1, 0.2],
|
||||
[0.4, 0.21],
|
||||
[0.39, 0.29],
|
||||
[0.11, 0.28]
|
||||
]
|
||||
};
|
||||
|
||||
describe('AnnotationLayer', () => {
|
||||
describe('dimmed prop', () => {
|
||||
it('should hide block number badges when dimmed is true', async () => {
|
||||
render(AnnotationLayer, {
|
||||
annotations: [annotation],
|
||||
canDraw: false,
|
||||
color: '#00c7b1',
|
||||
blockNumbers: { 'ann-1': 1 },
|
||||
dimmed: true,
|
||||
onDraw: () => {}
|
||||
});
|
||||
|
||||
const badge = page.getByText('1');
|
||||
await expect.element(badge).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show block number badges when dimmed is false', async () => {
|
||||
render(AnnotationLayer, {
|
||||
annotations: [annotation],
|
||||
canDraw: false,
|
||||
color: '#00c7b1',
|
||||
blockNumbers: { 'ann-1': 1 },
|
||||
dimmed: false,
|
||||
onDraw: () => {}
|
||||
});
|
||||
|
||||
const badge = page.getByText('1');
|
||||
await expect.element(badge).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should still fire onAnnotationClick when dimmed', async () => {
|
||||
let clickedId: string | undefined;
|
||||
render(AnnotationLayer, {
|
||||
annotations: [annotation],
|
||||
canDraw: false,
|
||||
color: '#00c7b1',
|
||||
dimmed: true,
|
||||
onDraw: () => {},
|
||||
onAnnotationClick: (id: string) => {
|
||||
clickedId = id;
|
||||
}
|
||||
});
|
||||
|
||||
const el = document.querySelector('[data-testid="annotation-ann-1"]')!;
|
||||
el.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
expect(clickedId).toBe('ann-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isResizable computation', () => {
|
||||
it('passes isResizable=true when canDraw, annotation is active, and has no polygon', async () => {
|
||||
render(AnnotationLayer, {
|
||||
annotations: [annotation],
|
||||
canDraw: true,
|
||||
color: '#00c7b1',
|
||||
activeAnnotationId: 'ann-1',
|
||||
onDraw: () => {}
|
||||
});
|
||||
|
||||
const handles = document.querySelectorAll('[data-handle]');
|
||||
expect(handles).toHaveLength(8);
|
||||
});
|
||||
|
||||
it('passes isResizable=false when annotation has a polygon', async () => {
|
||||
render(AnnotationLayer, {
|
||||
annotations: [polygonAnnotation],
|
||||
canDraw: true,
|
||||
color: '#00c7b1',
|
||||
activeAnnotationId: 'ann-poly',
|
||||
onDraw: () => {}
|
||||
});
|
||||
|
||||
const handles = document.querySelectorAll('[data-handle]');
|
||||
expect(handles).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('passes isResizable=false when canDraw is false', async () => {
|
||||
render(AnnotationLayer, {
|
||||
annotations: [annotation],
|
||||
canDraw: false,
|
||||
color: '#00c7b1',
|
||||
activeAnnotationId: 'ann-1',
|
||||
onDraw: () => {}
|
||||
});
|
||||
|
||||
const handles = document.querySelectorAll('[data-handle]');
|
||||
expect(handles).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('passes isResizable=false when annotation is not active', async () => {
|
||||
render(AnnotationLayer, {
|
||||
annotations: [annotation],
|
||||
canDraw: true,
|
||||
color: '#00c7b1',
|
||||
activeAnnotationId: 'other-id',
|
||||
onDraw: () => {}
|
||||
});
|
||||
|
||||
const handles = document.querySelectorAll('[data-handle]');
|
||||
expect(handles).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('flashAnnotationId prop', () => {
|
||||
it('should apply annotation-flash class when flashAnnotationId matches', async () => {
|
||||
render(AnnotationLayer, {
|
||||
annotations: [annotation],
|
||||
canDraw: false,
|
||||
color: '#00c7b1',
|
||||
flashAnnotationId: 'ann-1',
|
||||
onDraw: () => {}
|
||||
});
|
||||
|
||||
const el = document.querySelector('[data-testid="annotation-ann-1"]')!;
|
||||
expect(el.classList.contains('annotation-flash')).toBe(true);
|
||||
});
|
||||
|
||||
it('should not apply annotation-flash class when flashAnnotationId does not match', async () => {
|
||||
render(AnnotationLayer, {
|
||||
annotations: [annotation],
|
||||
canDraw: false,
|
||||
color: '#00c7b1',
|
||||
flashAnnotationId: 'other-id',
|
||||
onDraw: () => {}
|
||||
});
|
||||
|
||||
const el = document.querySelector('[data-testid="annotation-ann-1"]')!;
|
||||
expect(el.classList.contains('annotation-flash')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
194
frontend/src/lib/document/annotation/AnnotationShape.svelte
Normal file
194
frontend/src/lib/document/annotation/AnnotationShape.svelte
Normal file
@@ -0,0 +1,194 @@
|
||||
<script lang="ts">
|
||||
import type { Annotation } from '$lib/types';
|
||||
import AnnotationEditOverlay from './AnnotationEditOverlay.svelte';
|
||||
|
||||
let {
|
||||
annotation,
|
||||
isHovered,
|
||||
isActive,
|
||||
faded = false,
|
||||
dimmed = false,
|
||||
blockNumber = undefined,
|
||||
isFlashing = false,
|
||||
isResizable = false,
|
||||
showDelete = false,
|
||||
onDeleteRequest,
|
||||
onclick,
|
||||
onpointerenter,
|
||||
onpointerleave
|
||||
}: {
|
||||
annotation: Annotation;
|
||||
isHovered: boolean;
|
||||
isActive: boolean;
|
||||
faded?: boolean;
|
||||
dimmed?: boolean;
|
||||
blockNumber?: number | undefined;
|
||||
isFlashing?: boolean;
|
||||
isResizable?: boolean;
|
||||
showDelete?: boolean;
|
||||
onDeleteRequest?: () => void;
|
||||
onclick: () => void;
|
||||
onpointerenter: () => void;
|
||||
onpointerleave: () => void;
|
||||
} = $props();
|
||||
|
||||
const deleteVisible = $derived(showDelete && (isHovered || isActive));
|
||||
|
||||
function hexToRgba(hex: string, alpha: number): string {
|
||||
const r = parseInt(hex.slice(1, 3), 16);
|
||||
const g = parseInt(hex.slice(3, 5), 16);
|
||||
const b = parseInt(hex.slice(5, 7), 16);
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||
}
|
||||
|
||||
let clipPath = $derived.by(() => {
|
||||
if (!annotation.polygon || annotation.polygon.length !== 4) return 'none';
|
||||
const points = annotation.polygon
|
||||
.map(([px, py]) => {
|
||||
const cx = ((px - annotation.x) / annotation.width) * 100;
|
||||
const cy = ((py - annotation.y) / annotation.height) * 100;
|
||||
return `${cx}% ${cy}%`;
|
||||
})
|
||||
.join(', ');
|
||||
return `polygon(${points})`;
|
||||
});
|
||||
|
||||
let bgAlpha = $derived(dimmed ? 0.3 : isHovered || isActive ? 0.5 : 0.3);
|
||||
|
||||
let boxShadow = $derived.by(() => {
|
||||
if (dimmed) return 'none';
|
||||
if (isActive || isHovered) return `inset 0 0 0 2px ${hexToRgba(annotation.color, 0.8)}`;
|
||||
return 'none';
|
||||
});
|
||||
|
||||
let opacity = $derived(dimmed ? 1 : faded ? 0.3 : 1);
|
||||
|
||||
let shapeStyle = $derived(
|
||||
`position: absolute;` +
|
||||
` left: ${annotation.x * 100}%;` +
|
||||
` top: ${annotation.y * 100}%;` +
|
||||
` width: ${annotation.width * 100}%;` +
|
||||
` height: ${annotation.height * 100}%;` +
|
||||
` background-color: ${hexToRgba(annotation.color, bgAlpha)};` +
|
||||
` box-shadow: ${boxShadow};` +
|
||||
` opacity: ${opacity};` +
|
||||
` pointer-events: auto;` +
|
||||
` cursor: pointer;` +
|
||||
` transition: background-color 0.15s ease, box-shadow 0.15s ease, opacity 0.3s ease;` +
|
||||
(clipPath !== 'none' ? ` clip-path: ${clipPath};` : '')
|
||||
);
|
||||
</script>
|
||||
|
||||
<div
|
||||
data-testid="annotation-{annotation.id}"
|
||||
data-annotation
|
||||
class:annotation-flash={isFlashing}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label="Block anzeigen"
|
||||
onclick={onclick}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') onclick();
|
||||
if (e.key === 'Delete' && showDelete) onDeleteRequest?.();
|
||||
}}
|
||||
onpointerenter={onpointerenter}
|
||||
onpointerleave={onpointerleave}
|
||||
style={shapeStyle}
|
||||
>
|
||||
{#if !dimmed && blockNumber}
|
||||
<div
|
||||
style="
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
left: -8px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background-color: {annotation.color};
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
font-family: sans-serif;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
|
||||
"
|
||||
>
|
||||
{blockNumber}
|
||||
</div>
|
||||
{/if}
|
||||
{#if deleteVisible}
|
||||
<button
|
||||
data-testid="annotation-delete-{annotation.id}"
|
||||
type="button"
|
||||
aria-label="Löschen"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDeleteRequest?.();
|
||||
}}
|
||||
style="
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background-color: #fff;
|
||||
border: 1px solid var(--color-error, #e53e3e);
|
||||
color: var(--color-error, #e53e3e);
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.2);
|
||||
z-index: 10;
|
||||
"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
{#if isResizable}
|
||||
<AnnotationEditOverlay annotation={annotation} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes annotation-flash-anim {
|
||||
0% {
|
||||
outline: 3px solid color-mix(in srgb, var(--color-turquoise) 80%, transparent);
|
||||
outline-offset: 0px;
|
||||
}
|
||||
100% {
|
||||
outline: 3px solid color-mix(in srgb, var(--color-turquoise) 0%, transparent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.annotation-flash {
|
||||
animation: annotation-flash-anim 1.5s ease-out;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.annotation-flash {
|
||||
animation: none;
|
||||
outline: 3px solid color-mix(in srgb, var(--color-turquoise) 80%, transparent);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,177 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import AnnotationShape from './AnnotationShape.svelte';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function makeAnnotation(id = 'ann-1') {
|
||||
return {
|
||||
id,
|
||||
documentId: 'doc-1',
|
||||
pageNumber: 1,
|
||||
x: 0.1,
|
||||
y: 0.1,
|
||||
width: 0.3,
|
||||
height: 0.2,
|
||||
color: '#00C7B1',
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
describe('AnnotationShape', () => {
|
||||
it('renders the annotation element', async () => {
|
||||
render(AnnotationShape, {
|
||||
annotation: makeAnnotation(),
|
||||
isHovered: false,
|
||||
isActive: false,
|
||||
onclick: () => {},
|
||||
onpointerenter: () => {},
|
||||
onpointerleave: () => {}
|
||||
});
|
||||
|
||||
await expect.element(page.getByTestId('annotation-ann-1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show delete button when showDelete is false', async () => {
|
||||
render(AnnotationShape, {
|
||||
annotation: makeAnnotation(),
|
||||
isHovered: true,
|
||||
isActive: false,
|
||||
showDelete: false,
|
||||
onDeleteRequest: vi.fn(),
|
||||
onclick: () => {},
|
||||
onpointerenter: () => {},
|
||||
onpointerleave: () => {}
|
||||
});
|
||||
|
||||
expect(page.getByTestId('annotation-delete-ann-1').query()).toBeNull();
|
||||
});
|
||||
|
||||
it('does not show delete button when showDelete is true but neither hovered nor active', async () => {
|
||||
render(AnnotationShape, {
|
||||
annotation: makeAnnotation(),
|
||||
isHovered: false,
|
||||
isActive: false,
|
||||
showDelete: true,
|
||||
onDeleteRequest: vi.fn(),
|
||||
onclick: () => {},
|
||||
onpointerenter: () => {},
|
||||
onpointerleave: () => {}
|
||||
});
|
||||
|
||||
expect(page.getByTestId('annotation-delete-ann-1').query()).toBeNull();
|
||||
});
|
||||
|
||||
it('shows delete button when showDelete is true and isHovered is true', async () => {
|
||||
render(AnnotationShape, {
|
||||
annotation: makeAnnotation(),
|
||||
isHovered: true,
|
||||
isActive: false,
|
||||
showDelete: true,
|
||||
onDeleteRequest: vi.fn(),
|
||||
onclick: () => {},
|
||||
onpointerenter: () => {},
|
||||
onpointerleave: () => {}
|
||||
});
|
||||
|
||||
await expect.element(page.getByTestId('annotation-delete-ann-1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows delete button when showDelete is true and isActive is true', async () => {
|
||||
render(AnnotationShape, {
|
||||
annotation: makeAnnotation(),
|
||||
isHovered: false,
|
||||
isActive: true,
|
||||
showDelete: true,
|
||||
onDeleteRequest: vi.fn(),
|
||||
onclick: () => {},
|
||||
onpointerenter: () => {},
|
||||
onpointerleave: () => {}
|
||||
});
|
||||
|
||||
await expect.element(page.getByTestId('annotation-delete-ann-1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onDeleteRequest when delete button is clicked', async () => {
|
||||
const onDeleteRequest = vi.fn();
|
||||
|
||||
render(AnnotationShape, {
|
||||
annotation: makeAnnotation(),
|
||||
isHovered: true,
|
||||
isActive: false,
|
||||
showDelete: true,
|
||||
onDeleteRequest,
|
||||
onclick: () => {},
|
||||
onpointerenter: () => {},
|
||||
onpointerleave: () => {}
|
||||
});
|
||||
|
||||
const deleteBtn = page.getByTestId('annotation-delete-ann-1');
|
||||
await deleteBtn.click();
|
||||
|
||||
expect(onDeleteRequest).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('does not call onclick when delete button is clicked', async () => {
|
||||
const onclick = vi.fn();
|
||||
const onDeleteRequest = vi.fn();
|
||||
|
||||
render(AnnotationShape, {
|
||||
annotation: makeAnnotation(),
|
||||
isHovered: true,
|
||||
isActive: false,
|
||||
showDelete: true,
|
||||
onDeleteRequest,
|
||||
onclick,
|
||||
onpointerenter: () => {},
|
||||
onpointerleave: () => {}
|
||||
});
|
||||
|
||||
const deleteBtn = page.getByTestId('annotation-delete-ann-1');
|
||||
await deleteBtn.click();
|
||||
|
||||
expect(onclick).not.toHaveBeenCalled();
|
||||
expect(onDeleteRequest).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('calls onDeleteRequest when Delete key is pressed on the annotation', async () => {
|
||||
const onDeleteRequest = vi.fn();
|
||||
|
||||
render(AnnotationShape, {
|
||||
annotation: makeAnnotation(),
|
||||
isHovered: false,
|
||||
isActive: true,
|
||||
showDelete: true,
|
||||
onDeleteRequest,
|
||||
onclick: () => {},
|
||||
onpointerenter: () => {},
|
||||
onpointerleave: () => {}
|
||||
});
|
||||
|
||||
const annotationEl = page.getByTestId('annotation-ann-1').element() as HTMLElement;
|
||||
annotationEl.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', bubbles: true }));
|
||||
|
||||
expect(onDeleteRequest).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('does not call onDeleteRequest on Delete key when showDelete is false', async () => {
|
||||
const onDeleteRequest = vi.fn();
|
||||
|
||||
render(AnnotationShape, {
|
||||
annotation: makeAnnotation(),
|
||||
isHovered: false,
|
||||
isActive: true,
|
||||
showDelete: false,
|
||||
onDeleteRequest,
|
||||
onclick: () => {},
|
||||
onpointerenter: () => {},
|
||||
onpointerleave: () => {}
|
||||
});
|
||||
|
||||
const annotationEl = page.getByTestId('annotation-ann-1').element() as HTMLElement;
|
||||
annotationEl.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', bubbles: true }));
|
||||
|
||||
expect(onDeleteRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
import AnnotationShape from './AnnotationShape.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('AnnotationShape', () => {
|
||||
describe('isResizable prop', () => {
|
||||
it('does not render AnnotationEditOverlay when isResizable is false', async () => {
|
||||
render(AnnotationShape, {
|
||||
annotation,
|
||||
isHovered: false,
|
||||
isActive: false,
|
||||
isResizable: false,
|
||||
onclick: () => {},
|
||||
onpointerenter: () => {},
|
||||
onpointerleave: () => {}
|
||||
});
|
||||
|
||||
const handles = document.querySelectorAll('[data-handle]');
|
||||
expect(handles).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('renders AnnotationEditOverlay when isResizable is true', async () => {
|
||||
render(AnnotationShape, {
|
||||
annotation,
|
||||
isHovered: false,
|
||||
isActive: true,
|
||||
isResizable: true,
|
||||
onclick: () => {},
|
||||
onpointerenter: () => {},
|
||||
onpointerleave: () => {}
|
||||
});
|
||||
|
||||
const handles = document.querySelectorAll('[data-handle]');
|
||||
expect(handles).toHaveLength(8);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user