refactor(transcription): extract useBlockAutoSave and useBlockDragDrop from TranscriptionEditView (#199)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-15 14:45:03 +02:00
parent eb8aa92cf0
commit 8898863a48
5 changed files with 419 additions and 187 deletions

View File

@@ -0,0 +1,84 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
const mockSaveFn = vi.fn<(blockId: string, text: string) => Promise<void>>();
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');
as.handleTextChange('block-1', 'text 2');
as.handleTextChange('block-1', 'text 3');
await vi.advanceTimersByTimeAsync(1500);
expect(mockSaveFn).toHaveBeenCalledTimes(1);
expect(mockSaveFn).toHaveBeenCalledWith('block-1', 'text 3');
});
it('handles concurrent blocks independently', async () => {
const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' });
as.handleTextChange('block-1', 'hello');
as.handleTextChange('block-2', 'world');
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');
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');
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');
await vi.advanceTimersByTimeAsync(1500);
expect(as.getSaveState('block-1')).toBe('error');
await as.handleRetry('block-1', 'original');
expect(mockSaveFn).toHaveBeenCalledTimes(2);
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');
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');
as.destroy();
await vi.advanceTimersByTimeAsync(2000);
expect(mockSaveFn).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,79 @@
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
};
}
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 });
// Simulate a drag start without going through handleGripDown internals
// by checking that handlePointerUp without a drop target is a no-op for reorder
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);
});
});

View File

@@ -0,0 +1,127 @@
import { SvelteMap } from 'svelte/reactivity';
export type SaveState = 'idle' | 'saving' | 'saved' | 'fading' | 'error';
type Options = {
saveFn: (blockId: string, text: string) => 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 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;
pendingTexts.delete(blockId);
setSaveState(blockId, 'saving');
try {
await saveFn(blockId, text);
setSaveState(blockId, 'saved');
scheduleSavedFade(blockId);
} catch {
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): void {
pendingTexts.set(blockId, text);
scheduleDebounce(blockId);
}
function handleBlur(): void {
for (const [blockId] of [...debounceTimers]) {
clearDebounce(blockId);
executeSave(blockId);
}
}
async function handleRetry(blockId: string, currentText: string): Promise<void> {
const pending = pendingTexts.get(blockId);
const text = pending ?? currentText;
pendingTexts.set(blockId, text);
await executeSave(blockId);
}
function clearBlock(blockId: string): void {
clearDebounce(blockId);
pendingTexts.delete(blockId);
saveStates.delete(blockId);
}
function flushViaBeacon(): void {
for (const [blockId, text] of pendingTexts) {
clearDebounce(blockId);
const url = `/api/documents/${documentId}/transcription-blocks/${blockId}`;
const body = JSON.stringify({ text });
navigator.sendBeacon(url, new Blob([body], { type: 'application/json' }));
pendingTexts.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,
flushViaBeacon,
destroy
};
}

View File

@@ -0,0 +1,88 @@
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
};
}