refactor: move document transcription, annotation, viewer sub-packages

- transcription/: TranscriptionBlock, Column, EditView, PanelHeader, ReadView,
  Section + transcriptionMarkers, blockConflictMerge, saveBlockWithConflictRetry
  + useBlockAutoSave, useBlockDragDrop hooks
- annotation/: AnnotationLayer, AnnotationShape, AnnotationEditOverlay
- viewer/: PdfViewer, PdfControls + useFileLoader, usePdfRenderer hooks

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-05 14:01:39 +02:00
parent e7f8aa5894
commit 1e656d2db4
43 changed files with 32 additions and 27 deletions

View File

@@ -1,205 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest';
import type { PersonMention } from '$lib/types';
const mockSaveFn =
vi.fn<(blockId: string, text: string, mentionedPersons: PersonMention[]) => Promise<void>>();
const NO_MENTIONS: PersonMention[] = [];
const { createBlockAutoSave } = await import('../useBlockAutoSave.svelte');
describe('createBlockAutoSave', () => {
beforeEach(() => {
vi.useFakeTimers();
mockSaveFn.mockClear();
mockSaveFn.mockResolvedValue(undefined);
});
afterEach(() => {
vi.useRealTimers();
});
it('getSaveState returns idle initially', () => {
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
expect(as.getSaveState('block-1')).toBe('idle');
});
it('debounce coalesces multiple changes — saves once after 1500ms', async () => {
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
as.handleTextChange('block-1', 'text 1', NO_MENTIONS);
as.handleTextChange('block-1', 'text 2', NO_MENTIONS);
as.handleTextChange('block-1', 'text 3', NO_MENTIONS);
await vi.advanceTimersByTimeAsync(1500);
expect(mockSaveFn).toHaveBeenCalledTimes(1);
expect(mockSaveFn).toHaveBeenCalledWith('block-1', 'text 3', NO_MENTIONS);
});
it('handles concurrent blocks independently', async () => {
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
as.handleTextChange('block-1', 'hello', NO_MENTIONS);
as.handleTextChange('block-2', 'world', NO_MENTIONS);
await vi.advanceTimersByTimeAsync(1500);
expect(mockSaveFn).toHaveBeenCalledTimes(2);
});
it('sets save state to saving then saved on success', async () => {
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
as.handleTextChange('block-1', 'text', NO_MENTIONS);
vi.advanceTimersByTime(1500);
expect(as.getSaveState('block-1')).toBe('saving');
await Promise.resolve();
expect(as.getSaveState('block-1')).toBe('saved');
});
it('sets save state to error on save failure', async () => {
mockSaveFn.mockRejectedValue(new Error('save failed'));
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
as.handleTextChange('block-1', 'text', NO_MENTIONS);
await vi.advanceTimersByTimeAsync(1500);
expect(as.getSaveState('block-1')).toBe('error');
});
it('handleRetry saves with provided current text', async () => {
mockSaveFn.mockRejectedValueOnce(new Error('first fails'));
mockSaveFn.mockResolvedValueOnce(undefined);
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
as.handleTextChange('block-1', 'original', NO_MENTIONS);
await vi.advanceTimersByTimeAsync(1500);
expect(as.getSaveState('block-1')).toBe('error');
await as.handleRetry('block-1', 'original', NO_MENTIONS);
expect(mockSaveFn).toHaveBeenCalledTimes(2);
expect(as.getSaveState('block-1')).toBe('saved');
});
it('preserves the in-flight text + mentionedPersons across a save failure (B12)', async () => {
// Hold the second saveFn so we can observe the saving→saved transition
// (Tester #5506 §5).
let resolveSecond!: () => void;
mockSaveFn.mockRejectedValueOnce(new Error('boom'));
mockSaveFn.mockReturnValueOnce(new Promise<void>((r) => (resolveSecond = r)));
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
const mentions: PersonMention[] = [{ personId: 'p-aug', displayName: 'Auguste Raddatz' }];
as.handleTextChange('block-1', '@Auguste Raddatz hi', mentions);
await vi.advanceTimersByTimeAsync(1500);
expect(as.getSaveState('block-1')).toBe('error');
// Retry without re-passing the data — the hook resends the preserved payload.
const retryPromise = as.handleRetry('block-1', 'should-not-be-used', []);
// Yield once so executeSave runs synchronously up to the saveFn await.
await Promise.resolve();
expect(as.getSaveState('block-1')).toBe('saving');
expect(mockSaveFn).toHaveBeenLastCalledWith('block-1', '@Auguste Raddatz hi', mentions);
resolveSecond();
await retryPromise;
expect(as.getSaveState('block-1')).toBe('saved');
});
it('clearBlock removes all state for a block', () => {
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
as.handleTextChange('block-1', 'text', NO_MENTIONS);
as.clearBlock('block-1');
expect(as.getSaveState('block-1')).toBe('idle');
});
it('destroy clears all pending timers so no save occurs', async () => {
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
as.handleTextChange('block-1', 'text', NO_MENTIONS);
as.destroy();
await vi.advanceTimersByTimeAsync(2000);
expect(mockSaveFn).not.toHaveBeenCalled();
});
});
describe('flushOnUnload', () => {
let mockFetch: Mock;
beforeEach(() => {
vi.useFakeTimers();
mockSaveFn.mockClear();
mockSaveFn.mockResolvedValue(undefined);
mockFetch = vi.fn().mockResolvedValue(new Response(null, { status: 200 }));
vi.stubGlobal('fetch', mockFetch);
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
it('sends a PUT request with keepalive:true for each pending block', () => {
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
as.handleTextChange('block-1', 'hello', NO_MENTIONS);
as.handleTextChange('block-2', 'world', NO_MENTIONS);
as.flushOnUnload();
expect(mockFetch).toHaveBeenCalledTimes(2);
expect(mockFetch).toHaveBeenCalledWith(
'/api/documents/doc-1/transcription-blocks/block-1',
expect.objectContaining({
method: 'PUT',
keepalive: true,
body: JSON.stringify({ text: 'hello', mentionedPersons: [] })
})
);
expect(mockFetch).toHaveBeenCalledWith(
'/api/documents/doc-1/transcription-blocks/block-2',
expect.objectContaining({
method: 'PUT',
keepalive: true,
body: JSON.stringify({ text: 'world', mentionedPersons: [] })
})
);
});
it('does not call navigator.sendBeacon', () => {
const sendBeaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true);
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
as.handleTextChange('block-1', 'text', NO_MENTIONS);
as.flushOnUnload();
expect(sendBeaconSpy).not.toHaveBeenCalled();
});
it('does nothing when there are no pending edits', () => {
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
as.flushOnUnload();
expect(mockFetch).not.toHaveBeenCalled();
});
it('cancels the debounce timer so saveFn is not also called', async () => {
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
as.handleTextChange('block-1', 'text', NO_MENTIONS);
as.flushOnUnload();
await vi.advanceTimersByTimeAsync(2000);
expect(mockSaveFn).not.toHaveBeenCalled();
});
it('does not send fetch if debounce already fired and pendingTexts is empty', async () => {
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
as.handleTextChange('block-1', 'text', NO_MENTIONS);
await vi.advanceTimersByTimeAsync(1500);
mockFetch.mockClear();
as.flushOnUnload();
expect(mockFetch).not.toHaveBeenCalled();
});
it('flushes the pending mentionedPersons sidecar alongside text', () => {
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
const mentions: PersonMention[] = [{ personId: 'p-1', displayName: 'Auguste Raddatz' }];
as.handleTextChange('block-1', '@Auguste Raddatz', mentions);
as.flushOnUnload();
expect(mockFetch).toHaveBeenCalledWith(
'/api/documents/doc-1/transcription-blocks/block-1',
expect.objectContaining({
body: JSON.stringify({ text: '@Auguste Raddatz', mentionedPersons: mentions })
})
);
});
});

View File

@@ -1,169 +0,0 @@
import { describe, it, expect, vi } from 'vitest';
import { createBlockDragDrop } from '../useBlockDragDrop.svelte';
import type { TranscriptionBlockData } from '$lib/types';
function makeBlock(id: string, sortOrder: number): TranscriptionBlockData {
return {
id,
annotationId: `ann-${id}`,
documentId: 'doc-1',
text: '',
label: null,
sortOrder,
version: 1,
source: 'MANUAL',
reviewed: false,
mentionedPersons: []
};
}
/**
* Builds a DOM list, mocks getBoundingClientRect (60px per wrapper),
* drags `dragId` and drops it so dropTargetIdx === targetIdx, then
* triggers handlePointerUp. Returns the onReorder spy.
*/
function simulateDragDrop(
dragId: string,
targetIdx: number,
blocks: TranscriptionBlockData[]
): ReturnType<typeof vi.fn> {
const onReorder = vi.fn();
const dd = createBlockDragDrop({ getSortedBlocks: () => blocks, onReorder });
// Build DOM
const listEl = document.createElement('div');
const wrappers = blocks.map(() => {
const grip = document.createElement('div');
grip.setAttribute('data-drag-handle', '');
const wrapper = document.createElement('div');
wrapper.setAttribute('data-block-wrapper', '');
wrapper.appendChild(grip);
listEl.appendChild(wrapper);
return { grip, wrapper };
});
document.body.appendChild(listEl);
dd.setListElement(listEl);
// Mock bounding rects: each wrapper is 60px tall starting at y=0
wrappers.forEach(({ wrapper }, i) => {
vi.spyOn(wrapper, 'getBoundingClientRect').mockReturnValue({
top: i * 60,
height: 60,
bottom: (i + 1) * 60,
left: 0,
right: 100,
width: 100,
x: 0,
y: i * 60,
toJSON: () => ({})
} as DOMRect);
});
const dragIdx = blocks.findIndex((b) => b.id === dragId);
const { grip, wrapper: dragWrapper } = wrappers[dragIdx];
dragWrapper.setPointerCapture = vi.fn();
// Start drag
const downEvent = new PointerEvent('pointerdown', { clientY: dragIdx * 60, cancelable: true });
Object.defineProperty(downEvent, 'target', { value: grip });
dd.handleGripDown(downEvent as PointerEvent, dragId);
// Move pointer to achieve the desired targetIdx
// midpoint of wrapper[i] = i*60 + 30
// clientY just before midpoint[i] → target = i
// clientY past last midpoint → target = wrappers.length
let clientY: number;
if (targetIdx <= 0) {
clientY = 5; // before first midpoint (30)
} else if (targetIdx >= wrappers.length) {
clientY = wrappers.length * 60 + 10; // past all midpoints
} else {
clientY = targetIdx * 60 + 5; // just past top of wrapper[targetIdx], before its midpoint
}
const moveEvent = new PointerEvent('pointermove', { clientY });
dd.handlePointerMove(moveEvent as PointerEvent);
dd.handlePointerUp();
document.body.removeChild(listEl);
return onReorder;
}
describe('createBlockDragDrop', () => {
it('initial state — no drag in progress', () => {
const dd = createBlockDragDrop({ getSortedBlocks: () => [], onReorder: vi.fn() });
expect(dd.draggedBlockId).toBeNull();
expect(dd.dropTargetIdx).toBeNull();
expect(dd.dragOffsetY).toBe(0);
});
it('handleGripDown sets draggedBlockId when grip is hit', () => {
const dd = createBlockDragDrop({ getSortedBlocks: () => [], onReorder: vi.fn() });
const grip = document.createElement('div');
grip.setAttribute('data-drag-handle', '');
const wrapper = document.createElement('div');
wrapper.setAttribute('data-block-wrapper', '');
wrapper.appendChild(grip);
document.body.appendChild(wrapper);
const e = new PointerEvent('pointerdown', { clientY: 100, cancelable: true, bubbles: true });
Object.defineProperty(e, 'target', { value: grip });
wrapper.setPointerCapture = vi.fn();
dd.handleGripDown(e as PointerEvent, 'block-1');
expect(dd.draggedBlockId).toBe('block-1');
document.body.removeChild(wrapper);
});
it('handlePointerUp without active drag is a no-op', () => {
const onReorder = vi.fn();
const dd = createBlockDragDrop({ getSortedBlocks: () => [], onReorder });
dd.handlePointerUp();
expect(onReorder).not.toHaveBeenCalled();
});
it('handlePointerUp with null dropTargetIdx does not call onReorder', () => {
const onReorder = vi.fn();
const blocks = [makeBlock('b1', 0), makeBlock('b2', 1)];
const dd = createBlockDragDrop({ getSortedBlocks: () => blocks, onReorder });
const grip = document.createElement('div');
grip.setAttribute('data-drag-handle', '');
const wrapper = document.createElement('div');
wrapper.setAttribute('data-block-wrapper', '');
wrapper.appendChild(grip);
document.body.appendChild(wrapper);
wrapper.setPointerCapture = vi.fn();
const downEvent = new PointerEvent('pointerdown', { clientY: 50, cancelable: true });
Object.defineProperty(downEvent, 'target', { value: grip });
dd.handleGripDown(downEvent as PointerEvent, 'b1');
// dropTargetIdx is still null (no pointer move happened)
dd.handlePointerUp();
expect(onReorder).not.toHaveBeenCalled();
expect(dd.draggedBlockId).toBeNull();
document.body.removeChild(wrapper);
});
it('reorder: moves block from index 0 to end', () => {
const blocks = [makeBlock('b1', 0), makeBlock('b2', 1), makeBlock('b3', 2)];
const onReorder = simulateDragDrop('b1', 3, blocks);
expect(onReorder).toHaveBeenCalledWith(['b2', 'b3', 'b1']);
});
it('reorder: moves block from end to index 0', () => {
const blocks = [makeBlock('b1', 0), makeBlock('b2', 1), makeBlock('b3', 2)];
const onReorder = simulateDragDrop('b3', 0, blocks);
expect(onReorder).toHaveBeenCalledWith(['b3', 'b1', 'b2']);
});
it('reorder: moves block down by one position (tests insertAt = dropTargetIdx - 1)', () => {
const blocks = [makeBlock('b1', 0), makeBlock('b2', 1), makeBlock('b3', 2)];
// dragId=b1 (idx=0), targetIdx=2 → insertAt = 2-1 = 1 → [b2, b1, b3]
const onReorder = simulateDragDrop('b1', 2, blocks);
expect(onReorder).toHaveBeenCalledWith(['b2', 'b1', 'b3']);
});
});

View File

@@ -1,98 +0,0 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { createFileLoader } from '../useFileLoader.svelte';
const FAKE_URL = 'blob:fake-url';
function setupFetch(ok: boolean, body?: Blob) {
const blob = body ?? new Blob(['fake'], { type: 'application/pdf' });
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok,
blob: vi.fn().mockResolvedValue(blob)
})
);
}
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe('createFileLoader', () => {
it('sets fileUrl after a successful fetch', async () => {
vi.stubGlobal('URL', {
createObjectURL: vi.fn().mockReturnValue(FAKE_URL),
revokeObjectURL: vi.fn()
});
setupFetch(true);
const loader = createFileLoader();
await loader.loadFile('/api/documents/1/file');
expect(loader.fileUrl).toBe(FAKE_URL);
expect(loader.isLoading).toBe(false);
expect(loader.fileError).toBe('');
});
it('sets fileError on a failed fetch (non-ok response)', async () => {
vi.stubGlobal('URL', {
createObjectURL: vi.fn(),
revokeObjectURL: vi.fn()
});
setupFetch(false);
const loader = createFileLoader();
await loader.loadFile('/api/documents/1/file');
expect(loader.fileUrl).toBe('');
expect(loader.fileError).not.toBe('');
expect(loader.isLoading).toBe(false);
});
it('revokes the previous URL before creating a new one', async () => {
const revokeObjectURL = vi.fn();
vi.stubGlobal('URL', {
createObjectURL: vi.fn().mockReturnValue(FAKE_URL),
revokeObjectURL
});
setupFetch(true);
const loader = createFileLoader();
await loader.loadFile('/api/documents/1/file');
// First load: no previous URL to revoke
expect(revokeObjectURL).not.toHaveBeenCalled();
await loader.loadFile('/api/documents/2/file');
// Second load: previous URL should be revoked
expect(revokeObjectURL).toHaveBeenCalledWith(FAKE_URL);
});
it('revokes the URL on destroy', async () => {
const revokeObjectURL = vi.fn();
vi.stubGlobal('URL', {
createObjectURL: vi.fn().mockReturnValue(FAKE_URL),
revokeObjectURL
});
setupFetch(true);
const loader = createFileLoader();
await loader.loadFile('/api/documents/1/file');
loader.destroy();
expect(revokeObjectURL).toHaveBeenCalledWith(FAKE_URL);
});
it('does not revoke when no URL has been set', () => {
const revokeObjectURL = vi.fn();
vi.stubGlobal('URL', {
createObjectURL: vi.fn(),
revokeObjectURL
});
const loader = createFileLoader();
loader.destroy();
expect(revokeObjectURL).not.toHaveBeenCalled();
});
});

