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 {
|
let {
|
||||||
annotations = [],
|
annotations = [],
|
||||||
canAnnotate,
|
canDraw,
|
||||||
color,
|
color,
|
||||||
dimColor,
|
|
||||||
onDraw,
|
onDraw,
|
||||||
onDelete,
|
|
||||||
commentCounts,
|
|
||||||
onAnnotationClick
|
onAnnotationClick
|
||||||
}: {
|
}: {
|
||||||
annotations: Annotation[];
|
annotations: Annotation[];
|
||||||
canAnnotate: boolean;
|
canDraw: boolean;
|
||||||
color: string;
|
color: string;
|
||||||
dimColor?: string;
|
onDraw: (rect: DrawRect) => void;
|
||||||
onDraw: (rect: { x: number; y: number; width: number; height: number }) => void;
|
|
||||||
onDelete: (id: string) => void;
|
|
||||||
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);
|
||||||
|
|
||||||
@@ -52,7 +41,7 @@ function getNormalizedCoords(event: PointerEvent, element: HTMLElement): { x: nu
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handlePointerDown(event: PointerEvent) {
|
function handlePointerDown(event: PointerEvent) {
|
||||||
if (!canAnnotate) return;
|
if (!canDraw) return;
|
||||||
|
|
||||||
if ((event.target as HTMLElement).closest('[data-annotation]')) return;
|
if ((event.target as HTMLElement).closest('[data-annotation]')) return;
|
||||||
|
|
||||||
@@ -65,7 +54,7 @@ function handlePointerDown(event: PointerEvent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handlePointerMove(event: PointerEvent) {
|
function handlePointerMove(event: PointerEvent) {
|
||||||
if (!canAnnotate || !drawStart) return;
|
if (!canDraw || !drawStart) return;
|
||||||
|
|
||||||
const container = event.currentTarget as HTMLElement;
|
const container = event.currentTarget as HTMLElement;
|
||||||
const coords = getNormalizedCoords(event, container);
|
const coords = getNormalizedCoords(event, container);
|
||||||
@@ -79,7 +68,7 @@ function handlePointerMove(event: PointerEvent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handlePointerUp(event: PointerEvent) {
|
function handlePointerUp(event: PointerEvent) {
|
||||||
if (!canAnnotate || !drawStart || !drawRect) return;
|
if (!canDraw || !drawStart || !drawRect) return;
|
||||||
|
|
||||||
const container = event.currentTarget as HTMLElement;
|
const container = event.currentTarget as HTMLElement;
|
||||||
const coords = getNormalizedCoords(event, container);
|
const coords = getNormalizedCoords(event, container);
|
||||||
@@ -100,7 +89,7 @@ function handlePointerUp(event: PointerEvent) {
|
|||||||
let hoveredId = $state<string | null>(null);
|
let hoveredId = $state<string | null>(null);
|
||||||
|
|
||||||
const containerStyle = $derived(
|
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>
|
</script>
|
||||||
|
|
||||||
@@ -117,9 +106,11 @@ const containerStyle = $derived(
|
|||||||
data-annotation
|
data-annotation
|
||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
aria-label="Kommentare anzeigen"
|
aria-label="Block anzeigen"
|
||||||
onclick={() => onAnnotationClick?.(annotation.id)}
|
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)}
|
onpointerenter={() => (hoveredId = annotation.id)}
|
||||||
onpointerleave={() => (hoveredId = null)}
|
onpointerleave={() => (hoveredId = null)}
|
||||||
style="
|
style="
|
||||||
@@ -130,73 +121,11 @@ 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'};
|
||||||
opacity: {isDimmed(annotation) ? 0.3 : 1};
|
pointer-events: auto;
|
||||||
pointer-events: {isDimmed(annotation) ? 'none' : 'auto'};
|
cursor: pointer;
|
||||||
transition: background-color 0.15s ease, box-shadow 0.15s ease, opacity 0.3s ease;
|
transition: background-color 0.15s ease, box-shadow 0.15s ease;
|
||||||
{onAnnotationClick && !canAnnotate ? 'cursor: pointer;' : ''}
|
|
||||||
"
|
"
|
||||||
>
|
></div>
|
||||||
{#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>
|
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
{#if drawRect && drawRect.width > 0}
|
{#if drawRect && drawRect.width > 0}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ type Annotation = {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function makeAnnotation(id = 'ann-1'): Annotation {
|
function makeAnnotation(id = 'ann-1', color = '#00C7B1'): Annotation {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
documentId: 'doc-1',
|
documentId: 'doc-1',
|
||||||
@@ -27,7 +27,7 @@ function makeAnnotation(id = 'ann-1'): Annotation {
|
|||||||
y: 0.1,
|
y: 0.1,
|
||||||
width: 0.3,
|
width: 0.3,
|
||||||
height: 0.2,
|
height: 0.2,
|
||||||
color: '#ff0000',
|
color,
|
||||||
createdAt: new Date().toISOString()
|
createdAt: new Date().toISOString()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -36,87 +36,48 @@ describe('AnnotationLayer', () => {
|
|||||||
it('renders a colored element for each annotation', async () => {
|
it('renders a colored element for each annotation', async () => {
|
||||||
render(AnnotationLayer, {
|
render(AnnotationLayer, {
|
||||||
annotations: [makeAnnotation('ann-1'), makeAnnotation('ann-2')],
|
annotations: [makeAnnotation('ann-1'), makeAnnotation('ann-2')],
|
||||||
canAnnotate: false,
|
canDraw: false,
|
||||||
color: '#ff0000',
|
color: '#00C7B1',
|
||||||
onDraw: () => {},
|
onDraw: () => {}
|
||||||
onDelete: () => {}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect.element(page.getByTestId('annotation-ann-1')).toBeInTheDocument();
|
await expect.element(page.getByTestId('annotation-ann-1')).toBeInTheDocument();
|
||||||
await expect.element(page.getByTestId('annotation-ann-2')).toBeInTheDocument();
|
await expect.element(page.getByTestId('annotation-ann-2')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows a delete button for each annotation when canAnnotate is true', async () => {
|
it('has crosshair cursor when canDraw 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 () => {
|
|
||||||
render(AnnotationLayer, {
|
render(AnnotationLayer, {
|
||||||
annotations: [],
|
annotations: [],
|
||||||
canAnnotate: true,
|
canDraw: true,
|
||||||
color: '#00C7B1',
|
color: '#00C7B1',
|
||||||
onDraw: () => {},
|
onDraw: () => {}
|
||||||
onDelete: () => {}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const container = document.querySelector('[role="presentation"]')!;
|
const container = document.querySelector('[role="presentation"]')!;
|
||||||
expect(container.getAttribute('style')).toContain('cursor: crosshair');
|
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 { formatDate } from '$lib/utils/personFormat';
|
||||||
import { clickOutside } from '$lib/actions/clickOutside';
|
import { clickOutside } from '$lib/actions/clickOutside';
|
||||||
import PersonChipRow from './PersonChipRow.svelte';
|
import PersonChipRow from './PersonChipRow.svelte';
|
||||||
import AnnotateHintStrip from './AnnotateHintStrip.svelte';
|
|
||||||
import OverflowPillButton from './OverflowPillButton.svelte';
|
import OverflowPillButton from './OverflowPillButton.svelte';
|
||||||
import DocumentMetadataDrawer from './DocumentMetadataDrawer.svelte';
|
import DocumentMetadataDrawer from './DocumentMetadataDrawer.svelte';
|
||||||
|
|
||||||
@@ -28,20 +27,11 @@ type Doc = {
|
|||||||
type Props = {
|
type Props = {
|
||||||
doc: Doc;
|
doc: Doc;
|
||||||
canWrite: boolean;
|
canWrite: boolean;
|
||||||
canAnnotate: boolean;
|
|
||||||
fileUrl: string;
|
fileUrl: string;
|
||||||
annotateMode: boolean;
|
|
||||||
transcribeMode: boolean;
|
transcribeMode: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
let {
|
let { doc, canWrite, fileUrl, transcribeMode = $bindable() }: Props = $props();
|
||||||
doc,
|
|
||||||
canWrite,
|
|
||||||
canAnnotate,
|
|
||||||
fileUrl,
|
|
||||||
annotateMode = $bindable(),
|
|
||||||
transcribeMode = $bindable()
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
let detailsOpen = $state(false);
|
let detailsOpen = $state(false);
|
||||||
|
|
||||||
@@ -56,55 +46,10 @@ const longDate = $derived(doc.documentDate ? formatDate(doc.documentDate, 'long'
|
|||||||
let mobileMenuOpen = $state(false);
|
let mobileMenuOpen = $state(false);
|
||||||
</script>
|
</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)}
|
{#snippet transcribeBtn(mobile: boolean)}
|
||||||
<button
|
<button
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
transcribeMode = true;
|
transcribeMode = true;
|
||||||
annotateMode = false;
|
|
||||||
if (mobile) mobileMenuOpen = false;
|
if (mobile) mobileMenuOpen = false;
|
||||||
}}
|
}}
|
||||||
aria-label={m.transcription_mode_label()}
|
aria-label={m.transcription_mode_label()}
|
||||||
@@ -256,7 +201,7 @@ let mobileMenuOpen = $state(false);
|
|||||||
|
|
||||||
<!-- Action buttons -->
|
<!-- Action buttons -->
|
||||||
<div class="flex shrink-0 items-center gap-1.5 pr-3 font-sans">
|
<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)}
|
{@render transcribeBtn(false)}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -264,15 +209,7 @@ let mobileMenuOpen = $state(false);
|
|||||||
{@render transcribeStopBtn(false)}
|
{@render transcribeStopBtn(false)}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if canAnnotate && isPdf && !annotateMode && !transcribeMode}
|
{#if canWrite && !transcribeMode}
|
||||||
{@render annotateBtn(false)}
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if canAnnotate && isPdf && annotateMode}
|
|
||||||
{@render annotateStopBtn(false)}
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if canWrite && !annotateMode && !transcribeMode}
|
|
||||||
<a
|
<a
|
||||||
href="/documents/{doc.id}/edit"
|
href="/documents/{doc.id}/edit"
|
||||||
aria-label={m.btn_edit()}
|
aria-label={m.btn_edit()}
|
||||||
@@ -288,12 +225,12 @@ let mobileMenuOpen = $state(false);
|
|||||||
</a>
|
</a>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if doc.filePath && !annotateMode}
|
{#if doc.filePath && !transcribeMode}
|
||||||
{@render downloadLink(false)}
|
{@render downloadLink(false)}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Kebab menu — mobile only, contains actions hidden below md -->
|
<!-- Kebab menu — mobile only, contains actions hidden below md -->
|
||||||
{#if (canAnnotate && isPdf) || doc.filePath}
|
{#if (canWrite && isPdf) || doc.filePath}
|
||||||
<div
|
<div
|
||||||
role="group"
|
role="group"
|
||||||
class="relative md:hidden"
|
class="relative md:hidden"
|
||||||
@@ -321,14 +258,10 @@ let mobileMenuOpen = $state(false);
|
|||||||
role="menu"
|
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"
|
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)}
|
{@render transcribeBtn(true)}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if canAnnotate && isPdf && !annotateMode && !transcribeMode}
|
|
||||||
{@render annotateBtn(true)}
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if doc.filePath}
|
{#if doc.filePath}
|
||||||
{@render downloadLink(true)}
|
{@render downloadLink(true)}
|
||||||
{/if}
|
{/if}
|
||||||
@@ -339,9 +272,6 @@ let mobileMenuOpen = $state(false);
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Hint strip — only when annotateMode, only at ≥768px -->
|
|
||||||
<AnnotateHintStrip annotateMode={annotateMode} />
|
|
||||||
|
|
||||||
<!-- Metadata drawer -->
|
<!-- Metadata drawer -->
|
||||||
{#if detailsOpen}
|
{#if detailsOpen}
|
||||||
<div transition:slide={{ duration: 200 }}>
|
<div transition:slide={{ duration: 200 }}>
|
||||||
|
|||||||
@@ -16,10 +16,8 @@ type Props = {
|
|||||||
fileUrl: string;
|
fileUrl: string;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
error: string;
|
error: string;
|
||||||
annotateMode: boolean;
|
|
||||||
transcribeMode?: boolean;
|
transcribeMode?: boolean;
|
||||||
activeAnnotationId: string | null;
|
activeAnnotationId: string | null;
|
||||||
activeAnnotationPage: number | null;
|
|
||||||
onAnnotationClick: (id: string) => void;
|
onAnnotationClick: (id: string) => void;
|
||||||
onTranscriptionDraw?: (rect: DrawRect) => void;
|
onTranscriptionDraw?: (rect: DrawRect) => void;
|
||||||
};
|
};
|
||||||
@@ -29,10 +27,8 @@ let {
|
|||||||
fileUrl,
|
fileUrl,
|
||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
annotateMode = $bindable(),
|
|
||||||
transcribeMode = false,
|
transcribeMode = false,
|
||||||
activeAnnotationId = $bindable(),
|
activeAnnotationId = $bindable(),
|
||||||
activeAnnotationPage = $bindable(),
|
|
||||||
onAnnotationClick,
|
onAnnotationClick,
|
||||||
onTranscriptionDraw
|
onTranscriptionDraw
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
@@ -86,10 +82,8 @@ let {
|
|||||||
<PdfViewer
|
<PdfViewer
|
||||||
url={fileUrl}
|
url={fileUrl}
|
||||||
documentId={doc.id}
|
documentId={doc.id}
|
||||||
bind:annotateMode={annotateMode}
|
|
||||||
transcribeMode={transcribeMode}
|
transcribeMode={transcribeMode}
|
||||||
bind:activeAnnotationId={activeAnnotationId}
|
bind:activeAnnotationId={activeAnnotationId}
|
||||||
bind:activeAnnotationPage={activeAnnotationPage}
|
|
||||||
onAnnotationClick={onAnnotationClick}
|
onAnnotationClick={onAnnotationClick}
|
||||||
onTranscriptionDraw={onTranscriptionDraw}
|
onTranscriptionDraw={onTranscriptionDraw}
|
||||||
documentFileHash={doc.fileHash ?? null}
|
documentFileHash={doc.fileHash ?? null}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { SvelteMap } from 'svelte/reactivity';
|
|
||||||
import type { PDFDocumentProxy, PDFPageProxy, RenderTask } from 'pdfjs-dist';
|
import type { PDFDocumentProxy, PDFPageProxy, RenderTask } from 'pdfjs-dist';
|
||||||
import AnnotationLayer from './AnnotationLayer.svelte';
|
import AnnotationLayer from './AnnotationLayer.svelte';
|
||||||
import type { Annotation } from '$lib/types';
|
import type { Annotation } from '$lib/types';
|
||||||
@@ -11,20 +10,16 @@ type DrawRect = { x: number; y: number; width: number; height: number; pageNumbe
|
|||||||
let {
|
let {
|
||||||
url,
|
url,
|
||||||
documentId = '',
|
documentId = '',
|
||||||
annotateMode = $bindable(false),
|
|
||||||
transcribeMode = false,
|
transcribeMode = false,
|
||||||
activeAnnotationId = $bindable<string | null>(null),
|
activeAnnotationId = $bindable<string | null>(null),
|
||||||
activeAnnotationPage = $bindable<number | null>(null),
|
|
||||||
onAnnotationClick,
|
onAnnotationClick,
|
||||||
onTranscriptionDraw,
|
onTranscriptionDraw,
|
||||||
documentFileHash
|
documentFileHash
|
||||||
}: {
|
}: {
|
||||||
url: string;
|
url: string;
|
||||||
documentId?: string;
|
documentId?: string;
|
||||||
annotateMode?: boolean;
|
|
||||||
transcribeMode?: boolean;
|
transcribeMode?: boolean;
|
||||||
activeAnnotationId?: string | null;
|
activeAnnotationId?: string | null;
|
||||||
activeAnnotationPage?: number | null;
|
|
||||||
onAnnotationClick?: (id: string) => void;
|
onAnnotationClick?: (id: string) => void;
|
||||||
onTranscriptionDraw?: (rect: DrawRect) => void;
|
onTranscriptionDraw?: (rect: DrawRect) => void;
|
||||||
documentFileHash?: string | null;
|
documentFileHash?: string | null;
|
||||||
@@ -51,13 +46,9 @@ let pdfjsLib: typeof import('pdfjs-dist') | null = null;
|
|||||||
let pdfjsReady = $state(false);
|
let pdfjsReady = $state(false);
|
||||||
|
|
||||||
let annotations = $state<Annotation[]>([]);
|
let annotations = $state<Annotation[]>([]);
|
||||||
let annotateColor = $state('#ffff00');
|
|
||||||
let commentCounts = new SvelteMap<string, number>();
|
|
||||||
let showAnnotations = $state(true);
|
let showAnnotations = $state(true);
|
||||||
|
|
||||||
const TRANSCRIPTION_COLOR = '#00C7B1';
|
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)
|
||||||
@@ -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) {
|
async function loadAnnotations(docId: string) {
|
||||||
if (!docId) return;
|
if (!docId) return;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/documents/${docId}/annotations`);
|
const res = await fetch(`/api/documents/${docId}/annotations`);
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
annotations = await res.json();
|
annotations = await res.json();
|
||||||
await loadCommentCounts(docId, annotations);
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
@@ -205,57 +178,13 @@ async function loadAnnotations(docId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleDraw(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 || !transcribeMode) return;
|
||||||
|
await onTranscriptionDraw?.({ ...rect, pageNumber: currentPage });
|
||||||
if (transcribeMode) {
|
await loadAnnotations(documentId);
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleAnnotationClick(id: string) {
|
function handleAnnotationClick(id: string) {
|
||||||
activeAnnotationId = id;
|
activeAnnotationId = id;
|
||||||
const ann = annotations.find((a) => a.id === id);
|
|
||||||
activeAnnotationPage = ann?.pageNumber ?? null;
|
|
||||||
onAnnotationClick?.(id);
|
onAnnotationClick?.(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,7 +212,7 @@ $effect(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (annotateMode) showAnnotations = true;
|
if (transcribeMode) showAnnotations = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
function prevPage() {
|
function prevPage() {
|
||||||
@@ -429,16 +358,6 @@ function zoomOut() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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) -->
|
<!-- Annotation visibility toggle (shown when annotations exist) -->
|
||||||
{#if annotations.length > 0}
|
{#if annotations.length > 0}
|
||||||
<button
|
<button
|
||||||
@@ -503,12 +422,9 @@ function zoomOut() {
|
|||||||
{#if showAnnotations}
|
{#if showAnnotations}
|
||||||
<AnnotationLayer
|
<AnnotationLayer
|
||||||
annotations={visibleAnnotations.filter((a) => a.pageNumber === currentPage)}
|
annotations={visibleAnnotations.filter((a) => a.pageNumber === currentPage)}
|
||||||
canAnnotate={drawingEnabled}
|
canDraw={transcribeMode}
|
||||||
color={drawColor}
|
color={TRANSCRIPTION_COLOR}
|
||||||
dimColor={transcribeMode ? '#ffff00' : annotateMode ? TRANSCRIPTION_COLOR : undefined}
|
|
||||||
onDraw={handleDraw}
|
onDraw={handleDraw}
|
||||||
onDelete={handleAnnotationDelete}
|
|
||||||
commentCounts={Object.fromEntries(commentCounts)}
|
|
||||||
onAnnotationClick={handleAnnotationClick}
|
onAnnotationClick={handleAnnotationClick}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,25 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { page } from '$app/state';
|
|
||||||
import DocumentTopBar from '$lib/components/DocumentTopBar.svelte';
|
import DocumentTopBar from '$lib/components/DocumentTopBar.svelte';
|
||||||
import DocumentViewer from '$lib/components/DocumentViewer.svelte';
|
import DocumentViewer from '$lib/components/DocumentViewer.svelte';
|
||||||
import AnnotationSidePanel from '$lib/components/AnnotationSidePanel.svelte';
|
|
||||||
import TranscriptionEditView from '$lib/components/TranscriptionEditView.svelte';
|
import TranscriptionEditView from '$lib/components/TranscriptionEditView.svelte';
|
||||||
import type { TranscriptionBlockData } from '$lib/types';
|
import type { TranscriptionBlockData } from '$lib/types';
|
||||||
|
|
||||||
let { data } = $props();
|
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 doc = $derived(data.document);
|
||||||
const canWrite = $derived(data.canWrite ?? false);
|
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);
|
const currentUserId = $derived((data.user?.id as string | undefined) ?? null);
|
||||||
|
|
||||||
// ── File loading ──────────────────────────────────────────────────────────────
|
// ── 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 transcribeMode = $state(false);
|
||||||
let activeAnnotationId = $state<string | null>(null);
|
let activeAnnotationId = $state<string | null>(null);
|
||||||
let activeAnnotationPage = $state<number | null>(null);
|
|
||||||
|
|
||||||
// ── Transcription blocks ─────────────────────────────────────────────────────
|
// ── 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
|
// Load blocks when transcribe mode is entered
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (transcribeMode) {
|
if (transcribeMode) {
|
||||||
@@ -151,10 +150,6 @@ let navHeight = $state(0);
|
|||||||
onMount(() => {
|
onMount(() => {
|
||||||
navHeight = document.querySelector('header')?.getBoundingClientRect().height ?? 0;
|
navHeight = document.querySelector('header')?.getBoundingClientRect().height ?? 0;
|
||||||
|
|
||||||
if (targetAnnotationId) {
|
|
||||||
activeAnnotationId = targetAnnotationId;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (doc?.id) {
|
if (doc?.id) {
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
'familienarchiv.lastVisited',
|
'familienarchiv.lastVisited',
|
||||||
@@ -163,13 +158,8 @@ onMount(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onKeyDown(e: KeyboardEvent) {
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape' && transcribeMode) {
|
||||||
if (transcribeMode) {
|
transcribeMode = false;
|
||||||
transcribeMode = false;
|
|
||||||
} else if (activeAnnotationId) {
|
|
||||||
activeAnnotationId = null;
|
|
||||||
activeAnnotationPage = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
document.addEventListener('keydown', onKeyDown);
|
document.addEventListener('keydown', onKeyDown);
|
||||||
@@ -189,9 +179,7 @@ onMount(() => {
|
|||||||
<DocumentTopBar
|
<DocumentTopBar
|
||||||
doc={doc}
|
doc={doc}
|
||||||
canWrite={canWrite}
|
canWrite={canWrite}
|
||||||
canAnnotate={data.canAnnotate ?? false}
|
|
||||||
fileUrl={fileUrl}
|
fileUrl={fileUrl}
|
||||||
bind:annotateMode={annotateMode}
|
|
||||||
bind:transcribeMode={transcribeMode}
|
bind:transcribeMode={transcribeMode}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -202,39 +190,19 @@ onMount(() => {
|
|||||||
fileUrl={fileUrl}
|
fileUrl={fileUrl}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
error={fileError}
|
error={fileError}
|
||||||
bind:annotateMode={annotateMode}
|
|
||||||
transcribeMode={transcribeMode}
|
transcribeMode={transcribeMode}
|
||||||
bind:activeAnnotationId={activeAnnotationId}
|
bind:activeAnnotationId={activeAnnotationId}
|
||||||
bind:activeAnnotationPage={activeAnnotationPage}
|
onAnnotationClick={handleAnnotationClick}
|
||||||
onAnnotationClick={(id) => {
|
|
||||||
activeAnnotationId = id;
|
|
||||||
}}
|
|
||||||
onTranscriptionDraw={createBlockFromDraw}
|
onTranscriptionDraw={createBlockFromDraw}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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}
|
{#if transcribeMode}
|
||||||
<div class="w-[400px] shrink-0 border-l border-line lg:w-[480px]">
|
<div class="w-[400px] shrink-0 border-l border-line lg:w-[480px]">
|
||||||
<TranscriptionEditView
|
<TranscriptionEditView
|
||||||
documentId={doc.id}
|
documentId={doc.id}
|
||||||
blocks={transcriptionBlocks}
|
blocks={transcriptionBlocks}
|
||||||
canComment={canComment}
|
canComment={canWrite}
|
||||||
currentUserId={currentUserId}
|
currentUserId={currentUserId}
|
||||||
onBlockFocus={handleBlockFocus}
|
onBlockFocus={handleBlockFocus}
|
||||||
onSaveBlock={saveBlock}
|
onSaveBlock={saveBlock}
|
||||||
|
|||||||
Reference in New Issue
Block a user