refactor(transcription): extract useBlockAutoSave and useBlockDragDrop from TranscriptionEditView (#199)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
127
frontend/src/lib/hooks/useBlockAutoSave.svelte.ts
Normal file
127
frontend/src/lib/hooks/useBlockAutoSave.svelte.ts
Normal 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
|
||||
};
|
||||
}
|
||||
88
frontend/src/lib/hooks/useBlockDragDrop.svelte.ts
Normal file
88
frontend/src/lib/hooks/useBlockDragDrop.svelte.ts
Normal 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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user