View File

@@ -1,69 +0,0 @@
import { describe, it, expect } from 'vitest';
import { createPdfRenderer } from '../usePdfRenderer.svelte';
// Note: init() and loadDocument() require pdfjsLib (browser module).
// These tests cover pure state logic only — bounds clamping and zoom limits.
describe('createPdfRenderer', () => {
it('starts at page 1 with scale 1.5 and no error', () => {
const r = createPdfRenderer();
expect(r.currentPage).toBe(1);
expect(r.scale).toBe(1.5);
expect(r.totalPages).toBe(0);
expect(r.loading).toBe(false);
expect(r.error).toBeNull();
expect(r.isLoaded).toBe(false);
expect(r.pdfjsReady).toBe(false);
});
it('prevPage does not go below page 1', () => {
const r = createPdfRenderer();
r.prevPage();
expect(r.currentPage).toBe(1);
});
it('nextPage does not exceed totalPages', () => {
const r = createPdfRenderer();
// totalPages = 0, so 1 < 0 is false → stays at 1
r.nextPage();
expect(r.currentPage).toBe(1);
});
it('goToPage does not navigate when n > totalPages', () => {
const r = createPdfRenderer();
r.goToPage(5);
expect(r.currentPage).toBe(1);
});
it('goToPage does not navigate when n < 1', () => {
const r = createPdfRenderer();
r.goToPage(0);
expect(r.currentPage).toBe(1);
});
it('zoomIn increases scale by 0.25', () => {
const r = createPdfRenderer();
r.zoomIn();
expect(r.scale).toBeCloseTo(1.75);
});
it('zoomOut decreases scale by 0.25', () => {
const r = createPdfRenderer();
r.zoomOut();
expect(r.scale).toBeCloseTo(1.25);
});
it('zoomOut does not go below 0.5', () => {
const r = createPdfRenderer();
for (let i = 0; i < 20; i++) r.zoomOut();
expect(r.scale).toBeCloseTo(0.5);
});
it('loadDocument is a no-op when pdfjsLib not initialized', async () => {
const r = createPdfRenderer();
await r.loadDocument('/some/path');
// No-op because pdfjsLib is null (init not called)
expect(r.error).toBeNull();
expect(r.loading).toBe(false);
});
});

