refactor: remove legacy annotate mode — transcription replaces it
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Backend Unit Tests (pull_request) Failing after 4m26s
CI / Unit & Component Tests (pull_request) Failing after 14m40s
CI / E2E Tests (pull_request) Failing after 1h26m51s
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Backend Unit Tests (pull_request) Failing after 4m26s
CI / Unit & Component Tests (pull_request) Failing after 14m40s
CI / E2E Tests (pull_request) Failing after 1h26m51s
The yellow annotation+comment system is now redundant. Transcription blocks handle the same use case (mark region → discuss) but better, because they also produce a transcription. Removed: - annotateMode state and all wiring through page/topbar/viewer/pdfviewer - Annotate/Stop annotate buttons from DocumentTopBar - AnnotateHintStrip import and rendering - AnnotationSidePanel from document detail page - canAnnotate prop from DocumentTopBar - Color picker from PdfViewer - Comment count badges and loadCommentCounts from PdfViewer - Delete button from AnnotationLayer (blocks own annotation lifecycle) - dimColor prop from AnnotationLayer Simplified: - AnnotationLayer: only canDraw + color + onDraw + onAnnotationClick - PdfViewer: only draws in transcribeMode with turquoise - Clicking annotation in transcribe mode scrolls to corresponding block - canComment derived from canWrite (no longer needs canAnnotate) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -10,29 +10,18 @@ type DrawRect = {
|
||||
|
||||
let {
|
||||
annotations = [],
|
||||
canAnnotate,
|
||||
canDraw,
|
||||
color,
|
||||
dimColor,
|
||||
onDraw,
|
||||
onDelete,
|
||||
commentCounts,
|
||||
onAnnotationClick
|
||||
}: {
|
||||
annotations: Annotation[];
|
||||
canAnnotate: boolean;
|
||||
canDraw: boolean;
|
||||
color: string;
|
||||
dimColor?: string;
|
||||
onDraw: (rect: { x: number; y: number; width: number; height: number }) => void;
|
||||
onDelete: (id: string) => void;
|
||||
commentCounts?: Record<string, number>;
|
||||
onDraw: (rect: DrawRect) => void;
|
||||
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);
|
||||
|
||||
@@ -52,7 +41,7 @@ function getNormalizedCoords(event: PointerEvent, element: HTMLElement): { x: nu
|
||||
}
|
||||
|
||||
function handlePointerDown(event: PointerEvent) {
|
||||
if (!canAnnotate) return;
|
||||
if (!canDraw) return;
|
||||
|
||||
if ((event.target as HTMLElement).closest('[data-annotation]')) return;
|
||||
|
||||
@@ -65,7 +54,7 @@ function handlePointerDown(event: PointerEvent) {
|
||||
}
|
||||
|
||||
function handlePointerMove(event: PointerEvent) {
|
||||
if (!canAnnotate || !drawStart) return;
|
||||
if (!canDraw || !drawStart) return;
|
||||
|
||||
const container = event.currentTarget as HTMLElement;
|
||||
const coords = getNormalizedCoords(event, container);
|
||||
@@ -79,7 +68,7 @@ function handlePointerMove(event: PointerEvent) {
|
||||
}
|
||||
|
||||
function handlePointerUp(event: PointerEvent) {
|
||||
if (!canAnnotate || !drawStart || !drawRect) return;
|
||||
if (!canDraw || !drawStart || !drawRect) return;
|
||||
|
||||
const container = event.currentTarget as HTMLElement;
|
||||
const coords = getNormalizedCoords(event, container);
|
||||
@@ -100,7 +89,7 @@ function handlePointerUp(event: PointerEvent) {
|
||||
let hoveredId = $state<string | null>(null);
|
||||
|
||||
const containerStyle = $derived(
|
||||
`position: absolute; top: 0; left: 0; width: 100%; height: 100%;${canAnnotate ? ' cursor: crosshair; touch-action: none;' : ''}`
|
||||
`position: absolute; top: 0; left: 0; width: 100%; height: 100%;${canDraw ? ' cursor: crosshair; touch-action: none;' : ''}`
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -117,9 +106,11 @@ const containerStyle = $derived(
|
||||
data-annotation
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label="Kommentare anzeigen"
|
||||
aria-label="Block anzeigen"
|
||||
onclick={() => onAnnotationClick?.(annotation.id)}
|
||||
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') onAnnotationClick?.(annotation.id); }}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') onAnnotationClick?.(annotation.id);
|
||||
}}
|
||||
onpointerenter={() => (hoveredId = annotation.id)}
|
||||
onpointerleave={() => (hoveredId = null)}
|
||||
style="
|
||||
@@ -130,73 +121,11 @@ 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'};
|
||||
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;' : ''}
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease, box-shadow 0.15s ease;
|
||||
"
|
||||
>
|
||||
{#if canAnnotate}
|
||||
<button
|
||||
aria-label="Annotation löschen"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
const count = commentCounts?.[annotation.id] ?? 0;
|
||||
if (count > 0) {
|
||||
const msg =
|
||||
count === 1
|
||||
? 'Diese Annotation hat 1 Kommentar. Beim Löschen wird er ebenfalls entfernt. Fortfahren?'
|
||||
: `Diese Annotation hat ${count} Kommentare. Beim Löschen werden sie ebenfalls entfernt. Fortfahren?`;
|
||||
if (!window.confirm(msg)) return;
|
||||
}
|
||||
onDelete(annotation.id);
|
||||
}}
|
||||
style="
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
right: -8px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background-color: #ef4444;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
pointer-events: auto;
|
||||
">×</button
|
||||
>
|
||||
{/if}
|
||||
{#if (commentCounts?.[annotation.id] ?? 0) > 0}
|
||||
<div
|
||||
style="
|
||||
position: absolute;
|
||||
bottom: -10px;
|
||||
right: -10px;
|
||||
background-color: #002850;
|
||||
color: white;
|
||||
font-size: 11px;
|
||||
font-family: sans-serif;
|
||||
font-weight: 600;
|
||||
padding: 2px 6px;
|
||||
border-radius: 999px;
|
||||
min-width: 20px;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
line-height: 18px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
|
||||
"
|
||||
>
|
||||
{commentCounts?.[annotation.id]}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
></div>
|
||||
{/each}
|
||||
|
||||
{#if drawRect && drawRect.width > 0}
|
||||
|
||||
@@ -18,7 +18,7 @@ type Annotation = {
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
function makeAnnotation(id = 'ann-1'): Annotation {
|
||||
function makeAnnotation(id = 'ann-1', color = '#00C7B1'): Annotation {
|
||||
return {
|
||||
id,
|
||||
documentId: 'doc-1',
|
||||
@@ -27,7 +27,7 @@ function makeAnnotation(id = 'ann-1'): Annotation {
|
||||
y: 0.1,
|
||||
width: 0.3,
|
||||
height: 0.2,
|
||||
color: '#ff0000',
|
||||
color,
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
@@ -36,87 +36,48 @@ describe('AnnotationLayer', () => {
|
||||
it('renders a colored element for each annotation', async () => {
|
||||
render(AnnotationLayer, {
|
||||
annotations: [makeAnnotation('ann-1'), makeAnnotation('ann-2')],
|
||||
canAnnotate: false,
|
||||
color: '#ff0000',
|
||||
onDraw: () => {},
|
||||
onDelete: () => {}
|
||||
canDraw: false,
|
||||
color: '#00C7B1',
|
||||
onDraw: () => {}
|
||||
});
|
||||
|
||||
await expect.element(page.getByTestId('annotation-ann-1')).toBeInTheDocument();
|
||||
await expect.element(page.getByTestId('annotation-ann-2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a delete button for each annotation when canAnnotate is true', async () => {
|
||||
render(AnnotationLayer, {
|
||||
annotations: [makeAnnotation('ann-1')],
|
||||
canAnnotate: true,
|
||||
color: '#ff0000',
|
||||
onDraw: () => {},
|
||||
onDelete: () => {}
|
||||
});
|
||||
|
||||
await expect
|
||||
.element(page.getByRole('button', { name: /annotation löschen/i }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show delete buttons when canAnnotate is false', async () => {
|
||||
render(AnnotationLayer, {
|
||||
annotations: [makeAnnotation('ann-1')],
|
||||
canAnnotate: false,
|
||||
color: '#ff0000',
|
||||
onDraw: () => {},
|
||||
onDelete: () => {}
|
||||
});
|
||||
|
||||
expect(page.getByRole('button', { name: /annotation löschen/i }).query()).toBeNull();
|
||||
});
|
||||
|
||||
it('dims annotations matching dimColor', async () => {
|
||||
render(AnnotationLayer, {
|
||||
annotations: [makeAnnotation('ann-1')],
|
||||
canAnnotate: false,
|
||||
color: '#00C7B1',
|
||||
dimColor: '#ff0000',
|
||||
onDraw: () => {},
|
||||
onDelete: () => {}
|
||||
});
|
||||
|
||||
const el = page.getByTestId('annotation-ann-1');
|
||||
await expect.element(el).toBeInTheDocument();
|
||||
const style = el.element().style;
|
||||
expect(style.opacity).toBe('0.3');
|
||||
expect(style.pointerEvents).toBe('none');
|
||||
});
|
||||
|
||||
it('does not dim annotations that do not match dimColor', async () => {
|
||||
const ann = makeAnnotation('ann-1');
|
||||
ann.color = '#00C7B1';
|
||||
render(AnnotationLayer, {
|
||||
annotations: [ann],
|
||||
canAnnotate: false,
|
||||
color: '#00C7B1',
|
||||
dimColor: '#ff0000',
|
||||
onDraw: () => {},
|
||||
onDelete: () => {}
|
||||
});
|
||||
|
||||
const el = page.getByTestId('annotation-ann-1');
|
||||
await expect.element(el).toBeInTheDocument();
|
||||
const style = el.element().style;
|
||||
expect(style.opacity).toBe('1');
|
||||
});
|
||||
|
||||
it('has crosshair cursor when canAnnotate is true', async () => {
|
||||
it('has crosshair cursor when canDraw is true', async () => {
|
||||
render(AnnotationLayer, {
|
||||
annotations: [],
|
||||
canAnnotate: true,
|
||||
canDraw: true,
|
||||
color: '#00C7B1',
|
||||
onDraw: () => {},
|
||||
onDelete: () => {}
|
||||
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('does not show delete buttons (annotations owned by blocks)', async () => {
|
||||
render(AnnotationLayer, {
|
||||
annotations: [makeAnnotation('ann-1')],
|
||||
canDraw: true,
|
||||
color: '#00C7B1',
|
||||
onDraw: () => {}
|
||||
});
|
||||
|
||||
await expect.element(page.getByTestId('annotation-ann-1')).toBeInTheDocument();
|
||||
expect(page.getByRole('button', { name: /löschen/i }).query()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,6 @@ import { slide } from 'svelte/transition';
|
||||
import { formatDate } from '$lib/utils/personFormat';
|
||||
import { clickOutside } from '$lib/actions/clickOutside';
|
||||
import PersonChipRow from './PersonChipRow.svelte';
|
||||
import AnnotateHintStrip from './AnnotateHintStrip.svelte';
|
||||
import OverflowPillButton from './OverflowPillButton.svelte';
|
||||
import DocumentMetadataDrawer from './DocumentMetadataDrawer.svelte';
|
||||
|
||||
@@ -28,20 +27,11 @@ type Doc = {
|
||||
type Props = {
|
||||
doc: Doc;
|
||||
canWrite: boolean;
|
||||
canAnnotate: boolean;
|
||||
fileUrl: string;
|
||||
annotateMode: boolean;
|
||||
transcribeMode: boolean;
|
||||
};
|
||||
|
||||
let {
|
||||
doc,
|
||||
canWrite,
|
||||
canAnnotate,
|
||||
fileUrl,
|
||||
annotateMode = $bindable(),
|
||||
transcribeMode = $bindable()
|
||||
}: Props = $props();
|
||||
let { doc, canWrite, fileUrl, transcribeMode = $bindable() }: Props = $props();
|
||||
|
||||
let detailsOpen = $state(false);
|
||||
|
||||
@@ -56,55 +46,10 @@ const longDate = $derived(doc.documentDate ? formatDate(doc.documentDate, 'long'
|
||||
let mobileMenuOpen = $state(false);
|
||||
</script>
|
||||
|
||||
{#snippet annotateBtn(mobile: boolean)}
|
||||
<button
|
||||
onclick={() => {
|
||||
annotateMode = true;
|
||||
if (mobile) mobileMenuOpen = false;
|
||||
}}
|
||||
aria-label={m.doc_panel_annotate()}
|
||||
aria-pressed={false}
|
||||
class={mobile
|
||||
? 'flex w-full items-center gap-2 rounded px-3 py-2 text-left text-[16px] text-ink transition hover:bg-muted focus-visible:ring-2 focus-visible:ring-primary'
|
||||
: 'hidden items-center gap-1.5 rounded border border-primary px-3 py-1.5 font-sans text-[16px] font-medium text-ink transition hover:bg-primary hover:text-primary-fg focus-visible:ring-2 focus-visible:ring-primary md:flex'}
|
||||
>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Note/Note-Add-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-5 w-5 shrink-0"
|
||||
/>
|
||||
{m.doc_panel_annotate()}
|
||||
</button>
|
||||
{/snippet}
|
||||
|
||||
{#snippet annotateStopBtn(mobile: boolean)}
|
||||
<button
|
||||
onclick={() => {
|
||||
annotateMode = false;
|
||||
if (mobile) mobileMenuOpen = false;
|
||||
}}
|
||||
aria-label={m.doc_panel_annotate_stop()}
|
||||
aria-pressed={true}
|
||||
class={mobile
|
||||
? 'flex w-full items-center gap-2 rounded bg-primary px-3 py-2 text-left text-[16px] text-primary-fg transition focus-visible:ring-2 focus-visible:ring-primary'
|
||||
: 'flex items-center gap-1.5 rounded bg-primary px-3 py-1.5 font-sans text-[16px] font-medium text-primary-fg transition focus-visible:ring-2 focus-visible:ring-primary'}
|
||||
>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Note/Note-Add-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-5 w-5 shrink-0 invert"
|
||||
/>
|
||||
{m.doc_panel_annotate_stop()}
|
||||
</button>
|
||||
{/snippet}
|
||||
|
||||
{#snippet transcribeBtn(mobile: boolean)}
|
||||
<button
|
||||
onclick={() => {
|
||||
transcribeMode = true;
|
||||
annotateMode = false;
|
||||
if (mobile) mobileMenuOpen = false;
|
||||
}}
|
||||
aria-label={m.transcription_mode_label()}
|
||||
@@ -256,7 +201,7 @@ let mobileMenuOpen = $state(false);
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div class="flex shrink-0 items-center gap-1.5 pr-3 font-sans">
|
||||
{#if canWrite && isPdf && !transcribeMode && !annotateMode}
|
||||
{#if canWrite && isPdf && !transcribeMode}
|
||||
{@render transcribeBtn(false)}
|
||||
{/if}
|
||||
|
||||
@@ -264,15 +209,7 @@ let mobileMenuOpen = $state(false);
|
||||
{@render transcribeStopBtn(false)}
|
||||
{/if}
|
||||
|
||||
{#if canAnnotate && isPdf && !annotateMode && !transcribeMode}
|
||||
{@render annotateBtn(false)}
|
||||
{/if}
|
||||
|
||||
{#if canAnnotate && isPdf && annotateMode}
|
||||
{@render annotateStopBtn(false)}
|
||||
{/if}
|
||||
|
||||
{#if canWrite && !annotateMode && !transcribeMode}
|
||||
{#if canWrite && !transcribeMode}
|
||||
<a
|
||||
href="/documents/{doc.id}/edit"
|
||||
aria-label={m.btn_edit()}
|
||||
@@ -288,12 +225,12 @@ let mobileMenuOpen = $state(false);
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
{#if doc.filePath && !annotateMode}
|
||||
{#if doc.filePath && !transcribeMode}
|
||||
{@render downloadLink(false)}
|
||||
{/if}
|
||||
|
||||
<!-- Kebab menu — mobile only, contains actions hidden below md -->
|
||||
{#if (canAnnotate && isPdf) || doc.filePath}
|
||||
{#if (canWrite && isPdf) || doc.filePath}
|
||||
<div
|
||||
role="group"
|
||||
class="relative md:hidden"
|
||||
@@ -321,14 +258,10 @@ let mobileMenuOpen = $state(false);
|
||||
role="menu"
|
||||
class="absolute top-full right-0 z-50 mt-1 min-w-[200px] rounded-md border border-line bg-surface p-2 shadow-lg"
|
||||
>
|
||||
{#if canWrite && isPdf && !transcribeMode && !annotateMode}
|
||||
{#if canWrite && isPdf && !transcribeMode}
|
||||
{@render transcribeBtn(true)}
|
||||
{/if}
|
||||
|
||||
{#if canAnnotate && isPdf && !annotateMode && !transcribeMode}
|
||||
{@render annotateBtn(true)}
|
||||
{/if}
|
||||
|
||||
{#if doc.filePath}
|
||||
{@render downloadLink(true)}
|
||||
{/if}
|
||||
@@ -339,9 +272,6 @@ let mobileMenuOpen = $state(false);
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hint strip — only when annotateMode, only at ≥768px -->
|
||||
<AnnotateHintStrip annotateMode={annotateMode} />
|
||||
|
||||
<!-- Metadata drawer -->
|
||||
{#if detailsOpen}
|
||||
<div transition:slide={{ duration: 200 }}>
|
||||
|
||||
@@ -16,10 +16,8 @@ type Props = {
|
||||
fileUrl: string;
|
||||
isLoading: boolean;
|
||||
error: string;
|
||||
annotateMode: boolean;
|
||||
transcribeMode?: boolean;
|
||||
activeAnnotationId: string | null;
|
||||
activeAnnotationPage: number | null;
|
||||
onAnnotationClick: (id: string) => void;
|
||||
onTranscriptionDraw?: (rect: DrawRect) => void;
|
||||
};
|
||||
@@ -29,10 +27,8 @@ let {
|
||||
fileUrl,
|
||||
isLoading,
|
||||
error,
|
||||
annotateMode = $bindable(),
|
||||
transcribeMode = false,
|
||||
activeAnnotationId = $bindable(),
|
||||
activeAnnotationPage = $bindable(),
|
||||
onAnnotationClick,
|
||||
onTranscriptionDraw
|
||||
}: Props = $props();
|
||||
@@ -86,10 +82,8 @@ let {
|
||||
<PdfViewer
|
||||
url={fileUrl}
|
||||
documentId={doc.id}
|
||||
bind:annotateMode={annotateMode}
|
||||
transcribeMode={transcribeMode}
|
||||
bind:activeAnnotationId={activeAnnotationId}
|
||||
bind:activeAnnotationPage={activeAnnotationPage}
|
||||
onAnnotationClick={onAnnotationClick}
|
||||
onTranscriptionDraw={onTranscriptionDraw}
|
||||
documentFileHash={doc.fileHash ?? null}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import type { PDFDocumentProxy, PDFPageProxy, RenderTask } from 'pdfjs-dist';
|
||||
import AnnotationLayer from './AnnotationLayer.svelte';
|
||||
import type { Annotation } from '$lib/types';
|
||||
@@ -11,20 +10,16 @@ type DrawRect = { x: number; y: number; width: number; height: number; pageNumbe
|
||||
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;
|
||||
@@ -51,13 +46,9 @@ let pdfjsLib: typeof import('pdfjs-dist') | null = null;
|
||||
let pdfjsReady = $state(false);
|
||||
|
||||
let annotations = $state<Annotation[]>([]);
|
||||
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)
|
||||
@@ -174,30 +165,12 @@ async function prerender(doc: PDFDocumentProxy, pageNum: number) {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCommentCounts(docId: string, anns: Annotation[]) {
|
||||
await Promise.all(
|
||||
anns.map(async (a) => {
|
||||
try {
|
||||
const res = await fetch(`/api/documents/${docId}/annotations/${a.id}/comments`);
|
||||
if (res.ok) {
|
||||
const threads = (await res.json()) as Array<{ replies: unknown[] }>;
|
||||
const total = threads.reduce((sum, t) => sum + 1 + t.replies.length, 0);
|
||||
commentCounts.set(a.id, total);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function loadAnnotations(docId: string) {
|
||||
if (!docId) return;
|
||||
try {
|
||||
const res = await fetch(`/api/documents/${docId}/annotations`);
|
||||
if (res.ok) {
|
||||
annotations = await res.json();
|
||||
await loadCommentCounts(docId, annotations);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
@@ -205,57 +178,13 @@ async function loadAnnotations(docId: string) {
|
||||
}
|
||||
|
||||
async function handleDraw(rect: { x: number; y: number; width: number; height: number }) {
|
||||
if (!documentId) return;
|
||||
|
||||
if (transcribeMode) {
|
||||
await onTranscriptionDraw?.({ ...rect, pageNumber: currentPage });
|
||||
await loadAnnotations(documentId);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/documents/${documentId}/annotations`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
pageNumber: currentPage,
|
||||
x: rect.x,
|
||||
y: rect.y,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
color: annotateColor
|
||||
})
|
||||
});
|
||||
if (res.ok) {
|
||||
const created: Annotation = await res.json();
|
||||
annotations = [...annotations, created];
|
||||
activeAnnotationId = created.id;
|
||||
activeAnnotationPage = created.pageNumber;
|
||||
onAnnotationClick?.(created.id);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAnnotationDelete(annotationId: string) {
|
||||
if (!documentId) return;
|
||||
try {
|
||||
const res = await fetch(`/api/documents/${documentId}/annotations/${annotationId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (res.ok) {
|
||||
annotations = annotations.filter((a) => a.id !== annotationId);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (!documentId || !transcribeMode) return;
|
||||
await onTranscriptionDraw?.({ ...rect, pageNumber: currentPage });
|
||||
await loadAnnotations(documentId);
|
||||
}
|
||||
|
||||
function handleAnnotationClick(id: string) {
|
||||
activeAnnotationId = id;
|
||||
const ann = annotations.find((a) => a.id === id);
|
||||
activeAnnotationPage = ann?.pageNumber ?? null;
|
||||
onAnnotationClick?.(id);
|
||||
}
|
||||
|
||||
@@ -283,7 +212,7 @@ $effect(() => {
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (annotateMode) showAnnotations = true;
|
||||
if (transcribeMode) showAnnotations = true;
|
||||
});
|
||||
|
||||
function prevPage() {
|
||||
@@ -429,16 +358,6 @@ function zoomOut() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Color picker (shown in annotate mode) -->
|
||||
{#if annotateMode}
|
||||
<input
|
||||
type="color"
|
||||
bind:value={annotateColor}
|
||||
aria-label="Farbe wählen"
|
||||
class="h-6 w-6 cursor-pointer rounded border-0 bg-transparent p-0"
|
||||
title="Farbe wählen"
|
||||
/>
|
||||
{/if}
|
||||
<!-- Annotation visibility toggle (shown when annotations exist) -->
|
||||
{#if annotations.length > 0}
|
||||
<button
|
||||
@@ -503,12 +422,9 @@ function zoomOut() {
|
||||
{#if showAnnotations}
|
||||
<AnnotationLayer
|
||||
annotations={visibleAnnotations.filter((a) => a.pageNumber === currentPage)}
|
||||
canAnnotate={drawingEnabled}
|
||||
color={drawColor}
|
||||
dimColor={transcribeMode ? '#ffff00' : annotateMode ? TRANSCRIPTION_COLOR : undefined}
|
||||
canDraw={transcribeMode}
|
||||
color={TRANSCRIPTION_COLOR}
|
||||
onDraw={handleDraw}
|
||||
onDelete={handleAnnotationDelete}
|
||||
commentCounts={Object.fromEntries(commentCounts)}
|
||||
onAnnotationClick={handleAnnotationClick}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -1,25 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/state';
|
||||
import DocumentTopBar from '$lib/components/DocumentTopBar.svelte';
|
||||
import DocumentViewer from '$lib/components/DocumentViewer.svelte';
|
||||
import AnnotationSidePanel from '$lib/components/AnnotationSidePanel.svelte';
|
||||
import TranscriptionEditView from '$lib/components/TranscriptionEditView.svelte';
|
||||
import type { TranscriptionBlockData } from '$lib/types';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
const targetCommentId = $derived(page.url.searchParams.get('commentId'));
|
||||
const targetAnnotationId = $derived(page.url.searchParams.get('annotationId'));
|
||||
|
||||
const doc = $derived(data.document);
|
||||
const canWrite = $derived(data.canWrite ?? false);
|
||||
const canComment = $derived((data.canAnnotate || data.canWrite) ?? false);
|
||||
const canAdmin = $derived(
|
||||
(data.user?.groups as Array<{ permissions: string[] }> | undefined)?.some((g) =>
|
||||
g.permissions.includes('ADMIN')
|
||||
) ?? false
|
||||
);
|
||||
const currentUserId = $derived((data.user?.id as string | undefined) ?? null);
|
||||
|
||||
// ── File loading ──────────────────────────────────────────────────────────────
|
||||
@@ -57,12 +46,10 @@ async function loadFile(id: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Mode state (mutually exclusive) ──────────────────────────────────────────
|
||||
// ── Mode state ───────────────────────────────────────────────────────────────
|
||||
|
||||
let annotateMode = $state(false);
|
||||
let transcribeMode = $state(false);
|
||||
let activeAnnotationId = $state<string | null>(null);
|
||||
let activeAnnotationPage = $state<number | null>(null);
|
||||
|
||||
// ── Transcription blocks ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -137,6 +124,18 @@ function handleBlockFocus(blockId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function handleAnnotationClick(annotationId: string) {
|
||||
activeAnnotationId = annotationId;
|
||||
// In transcribe mode, focus the block that owns this annotation
|
||||
if (transcribeMode) {
|
||||
const block = transcriptionBlocks.find((b) => b.annotationId === annotationId);
|
||||
if (block) {
|
||||
const el = document.querySelector(`[data-block-id="${block.id}"]`);
|
||||
el?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load blocks when transcribe mode is entered
|
||||
$effect(() => {
|
||||
if (transcribeMode) {
|
||||
@@ -151,10 +150,6 @@ let navHeight = $state(0);
|
||||
onMount(() => {
|
||||
navHeight = document.querySelector('header')?.getBoundingClientRect().height ?? 0;
|
||||
|
||||
if (targetAnnotationId) {
|
||||
activeAnnotationId = targetAnnotationId;
|
||||
}
|
||||
|
||||
if (doc?.id) {
|
||||
localStorage.setItem(
|
||||
'familienarchiv.lastVisited',
|
||||
@@ -163,13 +158,8 @@ onMount(() => {
|
||||
}
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
if (transcribeMode) {
|
||||
transcribeMode = false;
|
||||
} else if (activeAnnotationId) {
|
||||
activeAnnotationId = null;
|
||||
activeAnnotationPage = null;
|
||||
}
|
||||
if (e.key === 'Escape' && transcribeMode) {
|
||||
transcribeMode = false;
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
@@ -189,9 +179,7 @@ onMount(() => {
|
||||
<DocumentTopBar
|
||||
doc={doc}
|
||||
canWrite={canWrite}
|
||||
canAnnotate={data.canAnnotate ?? false}
|
||||
fileUrl={fileUrl}
|
||||
bind:annotateMode={annotateMode}
|
||||
bind:transcribeMode={transcribeMode}
|
||||
/>
|
||||
|
||||
@@ -202,39 +190,19 @@ onMount(() => {
|
||||
fileUrl={fileUrl}
|
||||
isLoading={isLoading}
|
||||
error={fileError}
|
||||
bind:annotateMode={annotateMode}
|
||||
transcribeMode={transcribeMode}
|
||||
bind:activeAnnotationId={activeAnnotationId}
|
||||
bind:activeAnnotationPage={activeAnnotationPage}
|
||||
onAnnotationClick={(id) => {
|
||||
activeAnnotationId = id;
|
||||
}}
|
||||
onAnnotationClick={handleAnnotationClick}
|
||||
onTranscriptionDraw={createBlockFromDraw}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if !transcribeMode}
|
||||
<AnnotationSidePanel
|
||||
documentId={doc.id}
|
||||
activeAnnotationId={activeAnnotationId}
|
||||
activeAnnotationPage={activeAnnotationPage}
|
||||
canComment={canComment}
|
||||
currentUserId={currentUserId}
|
||||
canAdmin={canAdmin}
|
||||
targetCommentId={targetAnnotationId ? targetCommentId : null}
|
||||
onClose={() => {
|
||||
activeAnnotationId = null;
|
||||
activeAnnotationPage = null;
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if transcribeMode}
|
||||
<div class="w-[400px] shrink-0 border-l border-line lg:w-[480px]">
|
||||
<TranscriptionEditView
|
||||
documentId={doc.id}
|
||||
blocks={transcriptionBlocks}
|
||||
canComment={canComment}
|
||||
canComment={canWrite}
|
||||
currentUserId={currentUserId}
|
||||
onBlockFocus={handleBlockFocus}
|
||||
onSaveBlock={saveBlock}
|
||||
|
||||
Reference in New Issue
Block a user