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

View File

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

View File

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

View File

@@ -99,6 +99,37 @@ async function deleteBlock(blockId: string) {
transcriptionBlocks = transcriptionBlocks.filter((b) => b.id !== blockId); 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) { function handleBlockFocus(blockId: string) {
const block = transcriptionBlocks.find((b) => b.id === blockId); const block = transcriptionBlocks.find((b) => b.id === blockId);
if (block) { if (block) {
@@ -172,11 +203,13 @@ onMount(() => {
isLoading={isLoading} isLoading={isLoading}
error={fileError} error={fileError}
bind:annotateMode={annotateMode} bind:annotateMode={annotateMode}
transcribeMode={transcribeMode}
bind:activeAnnotationId={activeAnnotationId} bind:activeAnnotationId={activeAnnotationId}
bind:activeAnnotationPage={activeAnnotationPage} bind:activeAnnotationPage={activeAnnotationPage}
onAnnotationClick={(id) => { onAnnotationClick={(id) => {
activeAnnotationId = id; activeAnnotationId = id;
}} }}
onTranscriptionDraw={createBlockFromDraw}
/> />
</div> </div>