View File

@@ -1,150 +0,0 @@
import { SvelteMap } from 'svelte/reactivity';
import type { PersonMention } from '$lib/types';
export type SaveState = 'idle' | 'saving' | 'saved' | 'fading' | 'error';
type Options = {
saveFn: (blockId: string, text: string, mentionedPersons: PersonMention[]) => Promise<void>;
documentId: string;
};
export function createBlockAutoSave({ saveFn, documentId }: Options) {
const saveStates = new SvelteMap<string, SaveState>();
const debounceTimers = new SvelteMap<string, ReturnType<typeof setTimeout>>();
const pendingTexts = new SvelteMap<string, string>();
const pendingMentions = new SvelteMap<string, PersonMention[]>();
const fadeTimers: ReturnType<typeof setTimeout>[] = [];
function getSaveState(blockId: string): SaveState {
return saveStates.get(blockId) ?? 'idle';
}
function setSaveState(blockId: string, state: SaveState) {
saveStates.set(blockId, state);
}
async function executeSave(blockId: string): Promise<void> {
const text = pendingTexts.get(blockId);
if (text === undefined) return;
const mentions = pendingMentions.get(blockId) ?? [];
pendingTexts.delete(blockId);
pendingMentions.delete(blockId);
setSaveState(blockId, 'saving');
try {
await saveFn(blockId, text, mentions);
setSaveState(blockId, 'saved');
scheduleSavedFade(blockId);
} catch {
// Preserve in-flight payload so the user can retry without re-typing.
pendingTexts.set(blockId, text);
pendingMentions.set(blockId, mentions);
setSaveState(blockId, 'error');
}
}
function scheduleSavedFade(blockId: string): void {
const t1 = setTimeout(() => {
if (getSaveState(blockId) === 'saved') {
setSaveState(blockId, 'fading');
const t2 = setTimeout(() => {
if (getSaveState(blockId) === 'fading') {
setSaveState(blockId, 'idle');
}
}, 300);
fadeTimers.push(t2);
}
}, 2000);
fadeTimers.push(t1);
}
function scheduleDebounce(blockId: string): void {
clearDebounce(blockId);
const timer = setTimeout(() => {
debounceTimers.delete(blockId);
executeSave(blockId);
}, 1500);
debounceTimers.set(blockId, timer);
}
function clearDebounce(blockId: string): void {
const existing = debounceTimers.get(blockId);
if (existing !== undefined) {
clearTimeout(existing);
debounceTimers.delete(blockId);
}
}
function handleTextChange(
blockId: string,
text: string,
mentionedPersons: PersonMention[]
): void {
pendingTexts.set(blockId, text);
pendingMentions.set(blockId, mentionedPersons);
scheduleDebounce(blockId);
}
function handleBlur(): void {
for (const [blockId] of [...debounceTimers]) {
clearDebounce(blockId);
executeSave(blockId);
}
}
async function handleRetry(
blockId: string,
currentText: string,
currentMentions: PersonMention[]
): Promise<void> {
const text = pendingTexts.get(blockId) ?? currentText;
const mentions = pendingMentions.get(blockId) ?? currentMentions;
pendingTexts.set(blockId, text);
pendingMentions.set(blockId, mentions);
await executeSave(blockId);
}
function clearBlock(blockId: string): void {
clearDebounce(blockId);
pendingTexts.delete(blockId);
pendingMentions.delete(blockId);
saveStates.delete(blockId);
}
function flushOnUnload(): void {
for (const [blockId, text] of pendingTexts) {
const mentions = pendingMentions.get(blockId) ?? [];
clearDebounce(blockId);
void fetch(`/api/documents/${documentId}/transcription-blocks/${blockId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, mentionedPersons: mentions }),
keepalive: true
});
pendingTexts.delete(blockId);
pendingMentions.delete(blockId);
}
}
function destroy(): void {
for (const timer of debounceTimers.values()) {
clearTimeout(timer);
}
debounceTimers.clear();
for (const timer of fadeTimers) {
clearTimeout(timer);
}
fadeTimers.length = 0;
}
return {
getSaveState,
handleTextChange,
handleBlur,
handleRetry,
clearBlock,
flushOnUnload,
destroy
};
}

View File

@@ -1,88 +0,0 @@
import type { TranscriptionBlockData } from '$lib/types';
type Options = {
getSortedBlocks: () => TranscriptionBlockData[];
onReorder: (blockIds: string[]) => void;
};
export function createBlockDragDrop({ getSortedBlocks, onReorder }: Options) {
let draggedBlockId = $state<string | null>(null);
let dropTargetIdx = $state<number | null>(null);
let dragOffsetY = $state(0);
// Internal mutable refs — not reactive
let dragStartY = 0;
let capturedEl: HTMLElement | null = null;
let listEl: HTMLElement | null = null;
function setListElement(el: HTMLElement | null): void {
listEl = el;
}
function handleGripDown(e: PointerEvent, blockId: string): void {
if (!(e.target as HTMLElement).closest('[data-drag-handle]')) return;
e.preventDefault();
draggedBlockId = blockId;
dragStartY = e.clientY;
dragOffsetY = 0;
capturedEl = (e.target as HTMLElement).closest('[data-block-wrapper]') as HTMLElement;
capturedEl?.setPointerCapture(e.pointerId);
}
function handlePointerMove(e: PointerEvent): void {
if (!draggedBlockId || !listEl) return;
dragOffsetY = e.clientY - dragStartY;
const sortedBlocks = getSortedBlocks();
const wrappers = Array.from(listEl.querySelectorAll('[data-block-wrapper]'));
const dragIdx = sortedBlocks.findIndex((b) => b.id === draggedBlockId);
let target: number | null = null;
for (let i = 0; i < wrappers.length; i++) {
const rect = wrappers[i].getBoundingClientRect();
if (e.clientY < rect.top + rect.height / 2) {
target = i;
break;
}
}
if (target === null) target = wrappers.length;
if (target === dragIdx || target === dragIdx + 1) target = null;
dropTargetIdx = target;
}
function handlePointerUp(): void {
if (!draggedBlockId) return;
if (dropTargetIdx !== null) {
const sorted = [...getSortedBlocks()];
const fromIdx = sorted.findIndex((b) => b.id === draggedBlockId);
if (fromIdx >= 0) {
const [moved] = sorted.splice(fromIdx, 1);
const insertAt = dropTargetIdx > fromIdx ? dropTargetIdx - 1 : dropTargetIdx;
sorted.splice(insertAt, 0, moved);
onReorder(sorted.map((b) => b.id));
}
}
draggedBlockId = null;
dropTargetIdx = null;
dragOffsetY = 0;
capturedEl = null;
}
return {
get draggedBlockId() {
return draggedBlockId;
},
get dropTargetIdx() {
return dropTargetIdx;
},
get dragOffsetY() {
return dragOffsetY;
},
setListElement,
handleGripDown,
handlePointerMove,
handlePointerUp
};
}

View File

@@ -1,47 +0,0 @@
import { untrack } from 'svelte';
export function createFileLoader() {
let fileUrl = $state('');
let isLoading = $state(false);
let fileError = $state('');
async function loadFile(url: string): Promise<void> {
isLoading = true;
fileError = '';
// untrack prevents callers ($effect) from accidentally subscribing to fileUrl.
// Without it, the calling effect would re-run every time fileUrl changes (i.e.
// on every successful load), creating an infinite load loop.
const prev = untrack(() => fileUrl);
if (prev) URL.revokeObjectURL(prev);
fileUrl = '';
try {
const response = await fetch(url);
if (!response.ok) throw new Error('Failed to load file');
const blob = await response.blob();
fileUrl = URL.createObjectURL(blob);
} catch {
fileError = 'Vorschau konnte nicht geladen werden.';
} finally {
isLoading = false;
}
}
function destroy(): void {
if (fileUrl) URL.revokeObjectURL(fileUrl);
}
return {
get fileUrl() {
return fileUrl;
},
get isLoading() {
return isLoading;
},
get fileError() {
return fileError;
},
loadFile,
destroy
};
}

View File

@@ -1,203 +0,0 @@
import type { PDFDocumentProxy, RenderTask } from 'pdfjs-dist';
export function createPdfRenderer() {
// Reactive state — exposed via getters
let currentPage = $state(1);
let totalPages = $state(0);
let scale = $state(1.5);
let loading = $state(false);
let error = $state<string | null>(null);
let pdfjsReady = $state(false);
// Internal mutable refs — NOT $state to avoid reactive loops
let pdfDoc: PDFDocumentProxy | null = null;
let canvasEl: HTMLCanvasElement | null = null;
let textLayerEl: HTMLDivElement | null = null;
let renderTask: RenderTask | null = null;
let textLayerInstance: { cancel: () => void } | null = null;
let pdfjsLib: typeof import('pdfjs-dist') | null = null;
async function init(): Promise<void> {
const [lib, { default: workerUrl }] = await Promise.all([
import('pdfjs-dist'),
import('pdfjs-dist/build/pdf.worker.min.mjs?url')
]);
lib.GlobalWorkerOptions.workerSrc = workerUrl;
pdfjsLib = lib;
pdfjsReady = true;
}
function setElements(canvas: HTMLCanvasElement, textLayer: HTMLDivElement): void {
canvasEl = canvas;
textLayerEl = textLayer;
}
async function loadDocument(src: string): Promise<void> {
if (!pdfjsLib) return;
loading = true;
error = null;
pdfDoc = null;
currentPage = 1;
totalPages = 0;
try {
const loadingTask = pdfjsLib.getDocument(src);
const doc = await loadingTask.promise;
pdfDoc = doc;
totalPages = doc.numPages;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load PDF';
} finally {
loading = false;
}
}
async function renderCurrentPage(): Promise<void> {
if (!pdfjsLib || !canvasEl || !textLayerEl || !pdfDoc) return;
if (renderTask) {
renderTask.cancel();
renderTask = null;
}
if (textLayerInstance) {
textLayerInstance.cancel();
textLayerInstance = null;
}
let page;
try {
page = await pdfDoc.getPage(currentPage);
} catch {
return;
}
const dpr = window.devicePixelRatio || 1;
const viewport = page.getViewport({ scale: scale * dpr });
const canvas = canvasEl;
const ctx = canvas.getContext('2d');
if (!ctx) return;
canvas.width = viewport.width;
canvas.height = viewport.height;
canvas.style.width = `${viewport.width / dpr}px`;
canvas.style.height = `${viewport.height / dpr}px`;
const task = page.render({ canvas, canvasContext: ctx, viewport });
renderTask = task;
try {
await task.promise;
} catch (e: unknown) {
if (
typeof e === 'object' &&
e !== null &&
'name' in e &&
(e as { name: string }).name === 'RenderingCancelledException'
)
return;
return;
}
renderTask = null;
const textDiv = textLayerEl;
if (!textDiv) return;
textDiv.innerHTML = '';
textDiv.style.width = `${viewport.width / dpr}px`;
textDiv.style.height = `${viewport.height / dpr}px`;
const tl = new pdfjsLib.TextLayer({
textContentSource: page.streamTextContent(),
container: textDiv,
viewport
});
textLayerInstance = tl;
try {
await tl.render();
} catch {
// cancelled
}
}
async function prerender(): Promise<void> {
if (!pdfDoc) return;
const neighbors = [currentPage - 1, currentPage + 1].filter(
(n) => n >= 1 && n <= (pdfDoc?.numPages ?? 0)
);
for (const n of neighbors) {
try {
await pdfDoc.getPage(n);
} catch {
// ignore
}
}
}
function prevPage(): void {
if (currentPage > 1) currentPage -= 1;
}
function nextPage(): void {
if (currentPage < totalPages) currentPage += 1;
}
function goToPage(n: number): void {
if (n >= 1 && n <= totalPages) currentPage = n;
}
function zoomIn(): void {
scale += 0.25;
}
function zoomOut(): void {
if (scale > 0.5) scale -= 0.25;
}
function destroy(): void {
if (renderTask) {
renderTask.cancel();
renderTask = null;
}
if (textLayerInstance) {
textLayerInstance.cancel();
textLayerInstance = null;
}
pdfDoc?.destroy();
pdfDoc = null;
}
return {
get currentPage() {
return currentPage;
},
get totalPages() {
return totalPages;
},
get scale() {
return scale;
},
get loading() {
return loading;
},
get error() {
return error;
},
get isLoaded() {
return totalPages > 0;
},
get pdfjsReady() {
return pdfjsReady;
},
setElements,
init,
loadDocument,
renderCurrentPage,
prerender,
prevPage,
nextPage,
goToPage,
zoomIn,
zoomOut,
destroy
};
}