feat: Transcription read mode (clean split) #177 #205
@@ -468,5 +468,16 @@
|
||||
"transcription_quote_stale": "Zitat aus älterer Version",
|
||||
"transcription_block_conflict": "Dieser Block wurde von jemand anderem geändert — bitte neu laden",
|
||||
"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_block_conflict": "This block was changed by someone else — please reload",
|
||||
"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_block_conflict": "Este bloque fue cambiado por otra persona — por favor recargue",
|
||||
"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,
|
||||
blockNumbers = {},
|
||||
activeAnnotationId = null,
|
||||
dimmed = false,
|
||||
flashAnnotationId = null,
|
||||
onDraw,
|
||||
onAnnotationClick
|
||||
}: {
|
||||
@@ -22,6 +24,8 @@ let {
|
||||
color: string;
|
||||
blockNumbers?: Record<string, number>;
|
||||
activeAnnotationId?: string | null;
|
||||
dimmed?: boolean;
|
||||
flashAnnotationId?: string | null;
|
||||
onDraw: (rect: DrawRect) => void;
|
||||
onAnnotationClick?: (id: string) => void;
|
||||
} = $props();
|
||||
@@ -108,6 +112,7 @@ const containerStyle = $derived(
|
||||
<div
|
||||
data-testid="annotation-{annotation.id}"
|
||||
data-annotation
|
||||
class:annotation-flash={flashAnnotationId === annotation.id}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label="Block anzeigen"
|
||||
@@ -123,15 +128,15 @@ const containerStyle = $derived(
|
||||
top: {annotation.y * 100}%;
|
||||
width: {annotation.width * 100}%;
|
||||
height: {annotation.height * 100}%;
|
||||
background-color: {hexToRgba(annotation.color, 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'};
|
||||
opacity: {activeAnnotationId && annotation.id !== activeAnnotationId ? 0.3 : 1};
|
||||
background-color: {hexToRgba(annotation.color, dimmed ? 0.3 : (hoveredId === annotation.id || annotation.id === activeAnnotationId ? 0.5 : 0.3))};
|
||||
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: {dimmed ? 1 : (activeAnnotationId && annotation.id !== activeAnnotationId ? 0.3 : 1)};
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
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
|
||||
style="
|
||||
position: absolute;
|
||||
@@ -173,3 +178,27 @@ const containerStyle = $derived(
|
||||
></div>
|
||||
{/if}
|
||||
</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>;
|
||||
annotationReloadKey?: number;
|
||||
activeAnnotationId: string | null;
|
||||
annotationsDimmed?: boolean;
|
||||
flashAnnotationId?: string | null;
|
||||
onAnnotationClick: (id: string) => void;
|
||||
onTranscriptionDraw?: (rect: DrawRect) => void;
|
||||
};
|
||||
@@ -33,6 +35,8 @@ let {
|
||||
blockNumbers = {},
|
||||
annotationReloadKey = 0,
|
||||
activeAnnotationId = $bindable(),
|
||||
annotationsDimmed = false,
|
||||
flashAnnotationId = null,
|
||||
onAnnotationClick,
|
||||
onTranscriptionDraw
|
||||
}: Props = $props();
|
||||
@@ -90,6 +94,8 @@ let {
|
||||
blockNumbers={blockNumbers}
|
||||
annotationReloadKey={annotationReloadKey}
|
||||
bind:activeAnnotationId={activeAnnotationId}
|
||||
annotationsDimmed={annotationsDimmed}
|
||||
flashAnnotationId={flashAnnotationId}
|
||||
onAnnotationClick={onAnnotationClick}
|
||||
onTranscriptionDraw={onTranscriptionDraw}
|
||||
documentFileHash={doc.fileHash ?? null}
|
||||
|
||||
@@ -16,7 +16,9 @@ let {
|
||||
activeAnnotationId = $bindable<string | null>(null),
|
||||
onAnnotationClick,
|
||||
onTranscriptionDraw,
|
||||
documentFileHash
|
||||
documentFileHash,
|
||||
annotationsDimmed = false,
|
||||
flashAnnotationId = null
|
||||
}: {
|
||||
url: string;
|
||||
documentId?: string;
|
||||
@@ -27,6 +29,8 @@ let {
|
||||
onAnnotationClick?: (id: string) => void;
|
||||
onTranscriptionDraw?: (rect: DrawRect) => void;
|
||||
documentFileHash?: string | null;
|
||||
annotationsDimmed?: boolean;
|
||||
flashAnnotationId?: string | null;
|
||||
} = $props();
|
||||
|
||||
let pdfDoc = $state<PDFDocumentProxy | null>(null);
|
||||
@@ -456,6 +460,8 @@ function zoomOut() {
|
||||
color={TRANSCRIPTION_COLOR}
|
||||
blockNumbers={blockNumbers}
|
||||
activeAnnotationId={activeAnnotationId}
|
||||
dimmed={annotationsDimmed}
|
||||
flashAnnotationId={flashAnnotationId}
|
||||
onDraw={handleDraw}
|
||||
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;
|
||||
sortOrder: number;
|
||||
version: number;
|
||||
updatedAt?: string | null;
|
||||
};
|
||||
|
||||
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">
|
||||
import { onMount } from 'svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import DocumentTopBar from '$lib/components/DocumentTopBar.svelte';
|
||||
import DocumentViewer from '$lib/components/DocumentViewer.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';
|
||||
|
||||
let { data } = $props();
|
||||
@@ -49,7 +52,15 @@ async function loadFile(id: string) {
|
||||
// ── Mode state ───────────────────────────────────────────────────────────────
|
||||
|
||||
let transcribeMode = $state(false);
|
||||
let panelMode = $state<'read' | 'edit'>('read');
|
||||
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 ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -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() {
|
||||
if (!doc?.id) return;
|
||||
try {
|
||||
@@ -142,20 +164,46 @@ async function handleAnnotationClick(annotationId: string) {
|
||||
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(() => {
|
||||
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' });
|
||||
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(() => {
|
||||
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={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
|
||||
doc={doc}
|
||||
@@ -211,26 +261,67 @@ onMount(() => {
|
||||
transcribeMode={transcribeMode}
|
||||
blockNumbers={blockNumbers}
|
||||
annotationReloadKey={annotationReloadKey}
|
||||
annotationsDimmed={transcribeMode && panelMode === 'read'}
|
||||
flashAnnotationId={flashAnnotationId}
|
||||
bind:activeAnnotationId={activeAnnotationId}
|
||||
onAnnotationClick={handleAnnotationClick}
|
||||
onTranscriptionDraw={createBlockFromDraw}
|
||||
/>
|
||||
</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}
|
||||
<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
|
||||
documentId={doc.id}
|
||||
blocks={transcriptionBlocks}
|
||||
canComment={canWrite}
|
||||
currentUserId={currentUserId}
|
||||
activeAnnotationId={activeAnnotationId}
|
||||
onBlockFocus={handleBlockFocus}
|
||||
onSaveBlock={saveBlock}
|
||||
onDeleteBlock={deleteBlock}
|
||||
<TranscriptionPanelHeader
|
||||
mode={panelMode}
|
||||
hasBlocks={hasBlocks}
|
||||
blockCount={transcriptionBlocks.length}
|
||||
lastEditedAt={lastEditedAt}
|
||||
onModeChange={(newMode) => (panelMode = newMode)}
|
||||
onClose={() => (transcribeMode = false)}
|
||||
/>
|
||||
<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>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user