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:
@@ -1,349 +0,0 @@
|
||||
<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>
|
||||
@@ -1,71 +0,0 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -1,139 +0,0 @@
|
||||
<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>
|
||||
@@ -1,125 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,160 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,194 +0,0 @@
|
||||
<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>
|
||||
@@ -1,177 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,50 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import type { components } from '$lib/generated/api';
|
||||
import SegmentationColumn from '$lib/document/SegmentationColumn.svelte';
|
||||
import TranscriptionColumn from './TranscriptionColumn.svelte';
|
||||
import TranscriptionColumn from '$lib/document/transcription/TranscriptionColumn.svelte';
|
||||
import ReadyColumn from '$lib/document/ReadyColumn.svelte';
|
||||
|
||||
type TranscriptionQueueItemDTO = components['schemas']['TranscriptionQueueItemDTO'];
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
type Props = {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
isLoaded: boolean;
|
||||
showAnnotations: boolean;
|
||||
annotationCount: number;
|
||||
onPrev: () => void;
|
||||
onNext: () => void;
|
||||
onZoomIn: () => void;
|
||||
onZoomOut: () => void;
|
||||
onToggleAnnotations: () => void;
|
||||
};
|
||||
|
||||
let {
|
||||
currentPage,
|
||||
totalPages,
|
||||
isLoaded,
|
||||
showAnnotations,
|
||||
annotationCount,
|
||||
onPrev,
|
||||
onNext,
|
||||
onZoomIn,
|
||||
onZoomOut,
|
||||
onToggleAnnotations
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex shrink-0 items-center justify-between gap-2 border-b border-pdf-ctrl px-4 py-2">
|
||||
<!-- Page navigation: prev button, page counter, next button -->
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
onclick={onPrev}
|
||||
disabled={currentPage <= 1}
|
||||
aria-label="Zurück"
|
||||
class="rounded p-1 text-ink-3 transition hover:bg-surface/10 disabled:opacity-40"
|
||||
>
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if totalPages > 0}
|
||||
<span class="font-sans text-xs text-ink-2 tabular-nums">
|
||||
{currentPage} / {totalPages}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
onclick={onNext}
|
||||
disabled={!isLoaded || currentPage >= totalPages}
|
||||
aria-label="Weiter"
|
||||
class="rounded p-1 text-ink-3 transition hover:bg-surface/10 disabled:opacity-40"
|
||||
>
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Zoom controls -->
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
onclick={onZoomOut}
|
||||
aria-label="Verkleinern"
|
||||
class="rounded p-1 text-ink-3 transition hover:bg-surface/10"
|
||||
>
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path stroke-linecap="round" d="M21 21l-4.35-4.35M8 11h6" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={onZoomIn}
|
||||
aria-label="Vergrößern"
|
||||
class="rounded p-1 text-ink-3 transition hover:bg-surface/10"
|
||||
>
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path stroke-linecap="round" d="M21 21l-4.35-4.35M11 8v6M8 11h6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Annotation visibility toggle (only when annotations exist) -->
|
||||
{#if annotationCount > 0}
|
||||
<button
|
||||
onclick={onToggleAnnotations}
|
||||
aria-label={showAnnotations ? m.pdf_annotations_hide() : m.pdf_annotations_show()}
|
||||
class="flex items-center gap-1.5 rounded px-2 py-1 font-sans text-xs transition {showAnnotations
|
||||
? 'text-ink-2 hover:bg-surface/10'
|
||||
: 'bg-surface/10 text-primary'}"
|
||||
>
|
||||
<svg
|
||||
class="h-3.5 w-3.5 shrink-0"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
{#if showAnnotations}
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||
/>
|
||||
{:else}
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
|
||||
/>
|
||||
{/if}
|
||||
</svg>
|
||||
{showAnnotations ? m.pdf_annotations_hide() : m.pdf_annotations_show()}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,67 +0,0 @@
|
||||
import { vi, describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
|
||||
import PdfControls from './PdfControls.svelte';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const defaultProps = {
|
||||
currentPage: 1,
|
||||
totalPages: 3,
|
||||
isLoaded: true,
|
||||
showAnnotations: false,
|
||||
annotationCount: 0,
|
||||
onPrev: vi.fn(),
|
||||
onNext: vi.fn(),
|
||||
onZoomIn: vi.fn(),
|
||||
onZoomOut: vi.fn(),
|
||||
onToggleAnnotations: vi.fn()
|
||||
};
|
||||
|
||||
describe('PdfControls — annotation toggle visibility', () => {
|
||||
it('renders annotation toggle when annotationCount is greater than zero', async () => {
|
||||
render(PdfControls, { ...defaultProps, annotationCount: 3 });
|
||||
await expect
|
||||
.element(page.getByRole('button', { name: /annotierungen anzeigen/i }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render annotation toggle when annotationCount is zero', async () => {
|
||||
render(PdfControls, { ...defaultProps, annotationCount: 0 });
|
||||
await expect
|
||||
.element(page.getByRole('button', { name: /annotierungen/i }))
|
||||
.not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PdfControls — annotation toggle label', () => {
|
||||
it('shows "Annotierungen anzeigen" label when annotations are hidden', async () => {
|
||||
render(PdfControls, { ...defaultProps, annotationCount: 2, showAnnotations: false });
|
||||
const btn = page.getByRole('button', { name: /annotierungen anzeigen/i });
|
||||
await expect.element(btn).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "Annotierungen verbergen" label when annotations are visible', async () => {
|
||||
render(PdfControls, { ...defaultProps, annotationCount: 2, showAnnotations: true });
|
||||
const btn = page.getByRole('button', { name: /annotierungen verbergen/i });
|
||||
await expect.element(btn).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PdfControls — annotation toggle contrast (WCAG 2.1 AA)', () => {
|
||||
it('uses text-primary class on annotation toggle button when annotations are hidden', async () => {
|
||||
const { container } = render(PdfControls, {
|
||||
...defaultProps,
|
||||
annotationCount: 2,
|
||||
showAnnotations: false
|
||||
});
|
||||
const allButtons = container.querySelectorAll('button');
|
||||
const annotationBtn = Array.from(allButtons).find((b) =>
|
||||
b.getAttribute('aria-label')?.toLowerCase().includes('annotierungen')
|
||||
);
|
||||
expect(annotationBtn).not.toBeNull();
|
||||
expect(annotationBtn!.className).toContain('text-primary');
|
||||
expect(annotationBtn!.className).not.toContain('text-accent');
|
||||
});
|
||||
});
|
||||
@@ -1,277 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onMount, setContext } from 'svelte';
|
||||
import { createPdfRenderer } from '$lib/hooks/usePdfRenderer.svelte';
|
||||
import PdfControls from './PdfControls.svelte';
|
||||
import AnnotationLayer from './AnnotationLayer.svelte';
|
||||
import type { Annotation } from '$lib/types';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { parseBackendError, getErrorMessage } from '$lib/errors';
|
||||
|
||||
type DrawRect = { x: number; y: number; width: number; height: number; pageNumber: number };
|
||||
|
||||
let {
|
||||
url,
|
||||
documentId = '',
|
||||
transcribeMode = false,
|
||||
blockNumbers = {},
|
||||
annotationReloadKey = 0,
|
||||
activeAnnotationId = $bindable<string | null>(null),
|
||||
onAnnotationClick,
|
||||
onTranscriptionDraw,
|
||||
onDeleteAnnotationRequest,
|
||||
documentFileHash,
|
||||
annotationsDimmed = false,
|
||||
flashAnnotationId = null
|
||||
}: {
|
||||
url: string;
|
||||
documentId?: string;
|
||||
transcribeMode?: boolean;
|
||||
blockNumbers?: Record<string, number>;
|
||||
annotationReloadKey?: number;
|
||||
activeAnnotationId?: string | null;
|
||||
onAnnotationClick?: (id: string) => void;
|
||||
onTranscriptionDraw?: (rect: DrawRect) => void;
|
||||
onDeleteAnnotationRequest?: (annotationId: string) => void;
|
||||
documentFileHash?: string | null;
|
||||
annotationsDimmed?: boolean;
|
||||
flashAnnotationId?: string | null;
|
||||
} = $props();
|
||||
|
||||
const renderer = createPdfRenderer();
|
||||
|
||||
// Canvas and text layer container refs — bound via bind:this
|
||||
let canvasEl = $state<HTMLCanvasElement | null>(null);
|
||||
let textLayerEl = $state<HTMLDivElement | null>(null);
|
||||
|
||||
let annotations = $state<Annotation[]>([]);
|
||||
let showAnnotations = $state(true);
|
||||
let annotationUpdateError = $state<string | null>(null);
|
||||
|
||||
const TRANSCRIPTION_COLOR = '#00C7B1';
|
||||
|
||||
const visibleAnnotations = $derived(
|
||||
annotations.filter((a) => !a.fileHash || !documentFileHash || a.fileHash === documentFileHash)
|
||||
);
|
||||
const outdatedCount = $derived(annotations.length - visibleAnnotations.length);
|
||||
|
||||
onMount(async () => {
|
||||
await renderer.init();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (renderer.pdfjsReady && url) {
|
||||
renderer.loadDocument(url);
|
||||
}
|
||||
});
|
||||
|
||||
// Wire DOM elements to the renderer and trigger rendering.
|
||||
// canvasEl is read synchronously so Svelte tracks it as a dependency:
|
||||
// when the canvas reappears after the loading spinner (loading → false),
|
||||
// this effect re-fires and renders the already-loaded PDF.
|
||||
$effect(() => {
|
||||
if (!canvasEl || !textLayerEl) return;
|
||||
renderer.setElements(canvasEl, textLayerEl);
|
||||
// Also track currentPage and scale so page-nav / zoom re-renders work.
|
||||
if (renderer.isLoaded && renderer.currentPage && renderer.scale > 0) {
|
||||
renderer.renderCurrentPage().then(() => renderer.prerender());
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (documentId && annotationReloadKey >= 0) {
|
||||
loadAnnotations(documentId);
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (transcribeMode) showAnnotations = true;
|
||||
});
|
||||
|
||||
// Scroll-sync: when activeAnnotationId changes, navigate to its page
|
||||
let prevActiveAnnotationId: string | null = null;
|
||||
$effect(() => {
|
||||
const id = activeAnnotationId;
|
||||
if (!id || id === prevActiveAnnotationId || !renderer.isLoaded) {
|
||||
prevActiveAnnotationId = id;
|
||||
return;
|
||||
}
|
||||
prevActiveAnnotationId = id;
|
||||
|
||||
const ann = annotations.find((a) => a.id === id);
|
||||
if (!ann) return;
|
||||
|
||||
if (ann.pageNumber !== renderer.currentPage) {
|
||||
renderer.goToPage(ann.pageNumber);
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
const el = document.querySelector(`[data-testid="annotation-${id}"]`);
|
||||
el?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function loadAnnotations(docId: string) {
|
||||
if (!docId) return;
|
||||
try {
|
||||
const res = await fetch(`/api/documents/${docId}/annotations`);
|
||||
if (res.ok) {
|
||||
annotations = await res.json();
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
async function updateAnnotation(
|
||||
annotationId: string,
|
||||
coords: { x: number; y: number; width: number; height: number }
|
||||
) {
|
||||
if (!documentId) return;
|
||||
const res = await fetch(`/api/documents/${documentId}/annotations/${annotationId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(coords)
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await parseBackendError(res);
|
||||
const msg = getErrorMessage(err?.code ?? 'ANNOTATION_UPDATE_FAILED');
|
||||
annotationUpdateError = msg;
|
||||
setTimeout(() => (annotationUpdateError = null), 4000);
|
||||
throw new Error(msg);
|
||||
}
|
||||
const updated = await res.json();
|
||||
annotations = annotations.map((a) => (a.id === annotationId ? updated : a));
|
||||
}
|
||||
|
||||
setContext('annotationUpdate', updateAnnotation);
|
||||
|
||||
async function handleDraw(rect: { x: number; y: number; width: number; height: number }) {
|
||||
if (!documentId || !transcribeMode) return;
|
||||
await onTranscriptionDraw?.({ ...rect, pageNumber: renderer.currentPage });
|
||||
await loadAnnotations(documentId);
|
||||
}
|
||||
|
||||
function handleAnnotationClick(id: string) {
|
||||
activeAnnotationId = id;
|
||||
onAnnotationClick?.(id);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if !url}
|
||||
<div class="flex h-full w-full items-center justify-center bg-pdf-bg text-ink-3">
|
||||
<p class="font-sans text-sm">Keine Datei vorhanden</p>
|
||||
</div>
|
||||
{:else if renderer.error}
|
||||
<div class="flex h-full w-full flex-col items-center justify-center gap-3 bg-pdf-bg text-ink-3">
|
||||
<p class="font-sans text-sm text-red-400">Fehler beim Laden der PDF</p>
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="font-sans text-xs text-primary underline hover:text-ink-2"
|
||||
>
|
||||
Direkt öffnen
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex h-full w-full flex-col bg-pdf-bg">
|
||||
{#if outdatedCount > 0}
|
||||
<div
|
||||
class="flex shrink-0 items-center gap-2 border-b border-amber-500/30 bg-amber-500/10 px-4 py-2"
|
||||
data-testid="annotation-outdated-notice"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4 shrink-0 text-amber-400"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="font-sans text-xs text-amber-300">{m.annotation_outdated_notice()}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if annotationUpdateError}
|
||||
<div
|
||||
class="flex shrink-0 items-center gap-2 border-b border-red-500/30 bg-red-500/10 px-4 py-2"
|
||||
aria-live="assertive"
|
||||
role="alert"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4 shrink-0 text-red-400"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="15" y1="9" x2="9" y2="15" />
|
||||
<line x1="9" y1="9" x2="15" y2="15" />
|
||||
</svg>
|
||||
<span class="font-sans text-xs text-red-300">{annotationUpdateError}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<PdfControls
|
||||
currentPage={renderer.currentPage}
|
||||
totalPages={renderer.totalPages}
|
||||
isLoaded={renderer.isLoaded}
|
||||
showAnnotations={showAnnotations}
|
||||
annotationCount={annotations.length}
|
||||
onPrev={() => renderer.prevPage()}
|
||||
onNext={() => renderer.nextPage()}
|
||||
onZoomIn={() => renderer.zoomIn()}
|
||||
onZoomOut={() => renderer.zoomOut()}
|
||||
onToggleAnnotations={() => (showAnnotations = !showAnnotations)}
|
||||
/>
|
||||
|
||||
<!-- PDF canvas area -->
|
||||
<div class="relative flex-1 overflow-auto">
|
||||
{#if renderer.loading}
|
||||
<div class="flex h-full items-center justify-center">
|
||||
<div
|
||||
class="h-8 w-8 animate-spin rounded-full border-2 border-white/20 border-t-white"
|
||||
></div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex min-h-full items-start justify-center p-4">
|
||||
<div
|
||||
class="pdf-page relative shadow-xl"
|
||||
data-page-number={renderer.currentPage}
|
||||
style="position: relative"
|
||||
>
|
||||
<canvas bind:this={canvasEl}></canvas>
|
||||
<div
|
||||
bind:this={textLayerEl}
|
||||
class="textLayer"
|
||||
style="position: absolute; top: 0; left: 0; overflow: hidden; pointer-events: none; line-height: 1;"
|
||||
></div>
|
||||
{#if showAnnotations}
|
||||
<AnnotationLayer
|
||||
annotations={visibleAnnotations.filter(
|
||||
(a) => a.pageNumber === renderer.currentPage
|
||||
)}
|
||||
canDraw={transcribeMode}
|
||||
color={TRANSCRIPTION_COLOR}
|
||||
blockNumbers={blockNumbers}
|
||||
activeAnnotationId={activeAnnotationId}
|
||||
dimmed={annotationsDimmed}
|
||||
flashAnnotationId={flashAnnotationId}
|
||||
onDraw={handleDraw}
|
||||
onAnnotationClick={handleAnnotationClick}
|
||||
onDeleteRequest={onDeleteAnnotationRequest}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { vi, describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
|
||||
// pdfjs-dist is a rendering dependency — we mock it so unit tests don't need
|
||||
// a real browser PDF engine. The interesting behaviour under test here is the
|
||||
// component's own UI logic (controls, page counter), not pdfjs internals.
|
||||
vi.mock('pdfjs-dist', () => {
|
||||
function TextLayerMock() {}
|
||||
TextLayerMock.prototype.render = () => Promise.resolve();
|
||||
TextLayerMock.prototype.cancel = () => {};
|
||||
|
||||
return {
|
||||
GlobalWorkerOptions: { workerSrc: '' },
|
||||
getDocument: vi.fn().mockReturnValue({
|
||||
promise: Promise.resolve({
|
||||
numPages: 2,
|
||||
getPage: vi.fn().mockResolvedValue({
|
||||
getViewport: vi.fn().mockReturnValue({ width: 595, height: 842 }),
|
||||
render: vi.fn().mockReturnValue({ promise: Promise.resolve() }),
|
||||
streamTextContent: vi.fn().mockReturnValue(new ReadableStream())
|
||||
})
|
||||
})
|
||||
}),
|
||||
TextLayer: TextLayerMock
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('pdfjs-dist/build/pdf.worker.min.mjs?url', () => ({ default: '' }));
|
||||
|
||||
import PdfViewer from './PdfViewer.svelte';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('PdfViewer', () => {
|
||||
it('shows previous and next page navigation buttons', async () => {
|
||||
render(PdfViewer, { url: '/api/documents/test-id/file' });
|
||||
await expect.element(page.getByRole('button', { name: /zurück/i })).toBeInTheDocument();
|
||||
await expect.element(page.getByRole('button', { name: /weiter/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows zoom controls', async () => {
|
||||
render(PdfViewer, { url: '/api/documents/test-id/file' });
|
||||
await expect.element(page.getByRole('button', { name: /vergrößern/i })).toBeInTheDocument();
|
||||
await expect.element(page.getByRole('button', { name: /verkleinern/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays the page counter once the PDF has loaded', async () => {
|
||||
render(PdfViewer, { url: '/api/documents/test-id/file' });
|
||||
// Mock resolves synchronously, so "1 / 2" should appear quickly
|
||||
await expect.element(page.getByText(/1\s*\/\s*2/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,292 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { getConfirmService } from '$lib/services/confirm.svelte.js';
|
||||
import CommentThread from './CommentThread.svelte';
|
||||
import PersonMentionEditor from './PersonMentionEditor.svelte';
|
||||
import type { PersonMention } from '$lib/types';
|
||||
|
||||
const { confirm } = getConfirmService();
|
||||
|
||||
type SaveState = 'idle' | 'saving' | 'saved' | 'fading' | 'error';
|
||||
|
||||
type Props = {
|
||||
blockId: string;
|
||||
documentId: string;
|
||||
blockNumber: number;
|
||||
text: string;
|
||||
mentionedPersons: PersonMention[];
|
||||
label: string | null;
|
||||
active: boolean;
|
||||
reviewed: boolean;
|
||||
saveState: SaveState;
|
||||
canComment: boolean;
|
||||
currentUserId: string | null;
|
||||
onTextChange: (text: string, mentionedPersons: PersonMention[]) => void;
|
||||
onFocus: () => void;
|
||||
onDeleteClick: () => void;
|
||||
onRetry: () => void;
|
||||
onReviewToggle: () => void;
|
||||
onMoveUp?: () => void;
|
||||
onMoveDown?: () => void;
|
||||
isFirst?: boolean;
|
||||
isLast?: boolean;
|
||||
source?: 'MANUAL' | 'OCR';
|
||||
};
|
||||
|
||||
let {
|
||||
blockId,
|
||||
documentId,
|
||||
blockNumber,
|
||||
text,
|
||||
mentionedPersons,
|
||||
label = null,
|
||||
active,
|
||||
reviewed,
|
||||
saveState,
|
||||
canComment,
|
||||
currentUserId,
|
||||
onTextChange,
|
||||
onFocus,
|
||||
onDeleteClick,
|
||||
onRetry,
|
||||
onReviewToggle,
|
||||
onMoveUp,
|
||||
onMoveDown,
|
||||
isFirst = false,
|
||||
isLast = false,
|
||||
source = 'MANUAL'
|
||||
}: Props = $props();
|
||||
|
||||
let localText = $state(text);
|
||||
let localMentions = $state<PersonMention[]>([...mentionedPersons]);
|
||||
let commentOpen = $state(false);
|
||||
let commentCount = $state(0);
|
||||
let selectedQuote = $state<string | null>(null);
|
||||
|
||||
const hasComments = $derived(commentCount > 0);
|
||||
|
||||
// Sync from prop only when switching to a different block (not on save responses)
|
||||
let prevBlockId = $state(blockId);
|
||||
$effect(() => {
|
||||
if (blockId !== prevBlockId) {
|
||||
localText = text;
|
||||
localMentions = [...mentionedPersons];
|
||||
prevBlockId = blockId;
|
||||
}
|
||||
});
|
||||
|
||||
let leftBorderClass = $derived(
|
||||
saveState === 'error' ? 'border-l-2 border-error' : active ? 'border-l-2 border-turquoise' : ''
|
||||
);
|
||||
|
||||
function emitChange() {
|
||||
onTextChange(localText, localMentions);
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
const confirmed = await confirm({
|
||||
title: m.transcription_block_delete_confirm(),
|
||||
destructive: true
|
||||
});
|
||||
if (confirmed) onDeleteClick();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="relative flex overflow-visible rounded border border-line {leftBorderClass}"
|
||||
data-block-id={blockId}
|
||||
>
|
||||
<!-- Turquoise numbered badge — overlaps top-left of card -->
|
||||
<span
|
||||
class="absolute -top-2 -left-2 z-10 flex h-6 w-6 items-center justify-center rounded-full bg-turquoise text-xs font-bold text-turquoise-fg shadow-sm"
|
||||
>
|
||||
{blockNumber}
|
||||
</span>
|
||||
|
||||
<!-- Drag handle (desktop) / Arrow buttons (mobile) -->
|
||||
<div class="flex shrink-0 flex-col items-center justify-center border-r border-line px-1">
|
||||
<!-- Mobile: arrow buttons -->
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-7 w-7 cursor-pointer items-center justify-center rounded text-ink-3 transition-colors hover:bg-muted hover:text-ink disabled:opacity-20 md:hidden"
|
||||
disabled={isFirst}
|
||||
aria-label="Nach oben"
|
||||
onclick={() => onMoveUp?.()}
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 15l7-7 7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Desktop: grip handle (drag target) -->
|
||||
<div
|
||||
class="hidden cursor-grab text-ink-3 transition-colors select-none hover:text-ink active:cursor-grabbing md:block"
|
||||
data-drag-handle
|
||||
aria-label="Ziehen zum Sortieren"
|
||||
>
|
||||
⠿
|
||||
</div>
|
||||
<!-- Mobile: arrow down -->
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-7 w-7 cursor-pointer items-center justify-center rounded text-ink-3 transition-colors hover:bg-muted hover:text-ink disabled:opacity-20 md:hidden"
|
||||
disabled={isLast}
|
||||
aria-label="Nach unten"
|
||||
onclick={() => onMoveDown?.()}
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1 p-4 pl-3">
|
||||
<!-- Header -->
|
||||
<div class="mb-2 flex items-center gap-2">
|
||||
{#if label}
|
||||
<span class="text-xs font-medium tracking-wide text-ink-2 uppercase">
|
||||
{label}
|
||||
</span>
|
||||
{/if}
|
||||
{#if (!text || text.trim() === '') && source === 'MANUAL'}
|
||||
<span class="rounded bg-muted px-1.5 py-0.5 text-xs font-medium text-ink-3"
|
||||
>{m.transcription_block_segmentation_only()}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Editor powered by PersonMentionEditor (Tiptap) for @-mention typeahead -->
|
||||
<PersonMentionEditor
|
||||
bind:value={() => localText,
|
||||
(v) => {
|
||||
localText = v;
|
||||
emitChange();
|
||||
}}
|
||||
bind:mentionedPersons={() => localMentions,
|
||||
(next) => {
|
||||
localMentions = next;
|
||||
emitChange();
|
||||
}}
|
||||
placeholder={m.transcription_block_placeholder()}
|
||||
onfocus={onFocus}
|
||||
onSelectionChange={(text) => (selectedQuote = text)}
|
||||
/>
|
||||
|
||||
{#if selectedQuote}
|
||||
<p class="mt-1 text-xs text-ink-3">{m.transcription_block_quote_hint()}</p>
|
||||
{/if}
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-between border-t border-line pt-2">
|
||||
<div>
|
||||
{#if !hasComments}
|
||||
<button
|
||||
type="button"
|
||||
class="flex cursor-pointer items-center gap-1 text-xs font-medium text-ink-2 transition-colors hover:text-ink"
|
||||
onclick={() => (commentOpen = true)}
|
||||
>
|
||||
<svg
|
||||
class="h-3 w-3"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M2.25 12.76c0 1.6 1.123 2.994 2.707 3.227 1.087.16 2.185.283 3.293.369V21l4.076-4.076a1.526 1.526 0 011.037-.443 48.282 48.282 0 005.68-.494c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z"
|
||||
/>
|
||||
</svg>
|
||||
{m.transcription_block_comment_btn()}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Save state indicator -->
|
||||
{#if saveState === 'saving'}
|
||||
<span class="animate-pulse text-xs text-ink-3">
|
||||
{m.transcription_block_save_saving()}
|
||||
</span>
|
||||
{:else if saveState === 'saved' || saveState === 'fading'}
|
||||
<span
|
||||
class="text-xs text-green-600 transition-opacity duration-300 {saveState === 'fading' ? 'opacity-0' : 'opacity-100'}"
|
||||
>
|
||||
{m.transcription_block_save_saved()} <span class="inline-block">✓</span>
|
||||
</span>
|
||||
{:else if saveState === 'error'}
|
||||
<span class="text-error text-xs">
|
||||
{m.transcription_block_save_error()}
|
||||
<span class="mx-1">—</span>
|
||||
<button
|
||||
type="button"
|
||||
class="underline transition-colors hover:text-ink"
|
||||
onclick={onRetry}
|
||||
>
|
||||
{m.transcription_block_save_retry()}
|
||||
</button>
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<!-- Review toggle -->
|
||||
<button
|
||||
type="button"
|
||||
class="cursor-pointer transition-colors {reviewed ? 'text-turquoise hover:text-turquoise/70' : 'text-ink-3 hover:text-turquoise'}"
|
||||
aria-label={reviewed ? m.transcription_block_unreview() : m.transcription_block_review()}
|
||||
title={reviewed ? m.transcription_block_unreview() : m.transcription_block_review()}
|
||||
onclick={onReviewToggle}
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
fill={reviewed ? 'currentColor' : 'none'}
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Delete button -->
|
||||
<button
|
||||
type="button"
|
||||
class="hover:text-error cursor-pointer text-ink-3 transition-colors"
|
||||
aria-label={m.btn_delete()}
|
||||
onclick={handleDelete}
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Comment thread — list always visible, compose toggled by Kommentieren -->
|
||||
<div class="mt-3">
|
||||
<CommentThread
|
||||
documentId={documentId}
|
||||
blockId={blockId}
|
||||
loadOnMount={true}
|
||||
canComment={canComment}
|
||||
currentUserId={currentUserId}
|
||||
quotedText={selectedQuote}
|
||||
showCompose={commentOpen}
|
||||
onCountChange={(count) => (commentCount = count)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,255 +0,0 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import TranscriptionBlockHost from './TranscriptionBlock.test-host.svelte';
|
||||
import type { ConfirmService } from '$lib/services/confirm.svelte.js';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const BASE_PROPS = {
|
||||
blockId: 'block-1',
|
||||
documentId: 'doc-1',
|
||||
blockNumber: 3,
|
||||
text: 'Liebe Mutter,',
|
||||
label: null,
|
||||
active: false,
|
||||
saveState: 'idle' as const,
|
||||
canComment: true,
|
||||
currentUserId: 'user-1',
|
||||
onTextChange: vi.fn(),
|
||||
onFocus: vi.fn(),
|
||||
onDeleteClick: vi.fn(),
|
||||
onRetry: vi.fn()
|
||||
};
|
||||
|
||||
// Renders TranscriptionBlock via the host, which provides ConfirmService context.
|
||||
function renderBlock(overrides: Record<string, unknown> = {}) {
|
||||
return render(TranscriptionBlockHost, {
|
||||
...BASE_PROPS,
|
||||
onServiceReady: () => {},
|
||||
...overrides
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Rendering ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('TranscriptionBlock — rendering', () => {
|
||||
it('renders block number in turquoise badge', async () => {
|
||||
renderBlock();
|
||||
await expect.element(page.getByText('3')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders text in textarea', async () => {
|
||||
renderBlock();
|
||||
await expect.element(page.getByText('Liebe Mutter,')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders optional label when provided', async () => {
|
||||
renderBlock({ label: 'Anrede' });
|
||||
await expect.element(page.getByText('Anrede')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render label when null', async () => {
|
||||
renderBlock({ label: null });
|
||||
const label = page.getByText('Anrede');
|
||||
await expect.element(label).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Save states ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('TranscriptionBlock — save states', () => {
|
||||
it('shows nothing in idle state', async () => {
|
||||
renderBlock({ saveState: 'idle' });
|
||||
const saving = page.getByText('Speichere...');
|
||||
await expect.element(saving).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "Speichere..." in saving state', async () => {
|
||||
renderBlock({ saveState: 'saving' });
|
||||
await expect.element(page.getByText('Speichere...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "Gespeichert" in saved state', async () => {
|
||||
renderBlock({ saveState: 'saved' });
|
||||
await expect.element(page.getByText(/Gespeichert/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error with retry button in error state', async () => {
|
||||
const onRetry = vi.fn();
|
||||
renderBlock({ saveState: 'error', onRetry });
|
||||
await expect.element(page.getByText('Nicht gespeichert')).toBeInTheDocument();
|
||||
const retryBtn = page.getByText('Erneut versuchen');
|
||||
await expect.element(retryBtn).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Active state ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('TranscriptionBlock — active border', () => {
|
||||
it('has turquoise left border when active', async () => {
|
||||
renderBlock({ active: true });
|
||||
await expect.element(page.getByRole('textbox')).toBeInTheDocument();
|
||||
const block = document.querySelector('[data-block-id="block-1"]')!;
|
||||
expect(block.className).toContain('border-turquoise');
|
||||
});
|
||||
|
||||
it('has error left border when save failed', async () => {
|
||||
renderBlock({ saveState: 'error' });
|
||||
await expect.element(page.getByRole('textbox')).toBeInTheDocument();
|
||||
const block = document.querySelector('[data-block-id="block-1"]')!;
|
||||
expect(block.className).toContain('border-error');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Interactions ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('TranscriptionBlock — interactions', () => {
|
||||
it('calls onTextChange when typing in textarea', async () => {
|
||||
const onTextChange = vi.fn();
|
||||
renderBlock({ onTextChange });
|
||||
const textarea = page.getByRole('textbox');
|
||||
await textarea.fill('Neue Zeile');
|
||||
expect(onTextChange).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onFocus when textarea is focused', async () => {
|
||||
const onFocus = vi.fn();
|
||||
renderBlock({ onFocus });
|
||||
const textarea = page.getByRole('textbox');
|
||||
await textarea.click();
|
||||
expect(onFocus).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows Kommentieren button when no comments exist', async () => {
|
||||
renderBlock();
|
||||
const btn = page.getByText('Kommentieren');
|
||||
await expect.element(btn).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Reorder controls ────────────────────────────────────────────────────────
|
||||
|
||||
describe('TranscriptionBlock — reorder controls', () => {
|
||||
it('shows a drag handle element', async () => {
|
||||
renderBlock();
|
||||
const handle = document.querySelector('[data-drag-handle]');
|
||||
expect(handle).not.toBeNull();
|
||||
});
|
||||
|
||||
it('disables move-up button when isFirst', async () => {
|
||||
renderBlock({ isFirst: true });
|
||||
const btn = page.getByRole('button', { name: 'Nach oben' });
|
||||
await expect.element(btn).toBeDisabled();
|
||||
});
|
||||
|
||||
it('disables move-down button when isLast', async () => {
|
||||
renderBlock({ isLast: true });
|
||||
const btn = page.getByRole('button', { name: 'Nach unten' });
|
||||
await expect.element(btn).toBeDisabled();
|
||||
});
|
||||
|
||||
it('calls onMoveUp when up arrow clicked', async () => {
|
||||
const onMoveUp = vi.fn();
|
||||
renderBlock({ onMoveUp, isFirst: false });
|
||||
const btn = page.getByRole('button', { name: 'Nach oben' });
|
||||
await btn.click();
|
||||
expect(onMoveUp).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onMoveDown when down arrow clicked', async () => {
|
||||
const onMoveDown = vi.fn();
|
||||
renderBlock({ onMoveDown, isLast: false });
|
||||
const btn = page.getByRole('button', { name: 'Nach unten' });
|
||||
await btn.click();
|
||||
expect(onMoveDown).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Delete confirmation ──────────────────────────────────────────────────────
|
||||
|
||||
function renderBlockWithService(overrides: Record<string, unknown> = {}) {
|
||||
let service!: ConfirmService;
|
||||
render(TranscriptionBlockHost, {
|
||||
blockId: 'block-1',
|
||||
documentId: 'doc-1',
|
||||
blockNumber: 3,
|
||||
text: 'Liebe Mutter,',
|
||||
label: null,
|
||||
active: false,
|
||||
saveState: 'idle' as const,
|
||||
canComment: true,
|
||||
currentUserId: 'user-1',
|
||||
onTextChange: vi.fn(),
|
||||
onFocus: vi.fn(),
|
||||
onDeleteClick: vi.fn(),
|
||||
onRetry: vi.fn(),
|
||||
onServiceReady: (s: ConfirmService) => {
|
||||
service = s;
|
||||
},
|
||||
...overrides
|
||||
});
|
||||
return { service };
|
||||
}
|
||||
|
||||
describe('TranscriptionBlock — delete confirmation', () => {
|
||||
it('does not call onDeleteClick when user cancels via confirm service', async () => {
|
||||
const onDeleteClick = vi.fn();
|
||||
const { service } = renderBlockWithService({ onDeleteClick });
|
||||
|
||||
// Use native DOM click so the async handler starts but yields at the await,
|
||||
// letting the test observe service.options and settle the promise.
|
||||
const deleteBtn = document.querySelector('button[aria-label="Löschen"]') as HTMLButtonElement;
|
||||
deleteBtn.click();
|
||||
await vi.waitFor(() => expect(service.options).not.toBeNull());
|
||||
service.settle(false);
|
||||
await vi.waitFor(() => expect(service.options).toBeNull());
|
||||
|
||||
expect(onDeleteClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onDeleteClick when user confirms via confirm service', async () => {
|
||||
const onDeleteClick = vi.fn();
|
||||
const { service } = renderBlockWithService({ onDeleteClick });
|
||||
|
||||
const deleteBtn = document.querySelector('button[aria-label="Löschen"]') as HTMLButtonElement;
|
||||
deleteBtn.click();
|
||||
await vi.waitFor(() => expect(service.options).not.toBeNull());
|
||||
service.settle(true);
|
||||
await vi.waitFor(() => expect(service.options).toBeNull());
|
||||
|
||||
expect(onDeleteClick).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Quote selection ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('TranscriptionBlock — quote selection', () => {
|
||||
it('shows quote hint after text is selected in the editor', async () => {
|
||||
renderBlock({ text: 'Breslau, den 12. August' });
|
||||
await page.getByRole('textbox').click();
|
||||
// Select all text in the contenteditable via the native Selection API.
|
||||
// Tiptap fires selectionUpdate which the block forwards as onSelectionChange.
|
||||
const editorEl = document.querySelector('[role="textbox"]') as HTMLElement;
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(editorEl);
|
||||
const selection = window.getSelection()!;
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
editorEl.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await expect.element(page.getByText(/Zitat/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Fading state ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('TranscriptionBlock — fading save state', () => {
|
||||
it('shows Gespeichert text in fading state (opacity-0 fade-out)', async () => {
|
||||
renderBlock({ saveState: 'fading' });
|
||||
const indicator = page.getByText(/Gespeichert/);
|
||||
await expect.element(indicator).toBeInTheDocument();
|
||||
// The fading class sets opacity-0
|
||||
const el = document.querySelector('.opacity-0');
|
||||
expect(el).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,48 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { provideConfirmService, type ConfirmService } from '$lib/services/confirm.svelte.js';
|
||||
import TranscriptionBlock from './TranscriptionBlock.svelte';
|
||||
import type { PersonMention } from '$lib/types';
|
||||
|
||||
type BlockProps = {
|
||||
blockId: string;
|
||||
documentId: string;
|
||||
blockNumber: number;
|
||||
text: string;
|
||||
mentionedPersons?: PersonMention[];
|
||||
label: string | null;
|
||||
active: boolean;
|
||||
saveState: 'idle' | 'saving' | 'saved' | 'fading' | 'error';
|
||||
canComment: boolean;
|
||||
currentUserId: string | null;
|
||||
onTextChange: (text: string, mentionedPersons: PersonMention[]) => void;
|
||||
onFocus: () => void;
|
||||
onDeleteClick: () => void;
|
||||
onRetry: () => void;
|
||||
onReviewToggle?: () => void;
|
||||
onMoveUp?: () => void;
|
||||
onMoveDown?: () => void;
|
||||
isFirst?: boolean;
|
||||
isLast?: boolean;
|
||||
};
|
||||
|
||||
let {
|
||||
onServiceReady,
|
||||
mentionedPersons = [],
|
||||
reviewed = false,
|
||||
onReviewToggle = () => {},
|
||||
...blockProps
|
||||
}: BlockProps & {
|
||||
onServiceReady: (s: ConfirmService) => void;
|
||||
reviewed?: boolean;
|
||||
} = $props();
|
||||
|
||||
const service = provideConfirmService();
|
||||
onServiceReady(service);
|
||||
</script>
|
||||
|
||||
<TranscriptionBlock
|
||||
{...blockProps}
|
||||
mentionedPersons={mentionedPersons}
|
||||
reviewed={reviewed}
|
||||
onReviewToggle={onReviewToggle}
|
||||
/>
|
||||
@@ -1,85 +0,0 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import { getLocale } from '$lib/paraglide/runtime.js';
|
||||
import { formatMCDate } from '$lib/utils/date.js';
|
||||
import type { components } from '$lib/generated/api';
|
||||
import ContributorStack from './ContributorStack.svelte';
|
||||
|
||||
type TranscriptionQueueItemDTO = components['schemas']['TranscriptionQueueItemDTO'];
|
||||
|
||||
interface Props {
|
||||
docs: TranscriptionQueueItemDTO[];
|
||||
weeklyCount: number;
|
||||
}
|
||||
|
||||
let { docs, weeklyCount }: Props = $props();
|
||||
|
||||
function blockProgress(doc: TranscriptionQueueItemDTO): number {
|
||||
if (doc.annotationCount === 0) return 0;
|
||||
return (doc.textedBlockCount / doc.annotationCount) * 100;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if docs.length > 0}
|
||||
<div class="flex flex-col gap-3 rounded-sm border border-line bg-surface p-4">
|
||||
<div>
|
||||
<h3 class="mb-1 font-sans text-xs font-bold tracking-widest text-ink uppercase">
|
||||
{m.mission_control_transcription_heading()}
|
||||
</h3>
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded-full border border-line bg-surface px-2 py-0.5 text-xs font-semibold text-ink"
|
||||
>
|
||||
{m.mission_control_trans_skill_pill()}
|
||||
</span>
|
||||
{#if weeklyCount > 0}
|
||||
<p class="mt-1 text-xs font-semibold text-ink-2">
|
||||
{m.mission_control_weekly_pulse({ count: weeklyCount })}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
<ul class="space-y-1">
|
||||
{#each docs as doc (doc.id)}
|
||||
<li>
|
||||
<a
|
||||
href="/documents/{doc.id}?task=transcribe"
|
||||
class="flex min-h-[44px] flex-col justify-center rounded px-1 py-2 hover:bg-canvas focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||
>
|
||||
<span class="font-serif text-sm text-ink">{doc.title}</span>
|
||||
{#if doc.documentDate}
|
||||
<span class="mt-0.5 text-xs text-ink-3"
|
||||
>{formatMCDate(doc.documentDate, getLocale())}</span
|
||||
>
|
||||
{/if}
|
||||
{#if doc.textedBlockCount > 0}
|
||||
<div class="mt-1.5 flex items-center gap-2">
|
||||
<span class="shrink-0 text-xs text-ink-3">
|
||||
{m.mission_control_blocks_progress({
|
||||
texted: doc.textedBlockCount,
|
||||
total: doc.annotationCount
|
||||
})}
|
||||
</span>
|
||||
<div class="h-1 flex-1 overflow-hidden rounded-full bg-ink/20" aria-hidden="true">
|
||||
<div
|
||||
class="h-full rounded-full bg-ink transition-all"
|
||||
style="width: {blockProgress(doc).toFixed(0)}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<span class="mt-0.5 text-xs text-ink-3 italic">—</span>
|
||||
{/if}
|
||||
<div class="mt-1">
|
||||
<ContributorStack contributors={doc.contributors} hasMore={doc.hasMoreContributors} />
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="flex min-h-[120px] flex-col items-center justify-center rounded-sm border border-dashed border-line bg-surface/50 p-6 text-center"
|
||||
>
|
||||
<p class="text-xs text-ink-3">{m.mission_control_transcription_empty()}</p>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,85 +0,0 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import TranscriptionColumn from './TranscriptionColumn.svelte';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type TranscriptionQueueItemDTO = components['schemas']['TranscriptionQueueItemDTO'];
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function makeDoc(overrides: Partial<TranscriptionQueueItemDTO> = {}): TranscriptionQueueItemDTO {
|
||||
return {
|
||||
id: 'doc-1',
|
||||
title: 'Test Dokument',
|
||||
annotationCount: 0,
|
||||
textedBlockCount: 0,
|
||||
reviewedBlockCount: 0,
|
||||
contributors: [],
|
||||
hasMoreContributors: false,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe('TranscriptionColumn', () => {
|
||||
it('renders document list when docs are provided', async () => {
|
||||
const doc1 = makeDoc({ id: 'doc-1', title: 'Familienbrief' });
|
||||
const doc2 = makeDoc({ id: 'doc-2', title: 'Tagebuch Eintrag' });
|
||||
|
||||
render(TranscriptionColumn, { props: { docs: [doc1, doc2], weeklyCount: 0 } });
|
||||
|
||||
await expect.element(page.getByText('Familienbrief')).toBeInTheDocument();
|
||||
await expect.element(page.getByText('Tagebuch Eintrag')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders dashed empty state when docs array is empty', async () => {
|
||||
render(TranscriptionColumn, { props: { docs: [], weeklyCount: 0 } });
|
||||
|
||||
await expect
|
||||
.element(page.getByText('Keine Dokumente warten auf Transkription.'))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders progress bar when textedBlockCount > 0', async () => {
|
||||
const doc = makeDoc({
|
||||
id: 'doc-1',
|
||||
title: 'Brief mit Blöcken',
|
||||
annotationCount: 4,
|
||||
textedBlockCount: 2
|
||||
});
|
||||
|
||||
render(TranscriptionColumn, { props: { docs: [doc], weeklyCount: 0 } });
|
||||
|
||||
// The progress text should show "2 / 4 Blöcke"
|
||||
await expect.element(page.getByText('2 / 4 Blöcke')).toBeInTheDocument();
|
||||
|
||||
// A progress bar div should exist (the visual bar)
|
||||
const progressBar = document.querySelector('.h-1.flex-1');
|
||||
expect(progressBar).not.toBeNull();
|
||||
});
|
||||
|
||||
it('renders dash placeholder when textedBlockCount is 0', async () => {
|
||||
const doc = makeDoc({
|
||||
id: 'doc-1',
|
||||
title: 'Brief ohne Blöcke',
|
||||
annotationCount: 3,
|
||||
textedBlockCount: 0
|
||||
});
|
||||
|
||||
render(TranscriptionColumn, { props: { docs: [doc], weeklyCount: 0 } });
|
||||
|
||||
// The italic em-dash placeholder should render
|
||||
const dashEl = document.querySelector('span.italic');
|
||||
expect(dashEl).not.toBeNull();
|
||||
expect(dashEl?.textContent?.trim()).toBe('—');
|
||||
});
|
||||
|
||||
it('links to /documents/{id}?task=transcribe', async () => {
|
||||
const doc = makeDoc({ id: 'xyz-456', title: 'Transkriptions Dokument' });
|
||||
|
||||
render(TranscriptionColumn, { props: { docs: [doc], weeklyCount: 0 } });
|
||||
|
||||
const link = page.getByRole('link', { name: /Transkriptions Dokument/ });
|
||||
await expect.element(link).toHaveAttribute('href', '/documents/xyz-456?task=transcribe');
|
||||
});
|
||||
});
|
||||
@@ -1,322 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import TranscriptionBlock from './TranscriptionBlock.svelte';
|
||||
import OcrTrigger from './OcrTrigger.svelte';
|
||||
import TranscribeCoachEmptyState from './TranscribeCoachEmptyState.svelte';
|
||||
import type { PersonMention, TranscriptionBlockData } from '$lib/types';
|
||||
import { createBlockAutoSave } from '$lib/hooks/useBlockAutoSave.svelte';
|
||||
import { createBlockDragDrop } from '$lib/hooks/useBlockDragDrop.svelte';
|
||||
|
||||
type Props = {
|
||||
documentId: string;
|
||||
blocks: TranscriptionBlockData[];
|
||||
canComment: boolean;
|
||||
currentUserId: string | null;
|
||||
activeAnnotationId?: string | null;
|
||||
storedScriptType?: string;
|
||||
canRunOcr?: boolean;
|
||||
onBlockFocus: (blockId: string) => void;
|
||||
onSaveBlock: (blockId: string, text: string, mentionedPersons: PersonMention[]) => Promise<void>;
|
||||
onDeleteBlock: (blockId: string) => Promise<void>;
|
||||
onReviewToggle: (blockId: string) => Promise<void>;
|
||||
onMarkAllReviewed?: () => Promise<void>;
|
||||
onTriggerOcr?: (scriptType: string, useExistingAnnotations: boolean) => void;
|
||||
canWrite?: boolean;
|
||||
trainingLabels?: string[];
|
||||
onToggleTrainingLabel?: (label: string, enrolled: boolean) => Promise<void>;
|
||||
};
|
||||
|
||||
let {
|
||||
documentId,
|
||||
blocks,
|
||||
canComment,
|
||||
currentUserId,
|
||||
activeAnnotationId = null,
|
||||
storedScriptType = '',
|
||||
canRunOcr = false,
|
||||
onBlockFocus,
|
||||
onSaveBlock,
|
||||
onDeleteBlock,
|
||||
onReviewToggle,
|
||||
onMarkAllReviewed,
|
||||
onTriggerOcr,
|
||||
canWrite = false,
|
||||
trainingLabels = [],
|
||||
onToggleTrainingLabel
|
||||
}: Props = $props();
|
||||
|
||||
let activeBlockId: string | null = $state(null);
|
||||
let localLabels: string[] = $derived.by(() => [...trainingLabels]);
|
||||
let listEl: HTMLElement | null = $state(null);
|
||||
let markingAllReviewed = $state(false);
|
||||
|
||||
const sortedBlocks = $derived([...blocks].sort((a, b) => a.sortOrder - b.sortOrder));
|
||||
const hasBlocks = $derived(blocks.length > 0);
|
||||
const reviewedCount = $derived(blocks.filter((b) => b.reviewed).length);
|
||||
const totalCount = $derived(blocks.length);
|
||||
const reviewProgress = $derived(totalCount > 0 ? (reviewedCount / totalCount) * 100 : 0);
|
||||
const allReviewed = $derived(totalCount > 0 && reviewedCount === totalCount);
|
||||
|
||||
// Sync: when an annotation is clicked on the PDF, activate the corresponding block
|
||||
$effect(() => {
|
||||
if (!activeAnnotationId) return;
|
||||
const block = blocks.find((b) => b.annotationId === activeAnnotationId);
|
||||
if (block) activeBlockId = block.id;
|
||||
});
|
||||
|
||||
async function handleMarkAllReviewed() {
|
||||
if (!onMarkAllReviewed) return;
|
||||
markingAllReviewed = true;
|
||||
try {
|
||||
await onMarkAllReviewed();
|
||||
} finally {
|
||||
markingAllReviewed = false;
|
||||
}
|
||||
}
|
||||
|
||||
const autoSave = createBlockAutoSave({ saveFn: onSaveBlock, documentId });
|
||||
|
||||
const dragDrop = createBlockDragDrop({
|
||||
getSortedBlocks: () => sortedBlocks,
|
||||
onReorder: reorder
|
||||
});
|
||||
|
||||
// Wire listEl to drag-drop module
|
||||
$effect(() => {
|
||||
dragDrop.setListElement(listEl);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
function onBeforeUnload() {
|
||||
autoSave.flushOnUnload();
|
||||
}
|
||||
window.addEventListener('beforeunload', onBeforeUnload);
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', onBeforeUnload);
|
||||
autoSave.destroy();
|
||||
};
|
||||
});
|
||||
|
||||
function handleFocus(blockId: string) {
|
||||
activeBlockId = blockId;
|
||||
onBlockFocus(blockId);
|
||||
}
|
||||
|
||||
function handleDelete(blockId: string) {
|
||||
autoSave.clearBlock(blockId);
|
||||
onDeleteBlock(blockId);
|
||||
}
|
||||
|
||||
async function reorder(newOrder: string[]) {
|
||||
try {
|
||||
const res = await fetch(`/api/documents/${documentId}/transcription-blocks/reorder`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ blockIds: newOrder })
|
||||
});
|
||||
if (!res.ok) return;
|
||||
const updated = await res.json();
|
||||
for (const b of updated) {
|
||||
const existing = blocks.find((x) => x.id === b.id);
|
||||
if (existing) existing.sortOrder = b.sortOrder;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function handleMoveUp(blockId: string) {
|
||||
const sorted = [...sortedBlocks];
|
||||
const idx = sorted.findIndex((b) => b.id === blockId);
|
||||
if (idx <= 0) return;
|
||||
[sorted[idx - 1], sorted[idx]] = [sorted[idx], sorted[idx - 1]];
|
||||
reorder(sorted.map((b) => b.id));
|
||||
}
|
||||
|
||||
function handleMoveDown(blockId: string) {
|
||||
const sorted = [...sortedBlocks];
|
||||
const idx = sorted.findIndex((b) => b.id === blockId);
|
||||
if (idx < 0 || idx >= sorted.length - 1) return;
|
||||
[sorted[idx], sorted[idx + 1]] = [sorted[idx + 1], sorted[idx]];
|
||||
reorder(sorted.map((b) => b.id));
|
||||
}
|
||||
|
||||
async function handleLabelToggle(label: string) {
|
||||
if (!onToggleTrainingLabel) return;
|
||||
const enrolled = !localLabels.includes(label);
|
||||
if (enrolled) {
|
||||
localLabels = [...localLabels, label];
|
||||
} else {
|
||||
localLabels = localLabels.filter((l) => l !== label);
|
||||
}
|
||||
try {
|
||||
await onToggleTrainingLabel(label, enrolled);
|
||||
} catch {
|
||||
localLabels = [...trainingLabels];
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex h-full flex-col overflow-y-auto bg-surface">
|
||||
{#if hasBlocks}
|
||||
<!-- Sticky review progress header -->
|
||||
<div class="sticky top-0 z-10 border-b border-line bg-surface px-4 pt-3 pb-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="font-sans text-xs text-ink-2">
|
||||
<span class="font-semibold text-ink">{reviewedCount} / {totalCount}</span> geprüft
|
||||
</p>
|
||||
{#if onMarkAllReviewed}
|
||||
<button
|
||||
onclick={handleMarkAllReviewed}
|
||||
disabled={allReviewed || markingAllReviewed}
|
||||
title={allReviewed ? 'Alle Blöcke sind bereits als fertig markiert' : undefined}
|
||||
class="flex min-h-[44px] items-center gap-1.5 rounded-sm px-3 font-sans text-xs font-medium text-brand-navy/80 transition-colors hover:text-brand-navy focus-visible:ring-2 focus-visible:ring-brand-navy disabled:opacity-40"
|
||||
>
|
||||
{#if markingAllReviewed}
|
||||
<svg
|
||||
class="h-3.5 w-3.5 animate-spin"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
></path>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg
|
||||
class="h-3.5 w-3.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
{/if}
|
||||
Alle als fertig markieren
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="bg-brand-sand mt-1.5 h-0.5 w-full overflow-hidden rounded-full">
|
||||
<div
|
||||
class="h-full rounded-full bg-brand-mint transition-all duration-300"
|
||||
style="width: {reviewProgress}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="flex flex-col gap-3"
|
||||
bind:this={listEl}
|
||||
onpointermove={dragDrop.handlePointerMove}
|
||||
onpointerup={dragDrop.handlePointerUp}
|
||||
>
|
||||
{#each sortedBlocks as block, i (block.id)}
|
||||
{#if dragDrop.dropTargetIdx === i}
|
||||
<div class="h-1 rounded-full bg-turquoise transition-all"></div>
|
||||
{/if}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
data-block-wrapper
|
||||
onblur={autoSave.handleBlur}
|
||||
onpointerdown={(e) => dragDrop.handleGripDown(e, block.id)}
|
||||
class="relative transition-all duration-150 {dragDrop.draggedBlockId === block.id ? 'z-10 rounded-lg shadow-xl ring-2 ring-turquoise/40' : ''}"
|
||||
style={dragDrop.draggedBlockId === block.id
|
||||
? `transform: translateY(${dragDrop.dragOffsetY}px) scale(1.02); opacity: 0.9;`
|
||||
: ''}
|
||||
>
|
||||
<TranscriptionBlock
|
||||
blockId={block.id}
|
||||
documentId={documentId}
|
||||
blockNumber={i + 1}
|
||||
text={block.text}
|
||||
mentionedPersons={block.mentionedPersons ?? []}
|
||||
label={block.label}
|
||||
active={activeBlockId === block.id}
|
||||
reviewed={block.reviewed ?? false}
|
||||
saveState={autoSave.getSaveState(block.id)}
|
||||
canComment={canComment}
|
||||
currentUserId={currentUserId}
|
||||
onTextChange={(text, mentions) =>
|
||||
autoSave.handleTextChange(block.id, text, mentions)}
|
||||
onFocus={() => handleFocus(block.id)}
|
||||
onDeleteClick={() => handleDelete(block.id)}
|
||||
onRetry={() =>
|
||||
autoSave.handleRetry(block.id, block.text, block.mentionedPersons ?? [])}
|
||||
onReviewToggle={() => onReviewToggle(block.id)}
|
||||
onMoveUp={() => handleMoveUp(block.id)}
|
||||
onMoveDown={() => handleMoveDown(block.id)}
|
||||
isFirst={i === 0}
|
||||
isLast={i === sortedBlocks.length - 1}
|
||||
source={block.source}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if dragDrop.dropTargetIdx === sortedBlocks.length}
|
||||
<div class="h-1 rounded-full bg-turquoise transition-all"></div>
|
||||
{/if}
|
||||
|
||||
<!-- Next block CTA — dashed outline hint -->
|
||||
<div
|
||||
class="flex items-center justify-center rounded border border-dashed border-line px-4 py-5 text-center font-sans text-sm text-ink-3"
|
||||
>
|
||||
{m.transcription_next_block_cta({ number: sortedBlocks.length + 1 })}
|
||||
</div>
|
||||
|
||||
{#if canRunOcr && onTriggerOcr}
|
||||
<div class="mt-6">
|
||||
<p class="mb-3 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.ocr_section_heading()}
|
||||
</p>
|
||||
<div class="max-w-xs">
|
||||
<OcrTrigger
|
||||
blockCount={blocks.length}
|
||||
storedScriptType={storedScriptType}
|
||||
onTrigger={onTriggerOcr}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="p-4">
|
||||
<TranscribeCoachEmptyState />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if canWrite && hasBlocks}
|
||||
<div class="border-t border-line px-4 py-3">
|
||||
<p class="mb-2 font-sans text-xs font-medium text-ink-2">Für Training vormerken</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each [{ label: 'KURRENT_RECOGNITION', display: m.training_chip_kurrent() }, { label: 'KURRENT_SEGMENTATION', display: m.training_chip_segmentation() }] as chip (chip.label)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => handleLabelToggle(chip.label)}
|
||||
class="rounded-full border px-3 py-1 font-sans text-xs font-medium transition-colors {localLabels.includes(chip.label)
|
||||
? 'border-brand-mint bg-brand-mint text-brand-navy'
|
||||
: 'border-line bg-surface text-ink-3 hover:border-brand-mint hover:text-brand-navy'}"
|
||||
>
|
||||
{chip.display}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,357 +0,0 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import TranscriptionEditView from './TranscriptionEditView.svelte';
|
||||
import { createConfirmService, CONFIRM_KEY } from '$lib/services/confirm.svelte.js';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const block1 = {
|
||||
id: 'b1',
|
||||
annotationId: 'a1',
|
||||
documentId: 'doc-1',
|
||||
text: 'Block eins',
|
||||
label: null,
|
||||
sortOrder: 0,
|
||||
version: 0,
|
||||
source: 'MANUAL' as const,
|
||||
reviewed: false,
|
||||
mentionedPersons: []
|
||||
};
|
||||
const block2 = {
|
||||
id: 'b2',
|
||||
annotationId: 'a2',
|
||||
documentId: 'doc-1',
|
||||
text: 'Block zwei',
|
||||
label: null,
|
||||
sortOrder: 1,
|
||||
version: 0,
|
||||
source: 'OCR' as const,
|
||||
reviewed: true,
|
||||
mentionedPersons: []
|
||||
};
|
||||
|
||||
function renderView(overrides: Record<string, unknown> = {}, service = createConfirmService()) {
|
||||
return {
|
||||
...render(TranscriptionEditView, {
|
||||
props: {
|
||||
documentId: 'doc-1',
|
||||
blocks: [block1, block2],
|
||||
canComment: true,
|
||||
currentUserId: 'user-1',
|
||||
onBlockFocus: vi.fn(),
|
||||
onSaveBlock: vi.fn(),
|
||||
onDeleteBlock: vi.fn(),
|
||||
onReviewToggle: vi.fn(),
|
||||
...overrides
|
||||
},
|
||||
context: new Map([[CONFIRM_KEY, service]])
|
||||
}),
|
||||
service
|
||||
};
|
||||
}
|
||||
|
||||
const unreviewedBlock1 = { ...block1, reviewed: false };
|
||||
const unreviewedBlock2 = { ...block2, reviewed: false };
|
||||
const reviewedBlock1 = { ...block1, reviewed: true };
|
||||
const reviewedBlock2 = { ...block2, reviewed: true };
|
||||
|
||||
describe('TranscriptionEditView — rendering', () => {
|
||||
it('renders blocks in sort order', async () => {
|
||||
renderView();
|
||||
const textareas = page.getByRole('textbox').all();
|
||||
expect(textareas.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('shows next-block CTA after block list', async () => {
|
||||
renderView();
|
||||
await expect.element(page.getByText(/Block 3/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows coach card when no blocks', async () => {
|
||||
renderView({ blocks: [] });
|
||||
await expect
|
||||
.element(page.getByRole('heading', { level: 2 }))
|
||||
.toHaveTextContent('Erste Transkription?');
|
||||
});
|
||||
|
||||
it('hides training footer when no blocks', async () => {
|
||||
renderView({ blocks: [], canWrite: true });
|
||||
await expect.element(page.getByText('Für Training vormerken')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows training footer when blocks exist', async () => {
|
||||
renderView({ blocks: [block1], canWrite: true });
|
||||
await expect.element(page.getByText('Für Training vormerken')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TranscriptionEditView — annotation sync', () => {
|
||||
it('activates block matching activeAnnotationId', async () => {
|
||||
renderView({ activeAnnotationId: 'a2' });
|
||||
// Block 2 (annotation a2) should have turquoise border
|
||||
const block = document.querySelector('[data-block-id="b2"]')!;
|
||||
expect(block.className).toContain('border-turquoise');
|
||||
});
|
||||
|
||||
it('does not activate any block when activeAnnotationId is null', async () => {
|
||||
renderView({ activeAnnotationId: null });
|
||||
const block1 = document.querySelector('[data-block-id="b1"]')!;
|
||||
const block2 = document.querySelector('[data-block-id="b2"]')!;
|
||||
expect(block1.className).not.toContain('border-turquoise');
|
||||
expect(block2.className).not.toContain('border-turquoise');
|
||||
});
|
||||
});
|
||||
|
||||
describe('TranscriptionEditView — reorder', () => {
|
||||
it('renders move-up button disabled on first block', async () => {
|
||||
renderView();
|
||||
const upButtons = page.getByRole('button', { name: 'Nach oben' }).all();
|
||||
// First block's up button should be disabled
|
||||
await expect.element(upButtons[0]).toBeDisabled();
|
||||
});
|
||||
|
||||
it('renders move-down button disabled on last block', async () => {
|
||||
renderView();
|
||||
const downButtons = page.getByRole('button', { name: 'Nach unten' }).all();
|
||||
// Last block's down button should be disabled
|
||||
await expect.element(downButtons[downButtons.length - 1]).toBeDisabled();
|
||||
});
|
||||
|
||||
it('has a drag handle on each block', async () => {
|
||||
renderView();
|
||||
const handles = document.querySelectorAll('[data-drag-handle]');
|
||||
expect(handles.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Auto-save debounce ───────────────────────────────────────────────────────
|
||||
|
||||
describe('TranscriptionEditView — auto-save debounce', () => {
|
||||
it('calls onSaveBlock after 1500ms debounce when text changes', async () => {
|
||||
vi.useFakeTimers();
|
||||
const onSaveBlock = vi.fn().mockResolvedValue(undefined);
|
||||
renderView({ onSaveBlock });
|
||||
|
||||
const textarea = page.getByRole('textbox').first();
|
||||
await textarea.fill('Neue Zeile');
|
||||
|
||||
// Not called immediately
|
||||
expect(onSaveBlock).not.toHaveBeenCalled();
|
||||
|
||||
// Advance past debounce
|
||||
vi.advanceTimersByTime(1500);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(onSaveBlock).toHaveBeenCalledWith('b1', 'Neue Zeile', []);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('passes the block mentionedPersons array as the 3rd save argument', async () => {
|
||||
vi.useFakeTimers();
|
||||
const onSaveBlock = vi.fn().mockResolvedValue(undefined);
|
||||
const blockWithMention = {
|
||||
...block1,
|
||||
mentionedPersons: [{ personId: 'p-aug', displayName: 'Auguste Raddatz' }]
|
||||
};
|
||||
renderView({ blocks: [blockWithMention], onSaveBlock });
|
||||
|
||||
const textarea = page.getByRole('textbox').first();
|
||||
await textarea.fill('Hallo @Auguste Raddatz');
|
||||
|
||||
vi.advanceTimersByTime(1500);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(onSaveBlock).toHaveBeenCalledWith('b1', 'Hallo @Auguste Raddatz', [
|
||||
{ personId: 'p-aug', displayName: 'Auguste Raddatz' }
|
||||
]);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('resets debounce timer on rapid successive changes', async () => {
|
||||
vi.useFakeTimers();
|
||||
const onSaveBlock = vi.fn().mockResolvedValue(undefined);
|
||||
renderView({ onSaveBlock });
|
||||
|
||||
const textarea = page.getByRole('textbox').first();
|
||||
await textarea.fill('First');
|
||||
vi.advanceTimersByTime(500);
|
||||
|
||||
await textarea.fill('Second');
|
||||
vi.advanceTimersByTime(500);
|
||||
|
||||
// 1000ms elapsed since first change — should not have saved yet
|
||||
expect(onSaveBlock).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(1000);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
// Only one save with the final value
|
||||
expect(onSaveBlock).toHaveBeenCalledTimes(1);
|
||||
expect(onSaveBlock).toHaveBeenCalledWith('b1', 'Second', []);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Save state transitions ───────────────────────────────────────────────────
|
||||
|
||||
describe('TranscriptionEditView — save state indicators', () => {
|
||||
it('shows saving indicator while onSaveBlock is in-flight', async () => {
|
||||
vi.useFakeTimers();
|
||||
let resolveSave!: () => void;
|
||||
const onSaveBlock = vi.fn().mockReturnValue(new Promise<void>((r) => (resolveSave = r)));
|
||||
renderView({ onSaveBlock });
|
||||
|
||||
await page.getByRole('textbox').first().fill('Hello');
|
||||
vi.advanceTimersByTime(1500);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
await expect.element(page.getByText('Speichere...')).toBeInTheDocument();
|
||||
|
||||
resolveSave();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('shows error state when onSaveBlock rejects', async () => {
|
||||
vi.useFakeTimers();
|
||||
const onSaveBlock = vi.fn().mockRejectedValue(new Error('network'));
|
||||
renderView({ onSaveBlock });
|
||||
|
||||
await page.getByRole('textbox').first().fill('Fails');
|
||||
vi.advanceTimersByTime(1500);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
await expect.element(page.getByText('Nicht gespeichert')).toBeInTheDocument();
|
||||
await expect.element(page.getByText('Erneut versuchen')).toBeInTheDocument();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Flush on blur ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('TranscriptionEditView — flush on blur', () => {
|
||||
it('flushes pending save immediately on textarea blur before debounce expires', async () => {
|
||||
vi.useFakeTimers();
|
||||
const onSaveBlock = vi.fn().mockResolvedValue(undefined);
|
||||
renderView({ onSaveBlock });
|
||||
|
||||
const textarea = page.getByRole('textbox').first();
|
||||
await textarea.fill('Blur text');
|
||||
|
||||
// Blur before 1500ms debounce fires — locator.blur() not available, use native DOM
|
||||
const el = document.querySelector('textarea') as HTMLTextAreaElement;
|
||||
el.dispatchEvent(new FocusEvent('blur', { bubbles: true }));
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
expect(onSaveBlock).toHaveBeenCalledWith('b1', 'Blur text', []);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── onDeleteBlock callback ───────────────────────────────────────────────────
|
||||
|
||||
describe('TranscriptionEditView — delete block', () => {
|
||||
it('calls onDeleteBlock with correct blockId when delete is confirmed', async () => {
|
||||
const onDeleteBlock = vi.fn().mockResolvedValue(undefined);
|
||||
const { service } = renderView({ onDeleteBlock });
|
||||
|
||||
const deleteBtn = document.querySelector('button[aria-label="Löschen"]') as HTMLButtonElement;
|
||||
deleteBtn.click();
|
||||
await vi.waitFor(() => expect(service.options).not.toBeNull());
|
||||
service.settle(true);
|
||||
await vi.waitFor(() => expect(service.options).toBeNull());
|
||||
|
||||
expect(onDeleteBlock).toHaveBeenCalledWith('b1');
|
||||
});
|
||||
|
||||
it('does not call onDeleteBlock when deletion is cancelled', async () => {
|
||||
const onDeleteBlock = vi.fn();
|
||||
const { service } = renderView({ onDeleteBlock });
|
||||
|
||||
const deleteBtn = document.querySelector('button[aria-label="Löschen"]') as HTMLButtonElement;
|
||||
deleteBtn.click();
|
||||
await vi.waitFor(() => expect(service.options).not.toBeNull());
|
||||
service.settle(false);
|
||||
await vi.waitFor(() => expect(service.options).toBeNull());
|
||||
|
||||
expect(onDeleteBlock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Review progress counter ──────────────────────────────────────────────────
|
||||
|
||||
describe('TranscriptionEditView — review progress counter', () => {
|
||||
it('shows reviewed count and total when blocks exist', async () => {
|
||||
// block1: reviewed=false, block2: reviewed=true → "1 / 2 geprüft"
|
||||
renderView();
|
||||
await expect.element(page.getByText(/1 \/ 2 geprüft/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows 0 reviewed when no blocks are reviewed', async () => {
|
||||
renderView({ blocks: [block1] }); // block1.reviewed = false
|
||||
await expect.element(page.getByText(/0 \/ 1 geprüft/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show progress counter when there are no blocks', async () => {
|
||||
renderView({ blocks: [] });
|
||||
await expect.element(page.getByText(/geprüft/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Bulk mark all as reviewed ────────────────────────────────────────────────
|
||||
|
||||
describe('TranscriptionEditView — mark all reviewed', () => {
|
||||
it('shows "Alle als fertig markieren" button when onMarkAllReviewed is provided and blocks are unreviewed', async () => {
|
||||
renderView({
|
||||
blocks: [unreviewedBlock1, unreviewedBlock2],
|
||||
onMarkAllReviewed: vi.fn().mockResolvedValue(undefined)
|
||||
});
|
||||
await expect
|
||||
.element(page.getByRole('button', { name: /Alle als fertig markieren/ }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show "Alle als fertig markieren" button when onMarkAllReviewed is not provided', async () => {
|
||||
renderView({ blocks: [unreviewedBlock1, unreviewedBlock2] });
|
||||
await expect
|
||||
.element(page.getByRole('button', { name: /Alle als fertig markieren/ }))
|
||||
.not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables button when all blocks are already reviewed', async () => {
|
||||
renderView({
|
||||
blocks: [reviewedBlock1, reviewedBlock2],
|
||||
onMarkAllReviewed: vi.fn().mockResolvedValue(undefined)
|
||||
});
|
||||
await expect
|
||||
.element(page.getByRole('button', { name: /Alle als fertig markieren/ }))
|
||||
.toBeDisabled();
|
||||
});
|
||||
|
||||
it('calls onMarkAllReviewed exactly once when button is clicked', async () => {
|
||||
const onMarkAllReviewed = vi.fn().mockResolvedValue(undefined);
|
||||
renderView({
|
||||
blocks: [unreviewedBlock1, unreviewedBlock2],
|
||||
onMarkAllReviewed
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: /Alle als fertig markieren/ }).click();
|
||||
await vi.waitFor(() => expect(onMarkAllReviewed).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it('disables button while operation is in-flight', async () => {
|
||||
let resolveMarkAll!: () => void;
|
||||
const onMarkAllReviewed = vi
|
||||
.fn()
|
||||
.mockReturnValue(new Promise<void>((r) => (resolveMarkAll = r)));
|
||||
renderView({
|
||||
blocks: [unreviewedBlock1, unreviewedBlock2],
|
||||
onMarkAllReviewed
|
||||
});
|
||||
|
||||
const btn = page.getByRole('button', { name: /Alle als fertig markieren/ });
|
||||
await btn.click();
|
||||
await expect.element(btn).toBeDisabled();
|
||||
resolveMarkAll();
|
||||
});
|
||||
});
|
||||
@@ -1,102 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { getLocale } from '$lib/paraglide/runtime.js';
|
||||
import HelpPopover from './HelpPopover.svelte';
|
||||
|
||||
type Props = {
|
||||
mode: 'read' | 'edit';
|
||||
hasBlocks: boolean;
|
||||
blockCount: number;
|
||||
lastEditedAt: string | null;
|
||||
onModeChange: (mode: 'read' | 'edit') => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
let { mode, hasBlocks, blockCount, lastEditedAt, onModeChange, onClose }: Props = $props();
|
||||
|
||||
const formattedDate = $derived(
|
||||
lastEditedAt
|
||||
? new Intl.DateTimeFormat(getLocale(), {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric'
|
||||
}).format(new Date(lastEditedAt))
|
||||
: null
|
||||
);
|
||||
|
||||
function handleReadClick() {
|
||||
if (hasBlocks) {
|
||||
onModeChange('read');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex h-[44px] items-center justify-between border-b border-line bg-surface px-3 font-sans"
|
||||
>
|
||||
<!-- Segmented toggle + help chip -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="flex h-9 items-center rounded-full border border-line bg-muted p-0.5 md:h-7">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="mode-read"
|
||||
aria-disabled={!hasBlocks}
|
||||
onclick={handleReadClick}
|
||||
class="h-full rounded-full px-3 text-xs font-semibold transition-colors {mode === 'read'
|
||||
? 'bg-primary text-primary-fg'
|
||||
: 'text-ink-2 hover:text-ink'}"
|
||||
style:opacity={!hasBlocks ? '0.35' : undefined}
|
||||
>
|
||||
{m.mode_read()}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="mode-edit"
|
||||
onclick={() => onModeChange('edit')}
|
||||
class="h-full rounded-full px-3 text-xs font-semibold transition-colors {mode === 'edit'
|
||||
? 'bg-primary text-primary-fg'
|
||||
: 'text-ink-2 hover:text-ink'}"
|
||||
>
|
||||
<span class="md:hidden">{m.mode_edit_short()}</span>
|
||||
<span class="hidden md:inline">{m.mode_edit()}</span>
|
||||
</button>
|
||||
</div>
|
||||
<HelpPopover label={m.transcription_mode_help_label()}>
|
||||
<p class="text-xs leading-relaxed">{m.transcription_mode_help_body()}</p>
|
||||
</HelpPopover>
|
||||
</div>
|
||||
|
||||
<!-- Status line (hidden on mobile to save space) -->
|
||||
<p class="hidden text-xs text-ink-2 md:block">
|
||||
{#if blockCount === 1}
|
||||
{m.transcription_status_section()}
|
||||
{:else}
|
||||
{m.transcription_status_sections({ count: blockCount })}
|
||||
{/if}
|
||||
{#if formattedDate}
|
||||
<span class="ml-1"
|
||||
>· {m.transcription_status_last_edited({ time: formattedDate })}</span
|
||||
>
|
||||
{/if}
|
||||
</p>
|
||||
|
||||
<!-- Close button -->
|
||||
<button
|
||||
type="button"
|
||||
data-testid="panel-close"
|
||||
onclick={onClose}
|
||||
aria-label={m.transcription_panel_close()}
|
||||
class="flex h-11 w-11 items-center justify-center rounded text-ink-2 transition-colors hover:bg-muted hover:text-ink"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
@@ -1,182 +0,0 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import TranscriptionPanelHeader from './TranscriptionPanelHeader.svelte';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('TranscriptionPanelHeader', () => {
|
||||
it('should render Lesen and Bearbeiten buttons', async () => {
|
||||
render(TranscriptionPanelHeader, {
|
||||
mode: 'read',
|
||||
hasBlocks: true,
|
||||
blockCount: 3,
|
||||
lastEditedAt: null,
|
||||
onModeChange: () => {},
|
||||
onClose: () => {}
|
||||
});
|
||||
|
||||
await expect.element(page.getByText('Lesen')).toBeInTheDocument();
|
||||
await expect.element(page.getByText('Bearbeiten')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should disable Lesen button when hasBlocks is false', async () => {
|
||||
render(TranscriptionPanelHeader, {
|
||||
mode: 'edit',
|
||||
hasBlocks: false,
|
||||
blockCount: 0,
|
||||
lastEditedAt: null,
|
||||
onModeChange: () => {},
|
||||
onClose: () => {}
|
||||
});
|
||||
|
||||
const lesenBtn = document.querySelector('[data-testid="mode-read"]') as HTMLButtonElement;
|
||||
expect(lesenBtn.getAttribute('aria-disabled')).toBe('true');
|
||||
});
|
||||
|
||||
it('should call onModeChange when clicking Bearbeiten', async () => {
|
||||
const onModeChange = vi.fn();
|
||||
render(TranscriptionPanelHeader, {
|
||||
mode: 'read',
|
||||
hasBlocks: true,
|
||||
blockCount: 3,
|
||||
lastEditedAt: null,
|
||||
onModeChange,
|
||||
onClose: () => {}
|
||||
});
|
||||
|
||||
const editBtn = document.querySelector('[data-testid="mode-edit"]')!;
|
||||
editBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
expect(onModeChange).toHaveBeenCalledWith('edit');
|
||||
});
|
||||
|
||||
it('should not call onModeChange when clicking disabled Lesen', async () => {
|
||||
const onModeChange = vi.fn();
|
||||
render(TranscriptionPanelHeader, {
|
||||
mode: 'edit',
|
||||
hasBlocks: false,
|
||||
blockCount: 0,
|
||||
lastEditedAt: null,
|
||||
onModeChange,
|
||||
onClose: () => {}
|
||||
});
|
||||
|
||||
const readBtn = document.querySelector('[data-testid="mode-read"]')!;
|
||||
readBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
expect(onModeChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call onClose when clicking close button', async () => {
|
||||
const onClose = vi.fn();
|
||||
render(TranscriptionPanelHeader, {
|
||||
mode: 'read',
|
||||
hasBlocks: true,
|
||||
blockCount: 3,
|
||||
lastEditedAt: null,
|
||||
onModeChange: () => {},
|
||||
onClose
|
||||
});
|
||||
|
||||
const closeBtn = document.querySelector('[data-testid="panel-close"]')!;
|
||||
closeBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should show singular block count for 1 block', async () => {
|
||||
render(TranscriptionPanelHeader, {
|
||||
mode: 'read',
|
||||
hasBlocks: true,
|
||||
blockCount: 1,
|
||||
lastEditedAt: null,
|
||||
onModeChange: () => {},
|
||||
onClose: () => {}
|
||||
});
|
||||
|
||||
await expect.element(page.getByText('1 Abschnitt')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show plural block count for multiple blocks', async () => {
|
||||
render(TranscriptionPanelHeader, {
|
||||
mode: 'read',
|
||||
hasBlocks: true,
|
||||
blockCount: 5,
|
||||
lastEditedAt: null,
|
||||
onModeChange: () => {},
|
||||
onClose: () => {}
|
||||
});
|
||||
|
||||
await expect.element(page.getByText('5 Abschnitte')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show "0 Abschnitte" when blockCount is 0', async () => {
|
||||
render(TranscriptionPanelHeader, {
|
||||
mode: 'edit',
|
||||
hasBlocks: false,
|
||||
blockCount: 0,
|
||||
lastEditedAt: null,
|
||||
onModeChange: () => {},
|
||||
onClose: () => {}
|
||||
});
|
||||
|
||||
await expect.element(page.getByText('0 Abschnitte')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have close button with 44px touch target classes', async () => {
|
||||
render(TranscriptionPanelHeader, {
|
||||
mode: 'read',
|
||||
hasBlocks: true,
|
||||
blockCount: 3,
|
||||
lastEditedAt: null,
|
||||
onModeChange: () => {},
|
||||
onClose: () => {}
|
||||
});
|
||||
|
||||
const closeBtn = document.querySelector('[data-testid="panel-close"]') as HTMLElement;
|
||||
expect(closeBtn.classList.contains('h-11')).toBe(true);
|
||||
expect(closeBtn.classList.contains('w-11')).toBe(true);
|
||||
});
|
||||
|
||||
it('should show formatted date when lastEditedAt is provided', async () => {
|
||||
render(TranscriptionPanelHeader, {
|
||||
mode: 'read',
|
||||
hasBlocks: true,
|
||||
blockCount: 3,
|
||||
lastEditedAt: '2026-04-07T10:00:00Z',
|
||||
onModeChange: () => {},
|
||||
onClose: () => {}
|
||||
});
|
||||
|
||||
const statusText = document.querySelector('.hidden.md\\:block');
|
||||
expect(statusText).not.toBeNull();
|
||||
expect(statusText!.textContent).toContain('2026');
|
||||
});
|
||||
|
||||
it('renders a (?) help chip next to the Read/Edit toggle', async () => {
|
||||
render(TranscriptionPanelHeader, {
|
||||
mode: 'read',
|
||||
hasBlocks: true,
|
||||
blockCount: 3,
|
||||
lastEditedAt: null,
|
||||
onModeChange: () => {},
|
||||
onClose: () => {}
|
||||
});
|
||||
|
||||
const helpBtn = document.querySelector('button[aria-expanded]') as HTMLButtonElement;
|
||||
expect(helpBtn).not.toBeNull();
|
||||
});
|
||||
|
||||
it('opens a help popover with mode explanation when the chip is clicked', async () => {
|
||||
render(TranscriptionPanelHeader, {
|
||||
mode: 'read',
|
||||
hasBlocks: true,
|
||||
blockCount: 3,
|
||||
lastEditedAt: null,
|
||||
onModeChange: () => {},
|
||||
onClose: () => {}
|
||||
});
|
||||
|
||||
const helpBtn = document.querySelector('button[aria-expanded]') as HTMLButtonElement;
|
||||
helpBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
await vi.waitFor(() => expect(document.querySelector('[role="region"]')).not.toBeNull());
|
||||
});
|
||||
});
|
||||
@@ -1,276 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { TranscriptionBlockData } from '$lib/types';
|
||||
import type { components } from '$lib/generated/api';
|
||||
import { splitByMarkers } from '$lib/utils/transcriptionMarkers';
|
||||
import {
|
||||
renderTranscriptionBody,
|
||||
type SafeHtml,
|
||||
PERSON_MENTION_SELECTOR
|
||||
} from '$lib/utils/mention';
|
||||
import { computeHoverCardPosition } from '$lib/utils/hoverCardPosition';
|
||||
import PersonHoverCard from './PersonHoverCard.svelte';
|
||||
import type { HoverData, LoadState } from '$lib/types/personHoverCard';
|
||||
import { goto } from '$app/navigation';
|
||||
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
type Person = components['schemas']['Person'];
|
||||
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
||||
|
||||
interface Props {
|
||||
blocks: TranscriptionBlockData[];
|
||||
onParagraphClick: (annotationId: string) => void;
|
||||
highlightBlockId?: string | null;
|
||||
}
|
||||
|
||||
let { blocks, onParagraphClick, highlightBlockId = null }: Props = $props();
|
||||
|
||||
let sorted = $derived([...blocks].sort((a, b) => a.sortOrder - b.sortOrder));
|
||||
|
||||
// Per-component (per-mount) in-memory cache: a sweep across 20 mentions of the
|
||||
// same person must not fire 20 backend calls (B15.5). The Promise<HoverData | null>
|
||||
// shape lets simultaneous hovers share the same in-flight fetch.
|
||||
//
|
||||
// Trade-off: closing and re-opening the transcription panel rebuilds this cache
|
||||
// (Elicit OQ-372-02). That's intentional — staleness from another tab deleting
|
||||
// a person is rare in this read-only view, and a per-document/global cache would
|
||||
// complicate invalidation. If user reports on stale cards accumulate, revisit.
|
||||
const hoverCache = new SvelteMap<string, Promise<HoverData | null>>();
|
||||
const deletedPersonIds = new SvelteSet<string>();
|
||||
|
||||
let activeCard: {
|
||||
personId: string;
|
||||
cardId: string;
|
||||
state: LoadState;
|
||||
position: { top: number; left: number };
|
||||
} | null = $state(null);
|
||||
|
||||
// Compose splitByMarkers with renderTranscriptionBody. Markers are pre-rendered
|
||||
// as <em data-marker> tags; text segments run through HTML-escaping + mention
|
||||
// substitution. The two are concatenated to preserve marker boundaries — markers
|
||||
// never end up nested inside an anchor (Felix #5324 B19b).
|
||||
function renderBlockHtml(block: TranscriptionBlockData): SafeHtml {
|
||||
return splitByMarkers(block.text)
|
||||
.map((segment) => {
|
||||
if (segment.type === 'marker') {
|
||||
// splitByMarkers only emits the literal markers [unleserlich] and [...],
|
||||
// no user input — safe to embed directly. Wrap in SafeHtml to satisfy
|
||||
// the brand contract.
|
||||
return `<em data-marker class="text-ink-2 italic">${segment.text}</em>` as SafeHtml;
|
||||
}
|
||||
return renderTranscriptionBody(segment.text, block.mentionedPersons ?? []);
|
||||
})
|
||||
.join('') as SafeHtml;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches person + relationships from the backend. 404 returns null
|
||||
* (deleted person — caller marks the link as tombstoned). Any other
|
||||
* non-OK response throws so the caller can render the error state.
|
||||
*/
|
||||
async function loadHoverData(personId: string): Promise<HoverData | null> {
|
||||
const personRes = await fetch(`/api/persons/${personId}`);
|
||||
if (personRes.status === 404) return null;
|
||||
if (!personRes.ok) throw new Error(`person fetch failed: ${personRes.status}`);
|
||||
const person = (await personRes.json()) as Person;
|
||||
|
||||
const relRes = await fetch(`/api/persons/${personId}/relationships`);
|
||||
const relationships: RelationshipDTO[] = relRes.ok
|
||||
? ((await relRes.json()) as RelationshipDTO[])
|
||||
: [];
|
||||
return { person, relationships };
|
||||
}
|
||||
|
||||
/** Cache wrapper around `loadHoverData` — first hover fires the fetch, all
|
||||
* subsequent hovers (and concurrent in-flight ones) share the same Promise. */
|
||||
function getOrFetchHoverData(personId: string): Promise<HoverData | null> {
|
||||
const cached = hoverCache.get(personId);
|
||||
if (cached) return cached;
|
||||
const promise = loadHoverData(personId);
|
||||
hoverCache.set(personId, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
function currentViewport() {
|
||||
return {
|
||||
viewportWidth: window.innerWidth,
|
||||
viewportHeight: window.innerHeight
|
||||
};
|
||||
}
|
||||
|
||||
let closeTimer = $state<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
function scheduleCardClose() {
|
||||
// 150ms: long enough for pointer movement from mention to card, short enough
|
||||
// to feel responsive. Matches the Radix/shadcn hover card delay.
|
||||
closeTimer = setTimeout(() => {
|
||||
activeCard = null;
|
||||
closeTimer = null;
|
||||
}, 150);
|
||||
}
|
||||
|
||||
function cancelCardClose() {
|
||||
if (closeTimer !== null) {
|
||||
clearTimeout(closeTimer);
|
||||
closeTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMentionEnter(event: Event) {
|
||||
cancelCardClose();
|
||||
const link = event.target as HTMLAnchorElement;
|
||||
const personId = link.dataset.personId;
|
||||
if (!personId) return;
|
||||
if (deletedPersonIds.has(personId)) return;
|
||||
|
||||
const cardId = `person-hover-card-${personId}`;
|
||||
link.setAttribute('aria-describedby', cardId);
|
||||
|
||||
const rect = link.getBoundingClientRect();
|
||||
const position = computeHoverCardPosition(rect, currentViewport());
|
||||
|
||||
activeCard = { personId, cardId, position, state: { status: 'loading' } };
|
||||
|
||||
try {
|
||||
const data = await getOrFetchHoverData(personId);
|
||||
// Bail if a different mention is now active
|
||||
if (!activeCard || activeCard.personId !== personId) return;
|
||||
|
||||
if (data === null) {
|
||||
deletedPersonIds.add(personId);
|
||||
link.setAttribute('data-person-deleted', 'true');
|
||||
activeCard = null;
|
||||
return;
|
||||
}
|
||||
|
||||
activeCard = {
|
||||
personId,
|
||||
cardId,
|
||||
position,
|
||||
state: { status: 'loaded', person: data.person, relationships: data.relationships }
|
||||
};
|
||||
} catch {
|
||||
if (!activeCard || activeCard.personId !== personId) return;
|
||||
activeCard = { personId, cardId, position, state: { status: 'error' } };
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleMentionLeave(event: Event) {
|
||||
const link = event.target as HTMLAnchorElement;
|
||||
link.removeAttribute('aria-describedby');
|
||||
scheduleCardClose();
|
||||
}
|
||||
|
||||
/**
|
||||
* Modified clicks (ctrl/meta/shift/alt) and middle-clicks must fall through to
|
||||
* the browser's default anchor behaviour so users can open the person page in
|
||||
* a new tab/window. Felix #7. Only the plain primary-button click navigates
|
||||
* via SPA goto().
|
||||
*/
|
||||
function isPlainPrimaryClick(event: MouseEvent): boolean {
|
||||
return event.button === 0 && !event.ctrlKey && !event.metaKey && !event.shiftKey && !event.altKey;
|
||||
}
|
||||
|
||||
async function handleMentionClick(event: MouseEvent) {
|
||||
if (!isPlainPrimaryClick(event)) return;
|
||||
const link = event.target as HTMLAnchorElement;
|
||||
const personId = link.dataset.personId;
|
||||
if (!personId) return;
|
||||
if (deletedPersonIds.has(personId)) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
await goto(`/persons/${personId}`);
|
||||
}
|
||||
|
||||
// Attach delegated event listeners on each rendered block. Using {@html ...}
|
||||
// for the body means we cannot bind events declaratively to the injected
|
||||
// anchors, so we hook up listeners via a Svelte action when the wrapper mounts.
|
||||
//
|
||||
// Keyboard parity (Leonie FINDING-01, WCAG 2.1.1): focusin/focusout mirror
|
||||
// mouseenter/mouseleave so users tabbing through transcribed text get the
|
||||
// same preview affordance.
|
||||
function attachMentionHandlers(node: HTMLElement) {
|
||||
function onEnter(e: Event) {
|
||||
const t = e.target as HTMLElement;
|
||||
if (t.matches?.(PERSON_MENTION_SELECTOR)) handleMentionEnter(e);
|
||||
}
|
||||
function onLeave(e: Event) {
|
||||
const t = e.target as HTMLElement;
|
||||
if (t.matches?.(PERSON_MENTION_SELECTOR)) scheduleMentionLeave(e);
|
||||
}
|
||||
function onClick(e: MouseEvent) {
|
||||
const t = e.target as HTMLElement;
|
||||
if (t.matches?.(PERSON_MENTION_SELECTOR)) handleMentionClick(e);
|
||||
}
|
||||
// mouseenter does not bubble — capture it.
|
||||
node.addEventListener('mouseenter', onEnter, true);
|
||||
node.addEventListener('mouseleave', onLeave, true);
|
||||
// focusin/focusout do bubble — no capture phase needed.
|
||||
node.addEventListener('focusin', onEnter);
|
||||
node.addEventListener('focusout', onLeave);
|
||||
node.addEventListener('click', onClick);
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
node.removeEventListener('mouseenter', onEnter, true);
|
||||
node.removeEventListener('mouseleave', onLeave, true);
|
||||
node.removeEventListener('focusin', onEnter);
|
||||
node.removeEventListener('focusout', onLeave);
|
||||
node.removeEventListener('click', onClick);
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<article class="px-6 py-8" use:attachMentionHandlers>
|
||||
{#each sorted as block (block.id)}
|
||||
<div
|
||||
class="-mx-2 mb-6 cursor-pointer rounded-sm px-2 py-1 font-serif text-[16px] leading-[1.85] text-ink transition-colors hover:bg-turquoise/10"
|
||||
class:flash-highlight={highlightBlockId === block.id}
|
||||
data-block-id={block.id}
|
||||
onclick={() => onParagraphClick(block.annotationId)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') onParagraphClick(block.annotationId);
|
||||
}}
|
||||
>
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -- renderTranscriptionBody escapes all HTML before injecting mention links; mirrors CommentMessage.svelte -->
|
||||
{@html renderBlockHtml(block)}
|
||||
</div>
|
||||
{/each}
|
||||
</article>
|
||||
|
||||
{#if activeCard}
|
||||
<PersonHoverCard
|
||||
personId={activeCard.personId}
|
||||
cardId={activeCard.cardId}
|
||||
position={activeCard.position}
|
||||
state={activeCard.state}
|
||||
onmouseenter={cancelCardClose}
|
||||
onmouseleave={() => { activeCard = null; }}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
@keyframes flash {
|
||||
0% {
|
||||
background-color: color-mix(in srgb, var(--color-turquoise) 18%, transparent);
|
||||
}
|
||||
100% {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.flash-highlight {
|
||||
animation: flash 1.2s ease-out;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.flash-highlight {
|
||||
animation: none;
|
||||
background-color: color-mix(in srgb, var(--color-turquoise) 18%, transparent);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,170 +0,0 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import TranscriptionReadView from './TranscriptionReadView.svelte';
|
||||
import type { TranscriptionBlockData } from '$lib/types';
|
||||
|
||||
const PERSON_ID = '11111111-0000-0000-0000-000000000001';
|
||||
|
||||
const block: TranscriptionBlockData = {
|
||||
id: 'b1',
|
||||
annotationId: 'a1',
|
||||
documentId: 'd1',
|
||||
text: '@Auguste',
|
||||
label: null,
|
||||
sortOrder: 0,
|
||||
version: 1,
|
||||
source: 'MANUAL',
|
||||
reviewed: false,
|
||||
mentionedPersons: [{ personId: PERSON_ID, displayName: 'Auguste' }]
|
||||
};
|
||||
|
||||
function mockPersonFetch() {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockImplementation((url: string) => {
|
||||
if (url.includes('/relationships')) {
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve([]) });
|
||||
}
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
id: PERSON_ID,
|
||||
firstName: 'Auguste',
|
||||
lastName: 'Raddatz',
|
||||
displayName: 'Auguste Raddatz'
|
||||
})
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function getMentionLink(): HTMLAnchorElement {
|
||||
return document.querySelector(
|
||||
`a.person-mention[data-person-id="${PERSON_ID}"]`
|
||||
) as HTMLAnchorElement;
|
||||
}
|
||||
|
||||
function getHoverCard(): HTMLElement | null {
|
||||
return document.querySelector('[data-testid="person-hover-card"]');
|
||||
}
|
||||
|
||||
/** Hover a mention and wait until the loaded card content is in the DOM. */
|
||||
async function showCard(): Promise<void> {
|
||||
getMentionLink().dispatchEvent(new MouseEvent('mouseenter', { bubbles: false }));
|
||||
await vi.waitFor(() => {
|
||||
expect(document.querySelector('[data-testid="person-hover-card-content"]')).not.toBeNull();
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
// ─── Mouse timer behavior ──────────────────────────────────────────────────────
|
||||
|
||||
describe('TranscriptionReadView — hover card mouse timer', () => {
|
||||
it('keeps the card open when mouse moves from mention to card within 150ms', async () => {
|
||||
mockPersonFetch();
|
||||
render(TranscriptionReadView, { blocks: [block], onParagraphClick: () => {} });
|
||||
|
||||
await showCard();
|
||||
|
||||
// Leave mention — starts 150ms close timer
|
||||
getMentionLink().dispatchEvent(new MouseEvent('mouseleave', { bubbles: false }));
|
||||
|
||||
// Enter card before 150ms — cancels timer
|
||||
getHoverCard()!.dispatchEvent(new MouseEvent('mouseenter'));
|
||||
|
||||
// Wait past the original 150ms window
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
|
||||
expect(getHoverCard()).not.toBeNull();
|
||||
});
|
||||
|
||||
it('closes the card immediately when mouse leaves the card (no timer)', async () => {
|
||||
mockPersonFetch();
|
||||
render(TranscriptionReadView, { blocks: [block], onParagraphClick: () => {} });
|
||||
|
||||
await showCard();
|
||||
|
||||
// Leave card — activeCard = null immediately, no timer
|
||||
getHoverCard()!.dispatchEvent(new MouseEvent('mouseleave'));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(getHoverCard()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('cancels a pending close when mouse re-enters a mention', async () => {
|
||||
mockPersonFetch();
|
||||
render(TranscriptionReadView, { blocks: [block], onParagraphClick: () => {} });
|
||||
|
||||
await showCard();
|
||||
|
||||
// Leave mention — starts 150ms close timer
|
||||
getMentionLink().dispatchEvent(new MouseEvent('mouseleave', { bubbles: false }));
|
||||
|
||||
// Re-enter same mention before 150ms — cancels timer
|
||||
getMentionLink().dispatchEvent(new MouseEvent('mouseenter', { bubbles: false }));
|
||||
|
||||
// Wait past the original 150ms window
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
|
||||
expect(getHoverCard()).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Keyboard focus behavior ───────────────────────────────────────────────────
|
||||
|
||||
describe('TranscriptionReadView — hover card keyboard focus', () => {
|
||||
it('keeps the card open when keyboard focus moves from mention into card', async () => {
|
||||
mockPersonFetch();
|
||||
render(TranscriptionReadView, { blocks: [block], onParagraphClick: () => {} });
|
||||
|
||||
// Show card via keyboard focusin on mention
|
||||
getMentionLink().dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
|
||||
await vi.waitFor(() => {
|
||||
expect(document.querySelector('[data-testid="person-hover-card-content"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
// Focus leaves mention — starts 150ms close timer
|
||||
getMentionLink().dispatchEvent(new FocusEvent('focusout', { bubbles: true }));
|
||||
|
||||
// Focus enters card — should cancel the close timer
|
||||
getHoverCard()!.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
|
||||
|
||||
// Wait past the 150ms window
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
|
||||
expect(getHoverCard()).not.toBeNull();
|
||||
});
|
||||
|
||||
it('closes the card when keyboard focus leaves the card entirely', async () => {
|
||||
mockPersonFetch();
|
||||
render(TranscriptionReadView, { blocks: [block], onParagraphClick: () => {} });
|
||||
|
||||
// Show card via keyboard focusin
|
||||
getMentionLink().dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
|
||||
await vi.waitFor(() => {
|
||||
expect(document.querySelector('[data-testid="person-hover-card-content"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
// Focus leaves mention — 150ms timer starts
|
||||
getMentionLink().dispatchEvent(new FocusEvent('focusout', { bubbles: true }));
|
||||
|
||||
// Focus enters card — cancels timer
|
||||
getHoverCard()!.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
|
||||
|
||||
// Focus leaves card entirely (relatedTarget = null means focus left the page)
|
||||
getHoverCard()!.dispatchEvent(
|
||||
new FocusEvent('focusout', { bubbles: true, relatedTarget: null })
|
||||
);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(getHoverCard()).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,484 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import TranscriptionReadView from './TranscriptionReadView.svelte';
|
||||
import type { TranscriptionBlockData } from '$lib/types';
|
||||
|
||||
const blocks: TranscriptionBlockData[] = [
|
||||
{
|
||||
id: 'b1',
|
||||
annotationId: 'ann-1',
|
||||
documentId: 'doc-1',
|
||||
text: 'First paragraph text.',
|
||||
label: null,
|
||||
sortOrder: 1,
|
||||
version: 1,
|
||||
source: 'MANUAL',
|
||||
reviewed: false,
|
||||
mentionedPersons: []
|
||||
},
|
||||
{
|
||||
id: 'b2',
|
||||
annotationId: 'ann-2',
|
||||
documentId: 'doc-1',
|
||||
text: 'Second paragraph text.',
|
||||
label: null,
|
||||
sortOrder: 2,
|
||||
version: 1,
|
||||
source: 'MANUAL',
|
||||
reviewed: false,
|
||||
mentionedPersons: []
|
||||
}
|
||||
];
|
||||
|
||||
describe('TranscriptionReadView', () => {
|
||||
it('should render one paragraph per block', async () => {
|
||||
render(TranscriptionReadView, {
|
||||
blocks,
|
||||
onParagraphClick: () => {}
|
||||
});
|
||||
|
||||
await expect.element(page.getByText('First paragraph text.')).toBeInTheDocument();
|
||||
await expect.element(page.getByText('Second paragraph text.')).toBeInTheDocument();
|
||||
|
||||
const paragraphs = document.querySelectorAll('[data-block-id]');
|
||||
expect(paragraphs.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should render [unleserlich] as italic muted text', async () => {
|
||||
render(TranscriptionReadView, {
|
||||
blocks: [
|
||||
{
|
||||
id: 'b1',
|
||||
annotationId: 'ann-1',
|
||||
documentId: 'doc-1',
|
||||
text: 'Text before [unleserlich] text after',
|
||||
label: null,
|
||||
sortOrder: 1,
|
||||
version: 1,
|
||||
source: 'MANUAL',
|
||||
reviewed: false,
|
||||
mentionedPersons: []
|
||||
}
|
||||
],
|
||||
onParagraphClick: () => {}
|
||||
});
|
||||
|
||||
const marker = document.querySelector('[data-marker]');
|
||||
expect(marker).not.toBeNull();
|
||||
expect(marker!.textContent).toBe('[unleserlich]');
|
||||
expect(marker!.tagName.toLowerCase()).toBe('em');
|
||||
});
|
||||
|
||||
it('should render [...] as italic muted text', async () => {
|
||||
render(TranscriptionReadView, {
|
||||
blocks: [
|
||||
{
|
||||
id: 'b1',
|
||||
annotationId: 'ann-1',
|
||||
documentId: 'doc-1',
|
||||
text: 'Some [...] text',
|
||||
label: null,
|
||||
sortOrder: 1,
|
||||
version: 1,
|
||||
source: 'MANUAL',
|
||||
reviewed: false,
|
||||
mentionedPersons: []
|
||||
}
|
||||
],
|
||||
onParagraphClick: () => {}
|
||||
});
|
||||
|
||||
const marker = document.querySelector('[data-marker]');
|
||||
expect(marker).not.toBeNull();
|
||||
expect(marker!.textContent).toBe('[...]');
|
||||
});
|
||||
|
||||
it('should call onParagraphClick with annotationId when paragraph is clicked', async () => {
|
||||
const onParagraphClick = vi.fn();
|
||||
render(TranscriptionReadView, {
|
||||
blocks,
|
||||
onParagraphClick
|
||||
});
|
||||
|
||||
const paragraph = document.querySelector('[data-block-id="b1"]')!;
|
||||
paragraph.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
expect(onParagraphClick).toHaveBeenCalledWith('ann-1');
|
||||
});
|
||||
|
||||
it('should render blocks sorted by sortOrder', async () => {
|
||||
render(TranscriptionReadView, {
|
||||
blocks: [
|
||||
{ ...blocks[1], sortOrder: 1 },
|
||||
{ ...blocks[0], sortOrder: 2 }
|
||||
],
|
||||
onParagraphClick: () => {}
|
||||
});
|
||||
|
||||
const paragraphs = document.querySelectorAll('[data-block-id]');
|
||||
expect(paragraphs[0].getAttribute('data-block-id')).toBe('b2');
|
||||
expect(paragraphs[1].getAttribute('data-block-id')).toBe('b1');
|
||||
});
|
||||
|
||||
it('should apply flash-highlight class when highlightBlockId matches', async () => {
|
||||
render(TranscriptionReadView, {
|
||||
blocks: [blocks[0]],
|
||||
onParagraphClick: () => {},
|
||||
highlightBlockId: 'b1'
|
||||
});
|
||||
|
||||
const el = document.querySelector('[data-block-id="b1"]')!;
|
||||
expect(el.classList.contains('flash-highlight')).toBe(true);
|
||||
});
|
||||
|
||||
it('should not apply flash-highlight class when highlightBlockId does not match', async () => {
|
||||
render(TranscriptionReadView, {
|
||||
blocks: [blocks[0]],
|
||||
onParagraphClick: () => {},
|
||||
highlightBlockId: 'other-id'
|
||||
});
|
||||
|
||||
const el = document.querySelector('[data-block-id="b1"]')!;
|
||||
expect(el.classList.contains('flash-highlight')).toBe(false);
|
||||
});
|
||||
|
||||
it('should render empty state when no blocks', async () => {
|
||||
render(TranscriptionReadView, {
|
||||
blocks: [],
|
||||
onParagraphClick: () => {}
|
||||
});
|
||||
|
||||
const paragraphs = document.querySelectorAll('[data-block-id]');
|
||||
expect(paragraphs.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TranscriptionReadView — person-mention rendering', () => {
|
||||
const PERSON_ID = '550e8400-e29b-41d4-a716-446655440000';
|
||||
|
||||
const mentionBlock: TranscriptionBlockData = {
|
||||
id: 'b1',
|
||||
annotationId: 'ann-1',
|
||||
documentId: 'doc-1',
|
||||
text: 'Brief an @Auguste Raddatz vom Mai',
|
||||
label: null,
|
||||
sortOrder: 1,
|
||||
version: 1,
|
||||
source: 'MANUAL',
|
||||
reviewed: false,
|
||||
mentionedPersons: [{ personId: PERSON_ID, displayName: 'Auguste Raddatz' }]
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// Default: any /api/persons/{id} call returns 404 unless a test overrides it.
|
||||
// Tests that need loaded data stub fetch themselves.
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ status: 404, ok: false, json: vi.fn() }));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('renders a person mention as an anchor link with the person URL', async () => {
|
||||
render(TranscriptionReadView, {
|
||||
blocks: [mentionBlock],
|
||||
onParagraphClick: () => {}
|
||||
});
|
||||
|
||||
const link = document.querySelector(`a.person-mention[data-person-id="${PERSON_ID}"]`)!;
|
||||
expect(link).not.toBeNull();
|
||||
expect(link.getAttribute('href')).toBe(`/persons/${PERSON_ID}`);
|
||||
expect(link.textContent).toBe('Auguste Raddatz');
|
||||
});
|
||||
|
||||
it('strips the @ trigger from the rendered link text (read mode)', async () => {
|
||||
render(TranscriptionReadView, {
|
||||
blocks: [mentionBlock],
|
||||
onParagraphClick: () => {}
|
||||
});
|
||||
|
||||
const block = document.querySelector('[data-block-id="b1"]')!;
|
||||
expect(block.textContent).not.toContain('@Auguste Raddatz');
|
||||
expect(block.textContent).toContain('Auguste Raddatz');
|
||||
});
|
||||
|
||||
it('renders mention link AND [unleserlich] marker correctly when both occur in the same block (B19b)', async () => {
|
||||
const block: TranscriptionBlockData = {
|
||||
...mentionBlock,
|
||||
text: 'Hallo @Auguste Raddatz [unleserlich] Marie'
|
||||
};
|
||||
render(TranscriptionReadView, {
|
||||
blocks: [block],
|
||||
onParagraphClick: () => {}
|
||||
});
|
||||
|
||||
// Mention rendered as an anchor
|
||||
const link = document.querySelector('a.person-mention')!;
|
||||
expect(link).not.toBeNull();
|
||||
expect(link.textContent).toBe('Auguste Raddatz');
|
||||
|
||||
// Marker rendered as <em data-marker>
|
||||
const marker = document.querySelector('[data-marker]')!;
|
||||
expect(marker).not.toBeNull();
|
||||
expect(marker.textContent).toBe('[unleserlich]');
|
||||
|
||||
// Marker text is NOT inside the anchor — they are siblings, not nested
|
||||
expect(link.contains(marker)).toBe(false);
|
||||
|
||||
// No double-escape — text content reads cleanly
|
||||
const blockEl = document.querySelector('[data-block-id="b1"]')!;
|
||||
expect(blockEl.textContent).not.toContain('&');
|
||||
expect(blockEl.textContent).not.toContain('<');
|
||||
});
|
||||
|
||||
it('does not render mention link for plain text without the @ trigger', async () => {
|
||||
const plain: TranscriptionBlockData = {
|
||||
...mentionBlock,
|
||||
text: 'Auguste Raddatz war hier',
|
||||
mentionedPersons: [{ personId: PERSON_ID, displayName: 'Auguste Raddatz' }]
|
||||
};
|
||||
render(TranscriptionReadView, {
|
||||
blocks: [plain],
|
||||
onParagraphClick: () => {}
|
||||
});
|
||||
|
||||
const link = document.querySelector('a.person-mention');
|
||||
expect(link).toBeNull();
|
||||
});
|
||||
|
||||
it('escapes HTML in the block text — no stored XSS via raw text', async () => {
|
||||
const xss: TranscriptionBlockData = {
|
||||
...mentionBlock,
|
||||
text: '<img src=x onerror=alert(1)>',
|
||||
mentionedPersons: []
|
||||
};
|
||||
render(TranscriptionReadView, {
|
||||
blocks: [xss],
|
||||
onParagraphClick: () => {}
|
||||
});
|
||||
|
||||
// No raw <img> tag in DOM
|
||||
expect(document.querySelector('[data-block-id="b1"] img')).toBeNull();
|
||||
// The escaped text is visible
|
||||
const block = document.querySelector('[data-block-id="b1"]')!;
|
||||
expect(block.textContent).toContain('<img src=x onerror=alert(1)>');
|
||||
});
|
||||
|
||||
it('triggers fetch for the person on mention mouseenter (B15.5 cache, single call)', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
status: 404,
|
||||
ok: false,
|
||||
json: vi.fn()
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
render(TranscriptionReadView, {
|
||||
blocks: [mentionBlock],
|
||||
onParagraphClick: () => {}
|
||||
});
|
||||
|
||||
const link = document.querySelector('a.person-mention')!;
|
||||
link.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const personFetches = fetchMock.mock.calls.filter((c) =>
|
||||
String(c[0]).includes(`/api/persons/${PERSON_ID}`)
|
||||
);
|
||||
expect(personFetches.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('deduplicates fetches for the same personId across multiple mouseenter events (B15.5)', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
status: 404,
|
||||
ok: false,
|
||||
json: vi.fn()
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
// Two blocks both mention the same person
|
||||
const block2: TranscriptionBlockData = { ...mentionBlock, id: 'b2', annotationId: 'ann-2' };
|
||||
render(TranscriptionReadView, {
|
||||
blocks: [mentionBlock, block2],
|
||||
onParagraphClick: () => {}
|
||||
});
|
||||
|
||||
const links = document.querySelectorAll('a.person-mention');
|
||||
links.forEach((link) => link.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true })));
|
||||
// Plus a re-hover on the first
|
||||
links[0].dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const personFetches = fetchMock.mock.calls.filter(
|
||||
(c) => String(c[0]) === `/api/persons/${PERSON_ID}`
|
||||
);
|
||||
expect(personFetches.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('mounts the hover card on mouseenter when the fetch loads', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockImplementation((url: string) => {
|
||||
if (url.endsWith('/relationships')) {
|
||||
return Promise.resolve({ status: 200, ok: true, json: () => Promise.resolve([]) });
|
||||
}
|
||||
return Promise.resolve({
|
||||
status: 200,
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
id: PERSON_ID,
|
||||
firstName: 'Auguste',
|
||||
lastName: 'Raddatz',
|
||||
displayName: 'Auguste Raddatz',
|
||||
personType: 'PERSON',
|
||||
familyMember: true,
|
||||
birthYear: 1882,
|
||||
deathYear: 1944
|
||||
})
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
render(TranscriptionReadView, {
|
||||
blocks: [mentionBlock],
|
||||
onParagraphClick: () => {}
|
||||
});
|
||||
|
||||
const link = document.querySelector('a.person-mention')!;
|
||||
link.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const card = document.querySelector('[data-testid="person-hover-card"]');
|
||||
expect(card).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('unmounts the hover card on mouseleave', async () => {
|
||||
render(TranscriptionReadView, {
|
||||
blocks: [mentionBlock],
|
||||
onParagraphClick: () => {}
|
||||
});
|
||||
|
||||
const link = document.querySelector('a.person-mention')!;
|
||||
link.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
|
||||
link.dispatchEvent(new MouseEvent('mouseleave', { bubbles: true }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const card = document.querySelector('[data-testid="person-hover-card"]');
|
||||
expect(card).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('mounts the hover card on focusin so keyboard users see the preview (WCAG 2.1.1)', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockImplementation((url: string) => {
|
||||
if (String(url).endsWith('/relationships')) {
|
||||
return Promise.resolve({ status: 200, ok: true, json: () => Promise.resolve([]) });
|
||||
}
|
||||
return Promise.resolve({
|
||||
status: 200,
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
id: PERSON_ID,
|
||||
firstName: 'Auguste',
|
||||
lastName: 'Raddatz',
|
||||
displayName: 'Auguste Raddatz',
|
||||
personType: 'PERSON',
|
||||
familyMember: true
|
||||
})
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
render(TranscriptionReadView, {
|
||||
blocks: [mentionBlock],
|
||||
onParagraphClick: () => {}
|
||||
});
|
||||
|
||||
const link = document.querySelector('a.person-mention')!;
|
||||
link.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const card = document.querySelector('[data-testid="person-hover-card"]');
|
||||
expect(card).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('unmounts the hover card on focusout', async () => {
|
||||
render(TranscriptionReadView, {
|
||||
blocks: [mentionBlock],
|
||||
onParagraphClick: () => {}
|
||||
});
|
||||
|
||||
const link = document.querySelector('a.person-mention')!;
|
||||
link.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
|
||||
await vi.waitFor(() => {
|
||||
// the card mounts even in 404 → loading → null path; assert it cleans up on blur
|
||||
});
|
||||
link.dispatchEvent(new FocusEvent('focusout', { bubbles: true }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const card = document.querySelector('[data-testid="person-hover-card"]');
|
||||
expect(card).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('lets ctrl-click and meta-click fall through so users can open in a new tab', async () => {
|
||||
render(TranscriptionReadView, {
|
||||
blocks: [mentionBlock],
|
||||
onParagraphClick: () => {}
|
||||
});
|
||||
|
||||
const link = document.querySelector('a.person-mention')! as HTMLAnchorElement;
|
||||
|
||||
// ctrl-click (Linux/Win "open in new tab")
|
||||
const ctrlClick = new MouseEvent('click', { bubbles: true, cancelable: true, ctrlKey: true });
|
||||
const ctrlPrevented = !link.dispatchEvent(ctrlClick);
|
||||
expect(ctrlPrevented).toBe(false);
|
||||
|
||||
// meta-click (macOS "open in new tab")
|
||||
const metaClick = new MouseEvent('click', { bubbles: true, cancelable: true, metaKey: true });
|
||||
const metaPrevented = !link.dispatchEvent(metaClick);
|
||||
expect(metaPrevented).toBe(false);
|
||||
});
|
||||
|
||||
it('lets middle-click fall through so users can open in a background tab', async () => {
|
||||
render(TranscriptionReadView, {
|
||||
blocks: [mentionBlock],
|
||||
onParagraphClick: () => {}
|
||||
});
|
||||
|
||||
const link = document.querySelector('a.person-mention')! as HTMLAnchorElement;
|
||||
// button === 1 is middle mouse button
|
||||
const middleClick = new MouseEvent('click', { bubbles: true, cancelable: true, button: 1 });
|
||||
const prevented = !link.dispatchEvent(middleClick);
|
||||
expect(prevented).toBe(false);
|
||||
});
|
||||
|
||||
it('degrades to plain unlinked text when the person fetch returns 404', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ status: 404, ok: false, json: vi.fn() }));
|
||||
|
||||
render(TranscriptionReadView, {
|
||||
blocks: [mentionBlock],
|
||||
onParagraphClick: () => {}
|
||||
});
|
||||
|
||||
const link = document.querySelector('a.person-mention')!;
|
||||
link.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
// Anchor is marked as deleted so subsequent hovers/clicks treat it as plain text
|
||||
const stillLink = document.querySelector('a.person-mention')!;
|
||||
expect(stillLink.getAttribute('data-person-deleted')).toBe('true');
|
||||
});
|
||||
|
||||
// 404 → no card mounted
|
||||
const card = document.querySelector('[data-testid="person-hover-card"]');
|
||||
expect(card).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user