From 8898863a4839a5b828e2e12776d47a8b02002b70 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 15 Apr 2026 14:45:03 +0200 Subject: [PATCH] refactor(transcription): extract useBlockAutoSave and useBlockDragDrop from TranscriptionEditView (#199) Co-Authored-By: Claude Sonnet 4.6 --- .../components/TranscriptionEditView.svelte | 228 ++++-------------- .../__tests__/useBlockAutoSave.svelte.test.ts | 84 +++++++ .../__tests__/useBlockDragDrop.svelte.test.ts | 79 ++++++ .../src/lib/hooks/useBlockAutoSave.svelte.ts | 127 ++++++++++ .../src/lib/hooks/useBlockDragDrop.svelte.ts | 88 +++++++ 5 files changed, 419 insertions(+), 187 deletions(-) create mode 100644 frontend/src/lib/hooks/__tests__/useBlockAutoSave.svelte.test.ts create mode 100644 frontend/src/lib/hooks/__tests__/useBlockDragDrop.svelte.test.ts create mode 100644 frontend/src/lib/hooks/useBlockAutoSave.svelte.ts create mode 100644 frontend/src/lib/hooks/useBlockDragDrop.svelte.ts diff --git a/frontend/src/lib/components/TranscriptionEditView.svelte b/frontend/src/lib/components/TranscriptionEditView.svelte index 774cebc5..85becbc2 100644 --- a/frontend/src/lib/components/TranscriptionEditView.svelte +++ b/frontend/src/lib/components/TranscriptionEditView.svelte @@ -1,11 +1,10 @@
@@ -309,20 +161,22 @@ $effect(() => {
{#each sortedBlocks as block, i (block.id)} - {#if dropTargetIdx === i} + {#if dragDrop.dropTargetIdx === i}
{/if}
handleGripDown(e, block.id)} - class="relative transition-all duration-150 {draggedBlockId === block.id ? 'z-10 rounded-lg shadow-xl ring-2 ring-turquoise/40' : ''}" - style={draggedBlockId === block.id ? `transform: translateY(${dragOffsetY}px) scale(1.02); opacity: 0.9;` : ''} + onblur={autoSave.handleBlur} + onpointerdown={(e) => dragDrop.handleGripDown(e, block.id)} + class="relative transition-all duration-150 {dragDrop.draggedBlockId === block.id ? 'z-10 rounded-lg shadow-xl ring-2 ring-turquoise/40' : ''}" + style={dragDrop.draggedBlockId === block.id + ? `transform: translateY(${dragDrop.dragOffsetY}px) scale(1.02); opacity: 0.9;` + : ''} > { label={block.label} active={activeBlockId === block.id} reviewed={block.reviewed ?? false} - saveState={getSaveState(block.id)} + saveState={autoSave.getSaveState(block.id)} canComment={canComment} currentUserId={currentUserId} - onTextChange={(text) => handleTextChange(block.id, text)} + onTextChange={(text) => autoSave.handleTextChange(block.id, text)} onFocus={() => handleFocus(block.id)} onDeleteClick={() => handleDelete(block.id)} - onRetry={() => handleRetry(block.id)} + onRetry={() => autoSave.handleRetry(block.id, block.text)} onReviewToggle={() => onReviewToggle(block.id)} onMoveUp={() => handleMoveUp(block.id)} onMoveDown={() => handleMoveDown(block.id)} @@ -349,7 +203,7 @@ $effect(() => {
{/each} - {#if dropTargetIdx === sortedBlocks.length} + {#if dragDrop.dropTargetIdx === sortedBlocks.length}
{/if} diff --git a/frontend/src/lib/hooks/__tests__/useBlockAutoSave.svelte.test.ts b/frontend/src/lib/hooks/__tests__/useBlockAutoSave.svelte.test.ts new file mode 100644 index 00000000..dd13a7a6 --- /dev/null +++ b/frontend/src/lib/hooks/__tests__/useBlockAutoSave.svelte.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +const mockSaveFn = vi.fn<(blockId: string, text: string) => Promise>(); + +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(); + }); +}); diff --git a/frontend/src/lib/hooks/__tests__/useBlockDragDrop.svelte.test.ts b/frontend/src/lib/hooks/__tests__/useBlockDragDrop.svelte.test.ts new file mode 100644 index 00000000..7291e19f --- /dev/null +++ b/frontend/src/lib/hooks/__tests__/useBlockDragDrop.svelte.test.ts @@ -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); + }); +}); diff --git a/frontend/src/lib/hooks/useBlockAutoSave.svelte.ts b/frontend/src/lib/hooks/useBlockAutoSave.svelte.ts new file mode 100644 index 00000000..a627f50e --- /dev/null +++ b/frontend/src/lib/hooks/useBlockAutoSave.svelte.ts @@ -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; + documentId: string; +}; + +export function createBlockAutoSave({ saveFn, documentId }: Options) { + const saveStates = new SvelteMap(); + const debounceTimers = new SvelteMap>(); + const pendingTexts = new SvelteMap(); + const fadeTimers: ReturnType[] = []; + + 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 { + 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 { + 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 + }; +} diff --git a/frontend/src/lib/hooks/useBlockDragDrop.svelte.ts b/frontend/src/lib/hooks/useBlockDragDrop.svelte.ts new file mode 100644 index 00000000..176bd467 --- /dev/null +++ b/frontend/src/lib/hooks/useBlockDragDrop.svelte.ts @@ -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(null); + let dropTargetIdx = $state(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 + }; +}