feat(transcription): enable drawing turquoise rectangles on PDF to create blocks
Some checks failed
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Failing after 1m29s
CI / Backend Unit Tests (pull_request) Failing after 2m40s
CI / E2E Tests (pull_request) Failing after 1h22m53s

- AnnotationLayer: add dimColor prop — annotations matching dim color
  render at 30% opacity with pointer-events disabled (300ms transition)
- PdfViewer: add transcribeMode prop, derived drawingEnabled/drawColor;
  in transcribe mode draws with turquoise (#00C7B1), routes draw events
  to onTranscriptionDraw callback instead of annotation endpoint
- DocumentViewer: pass through transcribeMode + onTranscriptionDraw
- Document detail page: createBlockFromDraw() POSTs to transcription
  blocks API on draw completion, adds created block to list
- Mode-based dimming: yellow annotations dim in transcribe mode,
  turquoise annotations dim in annotate mode

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-05 20:44:45 +02:00
parent aaffee2804
commit 99e2e6e5c1
4 changed files with 73 additions and 7 deletions

View File

@@ -12,6 +12,7 @@ let {
annotations = [],
canAnnotate,
color,
dimColor,
onDraw,
onDelete,
commentCounts,
@@ -20,12 +21,18 @@ let {
annotations: Annotation[];
canAnnotate: boolean;
color: string;
dimColor?: string;
onDraw: (rect: { x: number; y: number; width: number; height: number }) => void;
onDelete: (id: string) => void;
commentCounts?: Record<string, number>;
onAnnotationClick?: (id: string) => void;
} = $props();
function isDimmed(annotation: Annotation): boolean {
if (!dimColor) return false;
return annotation.color.toLowerCase() === dimColor.toLowerCase();
}
let drawStart = $state<{ x: number; y: number } | null>(null);
let drawRect = $state<DrawRect | null>(null);
@@ -123,8 +130,9 @@ const containerStyle = $derived(
height: {annotation.height * 100}%;
background-color: {hexToRgba(annotation.color, hoveredId === annotation.id ? 0.5 : 0.3)};
box-shadow: {hoveredId === annotation.id ? `inset 0 0 0 2px ${hexToRgba(annotation.color, 0.8)}` : 'none'};
pointer-events: auto;
transition: background-color 0.15s ease, box-shadow 0.15s ease;
opacity: {isDimmed(annotation) ? 0.3 : 1};
pointer-events: {isDimmed(annotation) ? 'none' : 'auto'};
transition: background-color 0.15s ease, box-shadow 0.15s ease, opacity 0.3s ease;
{onAnnotationClick && !canAnnotate ? 'cursor: pointer;' : ''}
"
>

View File

@@ -9,15 +9,19 @@ type Doc = {
fileHash?: string | null;
};
type DrawRect = { x: number; y: number; width: number; height: number; pageNumber: number };
type Props = {
doc: Doc;
fileUrl: string;
isLoading: boolean;
error: string;
annotateMode: boolean;
transcribeMode?: boolean;
activeAnnotationId: string | null;
activeAnnotationPage: number | null;
onAnnotationClick: (id: string) => void;
onTranscriptionDraw?: (rect: DrawRect) => void;
};
let {
@@ -26,9 +30,11 @@ let {
isLoading,
error,
annotateMode = $bindable(),
transcribeMode = false,
activeAnnotationId = $bindable(),
activeAnnotationPage = $bindable(),
onAnnotationClick
onAnnotationClick,
onTranscriptionDraw
}: Props = $props();
</script>
@@ -81,9 +87,11 @@ let {
url={fileUrl}
documentId={doc.id}
bind:annotateMode={annotateMode}
transcribeMode={transcribeMode}
bind:activeAnnotationId={activeAnnotationId}
bind:activeAnnotationPage={activeAnnotationPage}
onAnnotationClick={onAnnotationClick}
onTranscriptionDraw={onTranscriptionDraw}
documentFileHash={doc.fileHash ?? null}
/>
{:else if fileUrl}

View File

@@ -6,21 +6,27 @@ import AnnotationLayer from './AnnotationLayer.svelte';
import type { Annotation } from '$lib/types';
import { m } from '$lib/paraglide/messages.js';
type DrawRect = { x: number; y: number; width: number; height: number; pageNumber: number };
let {
url,
documentId = '',
annotateMode = $bindable(false),
transcribeMode = false,
activeAnnotationId = $bindable<string | null>(null),
activeAnnotationPage = $bindable<number | null>(null),
onAnnotationClick,
onTranscriptionDraw,
documentFileHash
}: {
url: string;
documentId?: string;
annotateMode?: boolean;
transcribeMode?: boolean;
activeAnnotationId?: string | null;
activeAnnotationPage?: number | null;
onAnnotationClick?: (id: string) => void;
onTranscriptionDraw?: (rect: DrawRect) => void;
documentFileHash?: string | null;
} = $props();
@@ -49,6 +55,10 @@ let annotateColor = $state('#ffff00');
let commentCounts = new SvelteMap<string, number>();
let showAnnotations = $state(true);
const TRANSCRIPTION_COLOR = '#00C7B1';
const drawingEnabled = $derived(annotateMode || transcribeMode);
const drawColor = $derived(transcribeMode ? TRANSCRIPTION_COLOR : annotateColor);
const visibleAnnotations = $derived(
annotations.filter((a) => !a.fileHash || !documentFileHash || a.fileHash === documentFileHash)
);
@@ -194,8 +204,14 @@ async function loadAnnotations(docId: string) {
}
}
async function handleAnnotationDraw(rect: { x: number; y: number; width: number; height: number }) {
async function handleDraw(rect: { x: number; y: number; width: number; height: number }) {
if (!documentId) return;
if (transcribeMode) {
onTranscriptionDraw?.({ ...rect, pageNumber: currentPage });
return;
}
try {
const res = await fetch(`/api/documents/${documentId}/annotations`, {
method: 'POST',
@@ -486,9 +502,10 @@ function zoomOut() {
{#if showAnnotations}
<AnnotationLayer
annotations={visibleAnnotations.filter((a) => a.pageNumber === currentPage)}
canAnnotate={annotateMode}
color={annotateColor}
onDraw={handleAnnotationDraw}
canAnnotate={drawingEnabled}
color={drawColor}
dimColor={transcribeMode ? '#ffff00' : annotateMode ? TRANSCRIPTION_COLOR : undefined}
onDraw={handleDraw}
onDelete={handleAnnotationDelete}
commentCounts={Object.fromEntries(commentCounts)}
onAnnotationClick={handleAnnotationClick}

View File

@@ -99,6 +99,37 @@ async function deleteBlock(blockId: string) {
transcriptionBlocks = transcriptionBlocks.filter((b) => b.id !== blockId);
}
async function createBlockFromDraw(rect: {
x: number;
y: number;
width: number;
height: number;
pageNumber: number;
}) {
try {
const res = await fetch(`/api/documents/${doc.id}/transcription-blocks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
pageNumber: rect.pageNumber,
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
text: '',
label: null
})
});
if (res.ok) {
const created = (await res.json()) as TranscriptionBlockData;
transcriptionBlocks = [...transcriptionBlocks, created];
activeAnnotationId = created.annotationId;
}
} catch (e) {
console.error('Failed to create transcription block:', e);
}
}
function handleBlockFocus(blockId: string) {
const block = transcriptionBlocks.find((b) => b.id === blockId);
if (block) {
@@ -172,11 +203,13 @@ onMount(() => {
isLoading={isLoading}
error={fileError}
bind:annotateMode={annotateMode}
transcribeMode={transcribeMode}
bind:activeAnnotationId={activeAnnotationId}
bind:activeAnnotationPage={activeAnnotationPage}
onAnnotationClick={(id) => {
activeAnnotationId = id;
}}
onTranscriptionDraw={createBlockFromDraw}
/>
</div>