feat: Transcription read mode (clean split) #177 #205
@@ -468,5 +468,16 @@
|
|||||||
"transcription_quote_stale": "Zitat aus älterer Version",
|
"transcription_quote_stale": "Zitat aus älterer Version",
|
||||||
"transcription_block_conflict": "Dieser Block wurde von jemand anderem geändert — bitte neu laden",
|
"transcription_block_conflict": "Dieser Block wurde von jemand anderem geändert — bitte neu laden",
|
||||||
"sort_dir_asc": "Aufsteigend sortieren",
|
"sort_dir_asc": "Aufsteigend sortieren",
|
||||||
"sort_dir_desc": "Absteigend sortieren"
|
"sort_dir_desc": "Absteigend sortieren",
|
||||||
|
"mode_read": "Lesen",
|
||||||
|
"mode_edit": "Bearbeiten",
|
||||||
|
"mode_edit_short": "Bearb.",
|
||||||
|
"transcription_status_section": "1 Abschnitt",
|
||||||
|
"transcription_status_sections": "{count} Abschnitte",
|
||||||
|
"transcription_status_last_edited": "Zuletzt bearbeitet: {time}",
|
||||||
|
"scan_expand": "Scan vergrößern",
|
||||||
|
"scan_collapse": "Scan verkleinern",
|
||||||
|
"transcription_empty_title": "Noch keine Transkription",
|
||||||
|
"transcription_empty_desc": "Zeichne Bereiche auf dem Scan und tippe den Text ab, um eine Transkription zu erstellen.",
|
||||||
|
"transcription_panel_close": "Panel schließen"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -468,5 +468,16 @@
|
|||||||
"transcription_quote_stale": "Quote from an older version",
|
"transcription_quote_stale": "Quote from an older version",
|
||||||
"transcription_block_conflict": "This block was changed by someone else — please reload",
|
"transcription_block_conflict": "This block was changed by someone else — please reload",
|
||||||
"sort_dir_asc": "Sort ascending",
|
"sort_dir_asc": "Sort ascending",
|
||||||
"sort_dir_desc": "Sort descending"
|
"sort_dir_desc": "Sort descending",
|
||||||
|
"mode_read": "Read",
|
||||||
|
"mode_edit": "Edit",
|
||||||
|
"mode_edit_short": "Edit",
|
||||||
|
"transcription_status_section": "1 section",
|
||||||
|
"transcription_status_sections": "{count} sections",
|
||||||
|
"transcription_status_last_edited": "Last edited: {time}",
|
||||||
|
"scan_expand": "Expand scan",
|
||||||
|
"scan_collapse": "Collapse scan",
|
||||||
|
"transcription_empty_title": "No transcription yet",
|
||||||
|
"transcription_empty_desc": "Draw regions on the scan and type the text to create a transcription.",
|
||||||
|
"transcription_panel_close": "Close panel"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -468,5 +468,16 @@
|
|||||||
"transcription_quote_stale": "Cita de una versión anterior",
|
"transcription_quote_stale": "Cita de una versión anterior",
|
||||||
"transcription_block_conflict": "Este bloque fue cambiado por otra persona — por favor recargue",
|
"transcription_block_conflict": "Este bloque fue cambiado por otra persona — por favor recargue",
|
||||||
"sort_dir_asc": "Ordenar ascendente",
|
"sort_dir_asc": "Ordenar ascendente",
|
||||||
"sort_dir_desc": "Ordenar descendente"
|
"sort_dir_desc": "Ordenar descendente",
|
||||||
|
"mode_read": "Leer",
|
||||||
|
"mode_edit": "Editar",
|
||||||
|
"mode_edit_short": "Edit.",
|
||||||
|
"transcription_status_section": "1 seccion",
|
||||||
|
"transcription_status_sections": "{count} secciones",
|
||||||
|
"transcription_status_last_edited": "Ultima edicion: {time}",
|
||||||
|
"scan_expand": "Ampliar escaneo",
|
||||||
|
"scan_collapse": "Reducir escaneo",
|
||||||
|
"transcription_empty_title": "Sin transcripcion",
|
||||||
|
"transcription_empty_desc": "Dibuja regiones en el escaneo y escribe el texto para crear una transcripcion.",
|
||||||
|
"transcription_panel_close": "Cerrar panel"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ let {
|
|||||||
color,
|
color,
|
||||||
blockNumbers = {},
|
blockNumbers = {},
|
||||||
activeAnnotationId = null,
|
activeAnnotationId = null,
|
||||||
|
dimmed = false,
|
||||||
|
flashAnnotationId = null,
|
||||||
onDraw,
|
onDraw,
|
||||||
onAnnotationClick
|
onAnnotationClick
|
||||||
}: {
|
}: {
|
||||||
@@ -22,6 +24,8 @@ let {
|
|||||||
color: string;
|
color: string;
|
||||||
blockNumbers?: Record<string, number>;
|
blockNumbers?: Record<string, number>;
|
||||||
activeAnnotationId?: string | null;
|
activeAnnotationId?: string | null;
|
||||||
|
dimmed?: boolean;
|
||||||
|
flashAnnotationId?: string | null;
|
||||||
onDraw: (rect: DrawRect) => void;
|
onDraw: (rect: DrawRect) => void;
|
||||||
onAnnotationClick?: (id: string) => void;
|
onAnnotationClick?: (id: string) => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
@@ -108,6 +112,7 @@ const containerStyle = $derived(
|
|||||||
<div
|
<div
|
||||||
data-testid="annotation-{annotation.id}"
|
data-testid="annotation-{annotation.id}"
|
||||||
data-annotation
|
data-annotation
|
||||||
|
class:annotation-flash={flashAnnotationId === annotation.id}
|
||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
aria-label="Block anzeigen"
|
aria-label="Block anzeigen"
|
||||||
@@ -123,15 +128,15 @@ const containerStyle = $derived(
|
|||||||
top: {annotation.y * 100}%;
|
top: {annotation.y * 100}%;
|
||||||
width: {annotation.width * 100}%;
|
width: {annotation.width * 100}%;
|
||||||
height: {annotation.height * 100}%;
|
height: {annotation.height * 100}%;
|
||||||
background-color: {hexToRgba(annotation.color, hoveredId === annotation.id || annotation.id === activeAnnotationId ? 0.5 : 0.3)};
|
background-color: {hexToRgba(annotation.color, dimmed ? 0.3 : (hoveredId === annotation.id || annotation.id === activeAnnotationId ? 0.5 : 0.3))};
|
||||||
box-shadow: {annotation.id === activeAnnotationId ? `inset 0 0 0 2px ${hexToRgba(annotation.color, 0.8)}` : hoveredId === annotation.id ? `inset 0 0 0 2px ${hexToRgba(annotation.color, 0.8)}` : 'none'};
|
box-shadow: {dimmed ? 'none' : (annotation.id === activeAnnotationId ? `inset 0 0 0 2px ${hexToRgba(annotation.color, 0.8)}` : hoveredId === annotation.id ? `inset 0 0 0 2px ${hexToRgba(annotation.color, 0.8)}` : 'none')};
|
||||||
opacity: {activeAnnotationId && annotation.id !== activeAnnotationId ? 0.3 : 1};
|
opacity: {dimmed ? 1 : (activeAnnotationId && annotation.id !== activeAnnotationId ? 0.3 : 1)};
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
cursor: pointer;
|
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, opacity 0.3s ease;
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
{#if blockNumbers[annotation.id]}
|
{#if !dimmed && blockNumbers[annotation.id]}
|
||||||
<div
|
<div
|
||||||
style="
|
style="
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -173,3 +178,27 @@ const containerStyle = $derived(
|
|||||||
></div>
|
></div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@keyframes annotation-flash-anim {
|
||||||
|
0% {
|
||||||
|
outline: 3px solid color-mix(in srgb, var(--color-turquoise) 80%, transparent);
|
||||||
|
outline-offset: 0px;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
outline: 3px solid color-mix(in srgb, var(--color-turquoise) 0%, transparent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotation-flash {
|
||||||
|
animation: annotation-flash-anim 1.5s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.annotation-flash {
|
||||||
|
animation: none;
|
||||||
|
outline: 3px solid color-mix(in srgb, var(--color-turquoise) 80%, transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
95
frontend/src/lib/components/AnnotationLayer.svelte.test.ts
Normal file
95
frontend/src/lib/components/AnnotationLayer.svelte.test.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render } from 'vitest-browser-svelte';
|
||||||
|
import { page } from 'vitest/browser';
|
||||||
|
import AnnotationLayer from './AnnotationLayer.svelte';
|
||||||
|
import type { Annotation } from '$lib/types';
|
||||||
|
|
||||||
|
const annotation: Annotation = {
|
||||||
|
id: 'ann-1',
|
||||||
|
documentId: 'doc-1',
|
||||||
|
pageNumber: 1,
|
||||||
|
x: 0.1,
|
||||||
|
y: 0.2,
|
||||||
|
width: 0.3,
|
||||||
|
height: 0.1,
|
||||||
|
color: '#00c7b1',
|
||||||
|
createdAt: '2026-01-01T00:00:00Z'
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('AnnotationLayer', () => {
|
||||||
|
describe('dimmed prop', () => {
|
||||||
|
it('should hide block number badges when dimmed is true', async () => {
|
||||||
|
render(AnnotationLayer, {
|
||||||
|
annotations: [annotation],
|
||||||
|
canDraw: false,
|
||||||
|
color: '#00c7b1',
|
||||||
|
blockNumbers: { 'ann-1': 1 },
|
||||||
|
dimmed: true,
|
||||||
|
onDraw: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const badge = page.getByText('1');
|
||||||
|
await expect.element(badge).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show block number badges when dimmed is false', async () => {
|
||||||
|
render(AnnotationLayer, {
|
||||||
|
annotations: [annotation],
|
||||||
|
canDraw: false,
|
||||||
|
color: '#00c7b1',
|
||||||
|
blockNumbers: { 'ann-1': 1 },
|
||||||
|
dimmed: false,
|
||||||
|
onDraw: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const badge = page.getByText('1');
|
||||||
|
await expect.element(badge).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should still fire onAnnotationClick when dimmed', async () => {
|
||||||
|
let clickedId: string | undefined;
|
||||||
|
render(AnnotationLayer, {
|
||||||
|
annotations: [annotation],
|
||||||
|
canDraw: false,
|
||||||
|
color: '#00c7b1',
|
||||||
|
dimmed: true,
|
||||||
|
onDraw: () => {},
|
||||||
|
onAnnotationClick: (id: string) => {
|
||||||
|
clickedId = id;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const el = document.querySelector('[data-testid="annotation-ann-1"]')!;
|
||||||
|
el.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||||
|
expect(clickedId).toBe('ann-1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('flashAnnotationId prop', () => {
|
||||||
|
it('should apply annotation-flash class when flashAnnotationId matches', async () => {
|
||||||
|
render(AnnotationLayer, {
|
||||||
|
annotations: [annotation],
|
||||||
|
canDraw: false,
|
||||||
|
color: '#00c7b1',
|
||||||
|
flashAnnotationId: 'ann-1',
|
||||||
|
onDraw: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const el = document.querySelector('[data-testid="annotation-ann-1"]')!;
|
||||||
|
expect(el.classList.contains('annotation-flash')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not apply annotation-flash class when flashAnnotationId does not match', async () => {
|
||||||
|
render(AnnotationLayer, {
|
||||||
|
annotations: [annotation],
|
||||||
|
canDraw: false,
|
||||||
|
color: '#00c7b1',
|
||||||
|
flashAnnotationId: 'other-id',
|
||||||
|
onDraw: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const el = document.querySelector('[data-testid="annotation-ann-1"]')!;
|
||||||
|
expect(el.classList.contains('annotation-flash')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -20,6 +20,8 @@ type Props = {
|
|||||||
blockNumbers?: Record<string, number>;
|
blockNumbers?: Record<string, number>;
|
||||||
annotationReloadKey?: number;
|
annotationReloadKey?: number;
|
||||||
activeAnnotationId: string | null;
|
activeAnnotationId: string | null;
|
||||||
|
annotationsDimmed?: boolean;
|
||||||
|
flashAnnotationId?: string | null;
|
||||||
onAnnotationClick: (id: string) => void;
|
onAnnotationClick: (id: string) => void;
|
||||||
onTranscriptionDraw?: (rect: DrawRect) => void;
|
onTranscriptionDraw?: (rect: DrawRect) => void;
|
||||||
};
|
};
|
||||||
@@ -33,6 +35,8 @@ let {
|
|||||||
blockNumbers = {},
|
blockNumbers = {},
|
||||||
annotationReloadKey = 0,
|
annotationReloadKey = 0,
|
||||||
activeAnnotationId = $bindable(),
|
activeAnnotationId = $bindable(),
|
||||||
|
annotationsDimmed = false,
|
||||||
|
flashAnnotationId = null,
|
||||||
onAnnotationClick,
|
onAnnotationClick,
|
||||||
onTranscriptionDraw
|
onTranscriptionDraw
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
@@ -90,6 +94,8 @@ let {
|
|||||||
blockNumbers={blockNumbers}
|
blockNumbers={blockNumbers}
|
||||||
annotationReloadKey={annotationReloadKey}
|
annotationReloadKey={annotationReloadKey}
|
||||||
bind:activeAnnotationId={activeAnnotationId}
|
bind:activeAnnotationId={activeAnnotationId}
|
||||||
|
annotationsDimmed={annotationsDimmed}
|
||||||
|
flashAnnotationId={flashAnnotationId}
|
||||||
onAnnotationClick={onAnnotationClick}
|
onAnnotationClick={onAnnotationClick}
|
||||||
onTranscriptionDraw={onTranscriptionDraw}
|
onTranscriptionDraw={onTranscriptionDraw}
|
||||||
documentFileHash={doc.fileHash ?? null}
|
documentFileHash={doc.fileHash ?? null}
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ let {
|
|||||||
activeAnnotationId = $bindable<string | null>(null),
|
activeAnnotationId = $bindable<string | null>(null),
|
||||||
onAnnotationClick,
|
onAnnotationClick,
|
||||||
onTranscriptionDraw,
|
onTranscriptionDraw,
|
||||||
documentFileHash
|
documentFileHash,
|
||||||
|
annotationsDimmed = false,
|
||||||
|
flashAnnotationId = null
|
||||||
}: {
|
}: {
|
||||||
url: string;
|
url: string;
|
||||||
documentId?: string;
|
documentId?: string;
|
||||||
@@ -27,6 +29,8 @@ let {
|
|||||||
onAnnotationClick?: (id: string) => void;
|
onAnnotationClick?: (id: string) => void;
|
||||||
onTranscriptionDraw?: (rect: DrawRect) => void;
|
onTranscriptionDraw?: (rect: DrawRect) => void;
|
||||||
documentFileHash?: string | null;
|
documentFileHash?: string | null;
|
||||||
|
annotationsDimmed?: boolean;
|
||||||
|
flashAnnotationId?: string | null;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let pdfDoc = $state<PDFDocumentProxy | null>(null);
|
let pdfDoc = $state<PDFDocumentProxy | null>(null);
|
||||||
@@ -456,6 +460,8 @@ function zoomOut() {
|
|||||||
color={TRANSCRIPTION_COLOR}
|
color={TRANSCRIPTION_COLOR}
|
||||||
blockNumbers={blockNumbers}
|
blockNumbers={blockNumbers}
|
||||||
activeAnnotationId={activeAnnotationId}
|
activeAnnotationId={activeAnnotationId}
|
||||||
|
dimmed={annotationsDimmed}
|
||||||
|
flashAnnotationId={flashAnnotationId}
|
||||||
onDraw={handleDraw}
|
onDraw={handleDraw}
|
||||||
onAnnotationClick={handleAnnotationClick}
|
onAnnotationClick={handleAnnotationClick}
|
||||||
/>
|
/>
|
||||||
|
|||||||
96
frontend/src/lib/components/TranscriptionPanelHeader.svelte
Normal file
96
frontend/src/lib/components/TranscriptionPanelHeader.svelte
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import { getLocale } from '$lib/paraglide/runtime.js';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
mode: 'read' | 'edit';
|
||||||
|
hasBlocks: boolean;
|
||||||
|
blockCount: number;
|
||||||
|
lastEditedAt: string | null;
|
||||||
|
onModeChange: (mode: 'read' | 'edit') => void;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { mode, hasBlocks, blockCount, lastEditedAt, onModeChange, onClose }: Props = $props();
|
||||||
|
|
||||||
|
const formattedDate = $derived(
|
||||||
|
lastEditedAt
|
||||||
|
? new Intl.DateTimeFormat(getLocale(), {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric'
|
||||||
|
}).format(new Date(lastEditedAt))
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleReadClick() {
|
||||||
|
if (hasBlocks) {
|
||||||
|
onModeChange('read');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex h-[44px] items-center justify-between border-b border-line bg-surface px-3 font-sans"
|
||||||
|
>
|
||||||
|
<!-- Segmented toggle -->
|
||||||
|
<div class="flex h-9 items-center rounded-full border border-line bg-muted p-0.5 md:h-7">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="mode-read"
|
||||||
|
aria-disabled={!hasBlocks}
|
||||||
|
onclick={handleReadClick}
|
||||||
|
class="h-full rounded-full px-3 text-xs font-semibold transition-colors {mode === 'read'
|
||||||
|
? 'bg-primary text-primary-fg'
|
||||||
|
: 'text-ink-2 hover:text-ink'}"
|
||||||
|
style:opacity={!hasBlocks ? '0.35' : undefined}
|
||||||
|
>
|
||||||
|
{m.mode_read()}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="mode-edit"
|
||||||
|
onclick={() => onModeChange('edit')}
|
||||||
|
class="h-full rounded-full px-3 text-xs font-semibold transition-colors {mode === 'edit'
|
||||||
|
? 'bg-primary text-primary-fg'
|
||||||
|
: 'text-ink-2 hover:text-ink'}"
|
||||||
|
>
|
||||||
|
<span class="md:hidden">{m.mode_edit_short()}</span>
|
||||||
|
<span class="hidden md:inline">{m.mode_edit()}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status line (hidden on mobile to save space) -->
|
||||||
|
<p class="hidden text-xs text-ink-2 md:block">
|
||||||
|
{#if blockCount === 1}
|
||||||
|
{m.transcription_status_section()}
|
||||||
|
{:else}
|
||||||
|
{m.transcription_status_sections({ count: blockCount })}
|
||||||
|
{/if}
|
||||||
|
{#if formattedDate}
|
||||||
|
<span class="ml-1"
|
||||||
|
>· {m.transcription_status_last_edited({ time: formattedDate })}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Close button -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="panel-close"
|
||||||
|
onclick={onClose}
|
||||||
|
aria-label={m.transcription_panel_close()}
|
||||||
|
class="flex h-11 w-11 items-center justify-center rounded text-ink-2 transition-colors hover:bg-muted hover:text-ink"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-4 w-4"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render } from 'vitest-browser-svelte';
|
||||||
|
import { page } from 'vitest/browser';
|
||||||
|
import TranscriptionPanelHeader from './TranscriptionPanelHeader.svelte';
|
||||||
|
|
||||||
|
describe('TranscriptionPanelHeader', () => {
|
||||||
|
it('should render Lesen and Bearbeiten buttons', async () => {
|
||||||
|
render(TranscriptionPanelHeader, {
|
||||||
|
mode: 'read',
|
||||||
|
hasBlocks: true,
|
||||||
|
blockCount: 3,
|
||||||
|
lastEditedAt: null,
|
||||||
|
onModeChange: () => {},
|
||||||
|
onClose: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.element(page.getByText('Lesen')).toBeInTheDocument();
|
||||||
|
await expect.element(page.getByText('Bearbeiten')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should disable Lesen button when hasBlocks is false', async () => {
|
||||||
|
render(TranscriptionPanelHeader, {
|
||||||
|
mode: 'edit',
|
||||||
|
hasBlocks: false,
|
||||||
|
blockCount: 0,
|
||||||
|
lastEditedAt: null,
|
||||||
|
onModeChange: () => {},
|
||||||
|
onClose: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const lesenBtn = document.querySelector('[data-testid="mode-read"]') as HTMLButtonElement;
|
||||||
|
expect(lesenBtn.getAttribute('aria-disabled')).toBe('true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call onModeChange when clicking Bearbeiten', async () => {
|
||||||
|
const onModeChange = vi.fn();
|
||||||
|
render(TranscriptionPanelHeader, {
|
||||||
|
mode: 'read',
|
||||||
|
hasBlocks: true,
|
||||||
|
blockCount: 3,
|
||||||
|
lastEditedAt: null,
|
||||||
|
onModeChange,
|
||||||
|
onClose: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const editBtn = document.querySelector('[data-testid="mode-edit"]')!;
|
||||||
|
editBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||||
|
expect(onModeChange).toHaveBeenCalledWith('edit');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not call onModeChange when clicking disabled Lesen', async () => {
|
||||||
|
const onModeChange = vi.fn();
|
||||||
|
render(TranscriptionPanelHeader, {
|
||||||
|
mode: 'edit',
|
||||||
|
hasBlocks: false,
|
||||||
|
blockCount: 0,
|
||||||
|
lastEditedAt: null,
|
||||||
|
onModeChange,
|
||||||
|
onClose: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const readBtn = document.querySelector('[data-testid="mode-read"]')!;
|
||||||
|
readBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||||
|
expect(onModeChange).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call onClose when clicking close button', async () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(TranscriptionPanelHeader, {
|
||||||
|
mode: 'read',
|
||||||
|
hasBlocks: true,
|
||||||
|
blockCount: 3,
|
||||||
|
lastEditedAt: null,
|
||||||
|
onModeChange: () => {},
|
||||||
|
onClose
|
||||||
|
});
|
||||||
|
|
||||||
|
const closeBtn = document.querySelector('[data-testid="panel-close"]')!;
|
||||||
|
closeBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||||
|
expect(onClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show singular block count for 1 block', async () => {
|
||||||
|
render(TranscriptionPanelHeader, {
|
||||||
|
mode: 'read',
|
||||||
|
hasBlocks: true,
|
||||||
|
blockCount: 1,
|
||||||
|
lastEditedAt: null,
|
||||||
|
onModeChange: () => {},
|
||||||
|
onClose: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.element(page.getByText('1 Abschnitt')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show plural block count for multiple blocks', async () => {
|
||||||
|
render(TranscriptionPanelHeader, {
|
||||||
|
mode: 'read',
|
||||||
|
hasBlocks: true,
|
||||||
|
blockCount: 5,
|
||||||
|
lastEditedAt: null,
|
||||||
|
onModeChange: () => {},
|
||||||
|
onClose: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.element(page.getByText('5 Abschnitte')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show "0 Abschnitte" when blockCount is 0', async () => {
|
||||||
|
render(TranscriptionPanelHeader, {
|
||||||
|
mode: 'edit',
|
||||||
|
hasBlocks: false,
|
||||||
|
blockCount: 0,
|
||||||
|
lastEditedAt: null,
|
||||||
|
onModeChange: () => {},
|
||||||
|
onClose: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.element(page.getByText('0 Abschnitte')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have close button with 44px touch target classes', async () => {
|
||||||
|
render(TranscriptionPanelHeader, {
|
||||||
|
mode: 'read',
|
||||||
|
hasBlocks: true,
|
||||||
|
blockCount: 3,
|
||||||
|
lastEditedAt: null,
|
||||||
|
onModeChange: () => {},
|
||||||
|
onClose: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const closeBtn = document.querySelector('[data-testid="panel-close"]') as HTMLElement;
|
||||||
|
expect(closeBtn.classList.contains('h-11')).toBe(true);
|
||||||
|
expect(closeBtn.classList.contains('w-11')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show formatted date when lastEditedAt is provided', async () => {
|
||||||
|
render(TranscriptionPanelHeader, {
|
||||||
|
mode: 'read',
|
||||||
|
hasBlocks: true,
|
||||||
|
blockCount: 3,
|
||||||
|
lastEditedAt: '2026-04-07T10:00:00Z',
|
||||||
|
onModeChange: () => {},
|
||||||
|
onClose: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusText = document.querySelector('.hidden.md\\:block');
|
||||||
|
expect(statusText).not.toBeNull();
|
||||||
|
expect(statusText!.textContent).toContain('2026');
|
||||||
|
});
|
||||||
|
});
|
||||||
58
frontend/src/lib/components/TranscriptionReadView.svelte
Normal file
58
frontend/src/lib/components/TranscriptionReadView.svelte
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { TranscriptionBlockData } from '$lib/types';
|
||||||
|
import { splitByMarkers } from '$lib/utils/transcriptionMarkers';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
blocks: TranscriptionBlockData[];
|
||||||
|
onParagraphClick: (annotationId: string) => void;
|
||||||
|
highlightBlockId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { blocks, onParagraphClick, highlightBlockId = null }: Props = $props();
|
||||||
|
|
||||||
|
let sorted = $derived([...blocks].sort((a, b) => a.sortOrder - b.sortOrder));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<article class="px-6 py-8">
|
||||||
|
{#each sorted as block (block.id)}
|
||||||
|
<div
|
||||||
|
class="-mx-2 mb-6 cursor-pointer rounded-sm px-2 py-1 font-serif text-[16px] leading-[1.85] text-ink transition-colors hover:bg-turquoise/10"
|
||||||
|
class:flash-highlight={highlightBlockId === block.id}
|
||||||
|
data-block-id={block.id}
|
||||||
|
onclick={() => onParagraphClick(block.annotationId)}
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') onParagraphClick(block.annotationId); }}
|
||||||
|
>
|
||||||
|
{#each splitByMarkers(block.text) as segment, i (i)}
|
||||||
|
{#if segment.type === 'marker'}
|
||||||
|
<em data-marker class="text-ink-2 italic">{segment.text}</em>
|
||||||
|
{:else}
|
||||||
|
{segment.text}
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@keyframes flash {
|
||||||
|
0% {
|
||||||
|
background-color: color-mix(in srgb, var(--color-turquoise) 18%, transparent);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.flash-highlight {
|
||||||
|
animation: flash 1.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.flash-highlight {
|
||||||
|
animation: none;
|
||||||
|
background-color: color-mix(in srgb, var(--color-turquoise) 18%, transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
142
frontend/src/lib/components/TranscriptionReadView.svelte.test.ts
Normal file
142
frontend/src/lib/components/TranscriptionReadView.svelte.test.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render } from 'vitest-browser-svelte';
|
||||||
|
import { page } from 'vitest/browser';
|
||||||
|
import TranscriptionReadView from './TranscriptionReadView.svelte';
|
||||||
|
import type { TranscriptionBlockData } from '$lib/types';
|
||||||
|
|
||||||
|
const blocks: TranscriptionBlockData[] = [
|
||||||
|
{
|
||||||
|
id: 'b1',
|
||||||
|
annotationId: 'ann-1',
|
||||||
|
documentId: 'doc-1',
|
||||||
|
text: 'First paragraph text.',
|
||||||
|
label: null,
|
||||||
|
sortOrder: 1,
|
||||||
|
version: 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'b2',
|
||||||
|
annotationId: 'ann-2',
|
||||||
|
documentId: 'doc-1',
|
||||||
|
text: 'Second paragraph text.',
|
||||||
|
label: null,
|
||||||
|
sortOrder: 2,
|
||||||
|
version: 1
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('TranscriptionReadView', () => {
|
||||||
|
it('should render one paragraph per block', async () => {
|
||||||
|
render(TranscriptionReadView, {
|
||||||
|
blocks,
|
||||||
|
onParagraphClick: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.element(page.getByText('First paragraph text.')).toBeInTheDocument();
|
||||||
|
await expect.element(page.getByText('Second paragraph text.')).toBeInTheDocument();
|
||||||
|
|
||||||
|
const paragraphs = document.querySelectorAll('[data-block-id]');
|
||||||
|
expect(paragraphs.length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render [unleserlich] as italic muted text', async () => {
|
||||||
|
render(TranscriptionReadView, {
|
||||||
|
blocks: [
|
||||||
|
{
|
||||||
|
id: 'b1',
|
||||||
|
annotationId: 'ann-1',
|
||||||
|
documentId: 'doc-1',
|
||||||
|
text: 'Text before [unleserlich] text after',
|
||||||
|
label: null,
|
||||||
|
sortOrder: 1,
|
||||||
|
version: 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
onParagraphClick: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const marker = document.querySelector('[data-marker]');
|
||||||
|
expect(marker).not.toBeNull();
|
||||||
|
expect(marker!.textContent).toBe('[unleserlich]');
|
||||||
|
expect(marker!.tagName.toLowerCase()).toBe('em');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render [...] as italic muted text', async () => {
|
||||||
|
render(TranscriptionReadView, {
|
||||||
|
blocks: [
|
||||||
|
{
|
||||||
|
id: 'b1',
|
||||||
|
annotationId: 'ann-1',
|
||||||
|
documentId: 'doc-1',
|
||||||
|
text: 'Some [...] text',
|
||||||
|
label: null,
|
||||||
|
sortOrder: 1,
|
||||||
|
version: 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
onParagraphClick: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const marker = document.querySelector('[data-marker]');
|
||||||
|
expect(marker).not.toBeNull();
|
||||||
|
expect(marker!.textContent).toBe('[...]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call onParagraphClick with annotationId when paragraph is clicked', async () => {
|
||||||
|
const onParagraphClick = vi.fn();
|
||||||
|
render(TranscriptionReadView, {
|
||||||
|
blocks,
|
||||||
|
onParagraphClick
|
||||||
|
});
|
||||||
|
|
||||||
|
const paragraph = document.querySelector('[data-block-id="b1"]')!;
|
||||||
|
paragraph.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||||
|
expect(onParagraphClick).toHaveBeenCalledWith('ann-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render blocks sorted by sortOrder', async () => {
|
||||||
|
render(TranscriptionReadView, {
|
||||||
|
blocks: [
|
||||||
|
{ ...blocks[1], sortOrder: 1 },
|
||||||
|
{ ...blocks[0], sortOrder: 2 }
|
||||||
|
],
|
||||||
|
onParagraphClick: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const paragraphs = document.querySelectorAll('[data-block-id]');
|
||||||
|
expect(paragraphs[0].getAttribute('data-block-id')).toBe('b2');
|
||||||
|
expect(paragraphs[1].getAttribute('data-block-id')).toBe('b1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply flash-highlight class when highlightBlockId matches', async () => {
|
||||||
|
render(TranscriptionReadView, {
|
||||||
|
blocks: [blocks[0]],
|
||||||
|
onParagraphClick: () => {},
|
||||||
|
highlightBlockId: 'b1'
|
||||||
|
});
|
||||||
|
|
||||||
|
const el = document.querySelector('[data-block-id="b1"]')!;
|
||||||
|
expect(el.classList.contains('flash-highlight')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not apply flash-highlight class when highlightBlockId does not match', async () => {
|
||||||
|
render(TranscriptionReadView, {
|
||||||
|
blocks: [blocks[0]],
|
||||||
|
onParagraphClick: () => {},
|
||||||
|
highlightBlockId: 'other-id'
|
||||||
|
});
|
||||||
|
|
||||||
|
const el = document.querySelector('[data-block-id="b1"]')!;
|
||||||
|
expect(el.classList.contains('flash-highlight')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render empty state when no blocks', async () => {
|
||||||
|
render(TranscriptionReadView, {
|
||||||
|
blocks: [],
|
||||||
|
onParagraphClick: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const paragraphs = document.querySelectorAll('[data-block-id]');
|
||||||
|
expect(paragraphs.length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -35,6 +35,7 @@ export type TranscriptionBlockData = {
|
|||||||
label: string | null;
|
label: string | null;
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
version: number;
|
version: number;
|
||||||
|
updatedAt?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Annotation = {
|
export type Annotation = {
|
||||||
|
|||||||
60
frontend/src/lib/utils/transcriptionMarkers.spec.ts
Normal file
60
frontend/src/lib/utils/transcriptionMarkers.spec.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { splitByMarkers } from './transcriptionMarkers';
|
||||||
|
|
||||||
|
describe('splitByMarkers', () => {
|
||||||
|
it('should return single text segment for plain text', () => {
|
||||||
|
const result = splitByMarkers('Hello world');
|
||||||
|
expect(result).toEqual([{ type: 'text', text: 'Hello world' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should split [unleserlich] into a marker segment', () => {
|
||||||
|
const result = splitByMarkers('before [unleserlich] after');
|
||||||
|
expect(result).toEqual([
|
||||||
|
{ type: 'text', text: 'before ' },
|
||||||
|
{ type: 'marker', text: '[unleserlich]' },
|
||||||
|
{ type: 'text', text: ' after' }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should split [...] into a marker segment', () => {
|
||||||
|
const result = splitByMarkers('some text [...] more text');
|
||||||
|
expect(result).toEqual([
|
||||||
|
{ type: 'text', text: 'some text ' },
|
||||||
|
{ type: 'marker', text: '[...]' },
|
||||||
|
{ type: 'text', text: ' more text' }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multiple markers in one string', () => {
|
||||||
|
const result = splitByMarkers('[unleserlich] middle [...] end');
|
||||||
|
expect(result).toEqual([
|
||||||
|
{ type: 'marker', text: '[unleserlich]' },
|
||||||
|
{ type: 'text', text: ' middle ' },
|
||||||
|
{ type: 'marker', text: '[...]' },
|
||||||
|
{ type: 'text', text: ' end' }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle text that is only a marker', () => {
|
||||||
|
const result = splitByMarkers('[unleserlich]');
|
||||||
|
expect(result).toEqual([{ type: 'marker', text: '[unleserlich]' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty string', () => {
|
||||||
|
const result = splitByMarkers('');
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not match other bracket markers', () => {
|
||||||
|
const result = splitByMarkers('text [Seitenumbruch] more');
|
||||||
|
expect(result).toEqual([{ type: 'text', text: 'text [Seitenumbruch] more' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle adjacent markers', () => {
|
||||||
|
const result = splitByMarkers('[unleserlich][...]');
|
||||||
|
expect(result).toEqual([
|
||||||
|
{ type: 'marker', text: '[unleserlich]' },
|
||||||
|
{ type: 'marker', text: '[...]' }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
25
frontend/src/lib/utils/transcriptionMarkers.ts
Normal file
25
frontend/src/lib/utils/transcriptionMarkers.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
export type TextSegment = { type: 'text' | 'marker'; text: string };
|
||||||
|
|
||||||
|
const MARKER_PATTERN = /(\[unleserlich\]|\[\.{3}\])/g;
|
||||||
|
|
||||||
|
export function splitByMarkers(input: string): TextSegment[] {
|
||||||
|
if (!input) return [];
|
||||||
|
|
||||||
|
const segments: TextSegment[] = [];
|
||||||
|
let lastIndex = 0;
|
||||||
|
|
||||||
|
for (const match of input.matchAll(MARKER_PATTERN)) {
|
||||||
|
const matchStart = match.index;
|
||||||
|
if (matchStart > lastIndex) {
|
||||||
|
segments.push({ type: 'text', text: input.slice(lastIndex, matchStart) });
|
||||||
|
}
|
||||||
|
segments.push({ type: 'marker', text: match[0] });
|
||||||
|
lastIndex = matchStart + match[0].length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastIndex < input.length) {
|
||||||
|
segments.push({ type: 'text', text: input.slice(lastIndex) });
|
||||||
|
}
|
||||||
|
|
||||||
|
return segments;
|
||||||
|
}
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
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 TranscriptionEditView from '$lib/components/TranscriptionEditView.svelte';
|
import TranscriptionEditView from '$lib/components/TranscriptionEditView.svelte';
|
||||||
|
import TranscriptionReadView from '$lib/components/TranscriptionReadView.svelte';
|
||||||
|
import TranscriptionPanelHeader from '$lib/components/TranscriptionPanelHeader.svelte';
|
||||||
import type { TranscriptionBlockData } from '$lib/types';
|
import type { TranscriptionBlockData } from '$lib/types';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
@@ -49,7 +52,15 @@ async function loadFile(id: string) {
|
|||||||
// ── Mode state ───────────────────────────────────────────────────────────────
|
// ── Mode state ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
let transcribeMode = $state(false);
|
let transcribeMode = $state(false);
|
||||||
|
let panelMode = $state<'read' | 'edit'>('read');
|
||||||
let activeAnnotationId = $state<string | null>(null);
|
let activeAnnotationId = $state<string | null>(null);
|
||||||
|
let highlightBlockId = $state<string | null>(null);
|
||||||
|
let flashAnnotationId = $state<string | null>(null);
|
||||||
|
let pdfStripExpanded = $state(false);
|
||||||
|
|
||||||
|
const prefersReducedMotion = $derived(
|
||||||
|
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||||
|
);
|
||||||
|
|
||||||
// ── Transcription blocks ─────────────────────────────────────────────────────
|
// ── Transcription blocks ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -64,6 +75,17 @@ const blockNumbers = $derived(
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const hasBlocks = $derived(transcriptionBlocks.length > 0);
|
||||||
|
|
||||||
|
const lastEditedAt = $derived.by(() => {
|
||||||
|
if (transcriptionBlocks.length === 0) return null;
|
||||||
|
const dates = transcriptionBlocks
|
||||||
|
.filter((b) => b.updatedAt)
|
||||||
|
.map((b) => new Date(b.updatedAt!).getTime());
|
||||||
|
if (dates.length === 0) return null;
|
||||||
|
return new Date(Math.max(...dates)).toISOString();
|
||||||
|
});
|
||||||
|
|
||||||
async function loadTranscriptionBlocks() {
|
async function loadTranscriptionBlocks() {
|
||||||
if (!doc?.id) return;
|
if (!doc?.id) return;
|
||||||
try {
|
try {
|
||||||
@@ -142,20 +164,46 @@ async function handleAnnotationClick(annotationId: string) {
|
|||||||
await loadTranscriptionBlocks();
|
await loadTranscriptionBlocks();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for DOM to render the blocks, then scroll to the matching one
|
// In read mode, highlight the matching paragraph
|
||||||
|
const block = transcriptionBlocks.find((b) => b.annotationId === annotationId);
|
||||||
|
if (block) {
|
||||||
|
highlightBlockId = block.id;
|
||||||
|
setTimeout(
|
||||||
|
() => {
|
||||||
|
highlightBlockId = null;
|
||||||
|
},
|
||||||
|
prefersReducedMotion ? 2000 : 1500
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for DOM to render, then scroll to the matching block
|
||||||
|
const scrollBehavior = prefersReducedMotion ? 'instant' : 'smooth';
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
const block = transcriptionBlocks.find((b) => b.annotationId === annotationId);
|
|
||||||
if (block) {
|
if (block) {
|
||||||
const el = document.querySelector(`[data-block-id="${block.id}"]`);
|
const el = document.querySelector(`[data-block-id="${block.id}"]`);
|
||||||
el?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
el?.scrollIntoView({ behavior: scrollBehavior, block: 'nearest' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load blocks when transcribe mode is entered
|
function handleParagraphClick(annotationId: string) {
|
||||||
|
activeAnnotationId = annotationId;
|
||||||
|
flashAnnotationId = annotationId;
|
||||||
|
pdfStripExpanded = true;
|
||||||
|
setTimeout(
|
||||||
|
() => {
|
||||||
|
flashAnnotationId = null;
|
||||||
|
},
|
||||||
|
prefersReducedMotion ? 2000 : 1500
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load blocks when transcribe mode is entered and set default panel mode
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (transcribeMode) {
|
if (transcribeMode) {
|
||||||
loadTranscriptionBlocks();
|
loadTranscriptionBlocks().then(() => {
|
||||||
|
panelMode = transcriptionBlocks.length > 0 ? 'read' : 'edit';
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -201,7 +249,9 @@ onMount(() => {
|
|||||||
|
|
||||||
<div class="relative flex-1 overflow-hidden {transcribeMode ? 'flex flex-col md:flex-row' : ''}">
|
<div class="relative flex-1 overflow-hidden {transcribeMode ? 'flex flex-col md:flex-row' : ''}">
|
||||||
<div
|
<div
|
||||||
class={transcribeMode ? 'relative min-h-[40vh] flex-1 overflow-hidden md:min-h-0' : 'absolute inset-0'}
|
class={transcribeMode
|
||||||
|
? `relative flex-1 overflow-hidden md:min-h-0 ${panelMode === 'read' ? (pdfStripExpanded ? 'max-h-[50vh] min-h-[50vh] md:max-h-none md:min-h-0' : 'max-h-[70px] min-h-[70px] md:max-h-none md:min-h-0') : 'min-h-[40vh]'} transition-[min-height,max-height] duration-300`
|
||||||
|
: 'absolute inset-0'}
|
||||||
>
|
>
|
||||||
<DocumentViewer
|
<DocumentViewer
|
||||||
doc={doc}
|
doc={doc}
|
||||||
@@ -211,26 +261,67 @@ onMount(() => {
|
|||||||
transcribeMode={transcribeMode}
|
transcribeMode={transcribeMode}
|
||||||
blockNumbers={blockNumbers}
|
blockNumbers={blockNumbers}
|
||||||
annotationReloadKey={annotationReloadKey}
|
annotationReloadKey={annotationReloadKey}
|
||||||
|
annotationsDimmed={transcribeMode && panelMode === 'read'}
|
||||||
|
flashAnnotationId={flashAnnotationId}
|
||||||
bind:activeAnnotationId={activeAnnotationId}
|
bind:activeAnnotationId={activeAnnotationId}
|
||||||
onAnnotationClick={handleAnnotationClick}
|
onAnnotationClick={handleAnnotationClick}
|
||||||
onTranscriptionDraw={createBlockFromDraw}
|
onTranscriptionDraw={createBlockFromDraw}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if transcribeMode && panelMode === 'read'}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (pdfStripExpanded = !pdfStripExpanded)}
|
||||||
|
class="flex h-7 w-full shrink-0 items-center justify-center border-t border-line bg-muted text-xs font-semibold text-ink-2 md:hidden"
|
||||||
|
aria-label={pdfStripExpanded ? m.scan_collapse() : m.scan_expand()}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="mr-1 h-3 w-3 transition-transform duration-200 {pdfStripExpanded ? 'rotate-180' : ''}"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
{pdfStripExpanded ? m.scan_collapse() : m.scan_expand()}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if transcribeMode}
|
{#if transcribeMode}
|
||||||
<div
|
<div
|
||||||
class="shrink-0 border-t border-line md:w-[400px] md:border-t-0 md:border-l lg:w-[480px]"
|
class="flex min-h-0 flex-1 shrink-0 flex-col border-t border-line md:w-[400px] md:flex-none md:border-t-0 md:border-l lg:w-[480px]"
|
||||||
>
|
>
|
||||||
<TranscriptionEditView
|
<TranscriptionPanelHeader
|
||||||
documentId={doc.id}
|
mode={panelMode}
|
||||||
blocks={transcriptionBlocks}
|
hasBlocks={hasBlocks}
|
||||||
canComment={canWrite}
|
blockCount={transcriptionBlocks.length}
|
||||||
currentUserId={currentUserId}
|
lastEditedAt={lastEditedAt}
|
||||||
activeAnnotationId={activeAnnotationId}
|
onModeChange={(newMode) => (panelMode = newMode)}
|
||||||
onBlockFocus={handleBlockFocus}
|
onClose={() => (transcribeMode = false)}
|
||||||
onSaveBlock={saveBlock}
|
|
||||||
onDeleteBlock={deleteBlock}
|
|
||||||
/>
|
/>
|
||||||
|
<div class="flex-1 overflow-y-auto">
|
||||||
|
{#if panelMode === 'read'}
|
||||||
|
<TranscriptionReadView
|
||||||
|
blocks={transcriptionBlocks}
|
||||||
|
highlightBlockId={highlightBlockId}
|
||||||
|
onParagraphClick={handleParagraphClick}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<TranscriptionEditView
|
||||||
|
documentId={doc.id}
|
||||||
|
blocks={transcriptionBlocks}
|
||||||
|
canComment={canWrite}
|
||||||
|
currentUserId={currentUserId}
|
||||||
|
activeAnnotationId={activeAnnotationId}
|
||||||
|
onBlockFocus={handleBlockFocus}
|
||||||
|
onSaveBlock={saveBlock}
|
||||||
|
onDeleteBlock={deleteBlock}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user