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
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:
@@ -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;' : ''}
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user