refactor(transcription): extract useBlockAutoSave and useBlockDragDrop from TranscriptionEditView (#199)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { SvelteMap } from 'svelte/reactivity';
|
|
||||||
import TranscriptionBlock from './TranscriptionBlock.svelte';
|
import TranscriptionBlock from './TranscriptionBlock.svelte';
|
||||||
import OcrTrigger from './OcrTrigger.svelte';
|
import OcrTrigger from './OcrTrigger.svelte';
|
||||||
import type { TranscriptionBlockData } from '$lib/types';
|
import type { TranscriptionBlockData } from '$lib/types';
|
||||||
|
import { createBlockAutoSave } from '$lib/hooks/useBlockAutoSave.svelte';
|
||||||
type SaveState = 'idle' | 'saving' | 'saved' | 'fading' | 'error';
|
import { createBlockDragDrop } from '$lib/hooks/useBlockDragDrop.svelte';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
documentId: string;
|
documentId: string;
|
||||||
@@ -45,6 +44,13 @@ let {
|
|||||||
|
|
||||||
let activeBlockId: string | null = $state(null);
|
let activeBlockId: string | null = $state(null);
|
||||||
let localLabels: string[] = $derived.by(() => [...trainingLabels]);
|
let localLabels: string[] = $derived.by(() => [...trainingLabels]);
|
||||||
|
let listEl: HTMLElement | null = $state(null);
|
||||||
|
|
||||||
|
const sortedBlocks = $derived([...blocks].sort((a, b) => a.sortOrder - b.sortOrder));
|
||||||
|
const hasBlocks = $derived(blocks.length > 0);
|
||||||
|
const reviewedCount = $derived(blocks.filter((b) => b.reviewed).length);
|
||||||
|
const totalCount = $derived(blocks.length);
|
||||||
|
const reviewProgress = $derived(totalCount > 0 ? (reviewedCount / totalCount) * 100 : 0);
|
||||||
|
|
||||||
// Sync: when an annotation is clicked on the PDF, activate the corresponding block
|
// Sync: when an annotation is clicked on the PDF, activate the corresponding block
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -52,104 +58,37 @@ $effect(() => {
|
|||||||
const block = blocks.find((b) => b.annotationId === activeAnnotationId);
|
const block = blocks.find((b) => b.annotationId === activeAnnotationId);
|
||||||
if (block) activeBlockId = block.id;
|
if (block) activeBlockId = block.id;
|
||||||
});
|
});
|
||||||
let saveStates = new SvelteMap<string, SaveState>();
|
|
||||||
let debounceTimers = new SvelteMap<string, ReturnType<typeof setTimeout>>();
|
|
||||||
let pendingTexts = new SvelteMap<string, string>();
|
|
||||||
let sortedBlocks = $derived([...blocks].sort((a, b) => a.sortOrder - b.sortOrder));
|
|
||||||
let hasBlocks = $derived(blocks.length > 0);
|
|
||||||
let reviewedCount = $derived(blocks.filter((b) => b.reviewed).length);
|
|
||||||
let totalCount = $derived(blocks.length);
|
|
||||||
let reviewProgress = $derived(totalCount > 0 ? (reviewedCount / totalCount) * 100 : 0);
|
|
||||||
|
|
||||||
function getSaveState(blockId: string): SaveState {
|
const autoSave = createBlockAutoSave({ saveFn: onSaveBlock, documentId });
|
||||||
return saveStates.get(blockId) ?? 'idle';
|
|
||||||
}
|
|
||||||
|
|
||||||
function setSaveState(blockId: string, state: SaveState) {
|
const dragDrop = createBlockDragDrop({
|
||||||
saveStates.set(blockId, state);
|
getSortedBlocks: () => sortedBlocks,
|
||||||
}
|
onReorder: reorder
|
||||||
|
});
|
||||||
|
|
||||||
async function executeSave(blockId: string) {
|
// Wire listEl to drag-drop module
|
||||||
const text = pendingTexts.get(blockId);
|
$effect(() => {
|
||||||
if (text === undefined) return;
|
dragDrop.setListElement(listEl);
|
||||||
|
});
|
||||||
|
|
||||||
pendingTexts.delete(blockId);
|
$effect(() => {
|
||||||
setSaveState(blockId, 'saving');
|
function onBeforeUnload() {
|
||||||
|
autoSave.flushViaBeacon();
|
||||||
try {
|
|
||||||
await onSaveBlock(blockId, text);
|
|
||||||
setSaveState(blockId, 'saved');
|
|
||||||
scheduleSavedFade(blockId);
|
|
||||||
} catch {
|
|
||||||
setSaveState(blockId, 'error');
|
|
||||||
}
|
}
|
||||||
}
|
window.addEventListener('beforeunload', onBeforeUnload);
|
||||||
|
return () => {
|
||||||
function scheduleSavedFade(blockId: string) {
|
window.removeEventListener('beforeunload', onBeforeUnload);
|
||||||
setTimeout(() => {
|
autoSave.destroy();
|
||||||
if (getSaveState(blockId) === 'saved') {
|
};
|
||||||
setSaveState(blockId, 'fading');
|
});
|
||||||
setTimeout(() => {
|
|
||||||
if (getSaveState(blockId) === 'fading') {
|
|
||||||
setSaveState(blockId, 'idle');
|
|
||||||
}
|
|
||||||
}, 300);
|
|
||||||
}
|
|
||||||
}, 2000);
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleDebounce(blockId: string) {
|
|
||||||
clearDebounce(blockId);
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
debounceTimers.delete(blockId);
|
|
||||||
executeSave(blockId);
|
|
||||||
}, 1500);
|
|
||||||
debounceTimers.set(blockId, timer);
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearDebounce(blockId: string) {
|
|
||||||
const existing = debounceTimers.get(blockId);
|
|
||||||
if (existing !== undefined) {
|
|
||||||
clearTimeout(existing);
|
|
||||||
debounceTimers.delete(blockId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function flushAllPending() {
|
|
||||||
for (const [blockId] of debounceTimers) {
|
|
||||||
clearDebounce(blockId);
|
|
||||||
executeSave(blockId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleTextChange(blockId: string, text: string) {
|
|
||||||
pendingTexts.set(blockId, text);
|
|
||||||
scheduleDebounce(blockId);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleFocus(blockId: string) {
|
function handleFocus(blockId: string) {
|
||||||
activeBlockId = blockId;
|
activeBlockId = blockId;
|
||||||
onBlockFocus(blockId);
|
onBlockFocus(blockId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleBlur() {
|
|
||||||
flushAllPending();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleRetry(blockId: string) {
|
|
||||||
const block = blocks.find((b) => b.id === blockId);
|
|
||||||
if (!block) return;
|
|
||||||
|
|
||||||
const pending = pendingTexts.get(blockId);
|
|
||||||
const text = pending ?? block.text;
|
|
||||||
pendingTexts.set(blockId, text);
|
|
||||||
await executeSave(blockId);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDelete(blockId: string) {
|
function handleDelete(blockId: string) {
|
||||||
clearDebounce(blockId);
|
autoSave.clearBlock(blockId);
|
||||||
pendingTexts.delete(blockId);
|
|
||||||
saveStates.delete(blockId);
|
|
||||||
onDeleteBlock(blockId);
|
onDeleteBlock(blockId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,7 +101,6 @@ async function reorder(newOrder: string[]) {
|
|||||||
});
|
});
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
const updated = await res.json();
|
const updated = await res.json();
|
||||||
// Update blocks with new sort orders from server
|
|
||||||
for (const b of updated) {
|
for (const b of updated) {
|
||||||
const existing = blocks.find((x) => x.id === b.id);
|
const existing = blocks.find((x) => x.id === b.id);
|
||||||
if (existing) existing.sortOrder = b.sortOrder;
|
if (existing) existing.sortOrder = b.sortOrder;
|
||||||
@@ -188,69 +126,9 @@ function handleMoveDown(blockId: string) {
|
|||||||
reorder(sorted.map((b) => b.id));
|
reorder(sorted.map((b) => b.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Pointer-based drag and drop ──────────────────────────────────────────
|
|
||||||
|
|
||||||
let draggedBlockId: string | null = $state(null);
|
|
||||||
let dropTargetIdx: number | null = $state(null);
|
|
||||||
let dragOffsetY: number = $state(0);
|
|
||||||
let dragStartY = 0;
|
|
||||||
let capturedEl: HTMLElement | null = null;
|
|
||||||
let listEl: HTMLElement | null = $state(null);
|
|
||||||
|
|
||||||
function handleGripDown(e: PointerEvent, blockId: string) {
|
|
||||||
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) {
|
|
||||||
if (!draggedBlockId || !listEl) return;
|
|
||||||
dragOffsetY = e.clientY - dragStartY;
|
|
||||||
|
|
||||||
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() {
|
|
||||||
if (!draggedBlockId) return;
|
|
||||||
|
|
||||||
if (dropTargetIdx !== null) {
|
|
||||||
const sorted = [...sortedBlocks];
|
|
||||||
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);
|
|
||||||
reorder(sorted.map((b) => b.id));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
draggedBlockId = null;
|
|
||||||
dropTargetIdx = null;
|
|
||||||
dragOffsetY = 0;
|
|
||||||
capturedEl = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleLabelToggle(label: string) {
|
async function handleLabelToggle(label: string) {
|
||||||
if (!onToggleTrainingLabel) return;
|
if (!onToggleTrainingLabel) return;
|
||||||
const enrolled = !localLabels.includes(label);
|
const enrolled = !localLabels.includes(label);
|
||||||
// Optimistic update
|
|
||||||
if (enrolled) {
|
if (enrolled) {
|
||||||
localLabels = [...localLabels, label];
|
localLabels = [...localLabels, label];
|
||||||
} else {
|
} else {
|
||||||
@@ -259,35 +137,9 @@ async function handleLabelToggle(label: string) {
|
|||||||
try {
|
try {
|
||||||
await onToggleTrainingLabel(label, enrolled);
|
await onToggleTrainingLabel(label, enrolled);
|
||||||
} catch {
|
} catch {
|
||||||
// Revert on failure
|
|
||||||
localLabels = [...trainingLabels];
|
localLabels = [...trainingLabels];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function flushViaBeacon() {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
function onBeforeUnload() {
|
|
||||||
flushViaBeacon();
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('beforeunload', onBeforeUnload);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('beforeunload', onBeforeUnload);
|
|
||||||
for (const timer of debounceTimers.values()) {
|
|
||||||
clearTimeout(timer);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex h-full flex-col overflow-y-auto bg-surface">
|
<div class="flex h-full flex-col overflow-y-auto bg-surface">
|
||||||
@@ -309,20 +161,22 @@ $effect(() => {
|
|||||||
<div
|
<div
|
||||||
class="flex flex-col gap-3"
|
class="flex flex-col gap-3"
|
||||||
bind:this={listEl}
|
bind:this={listEl}
|
||||||
onpointermove={handlePointerMove}
|
onpointermove={dragDrop.handlePointerMove}
|
||||||
onpointerup={handlePointerUp}
|
onpointerup={dragDrop.handlePointerUp}
|
||||||
>
|
>
|
||||||
{#each sortedBlocks as block, i (block.id)}
|
{#each sortedBlocks as block, i (block.id)}
|
||||||
{#if dropTargetIdx === i}
|
{#if dragDrop.dropTargetIdx === i}
|
||||||
<div class="h-1 rounded-full bg-turquoise transition-all"></div>
|
<div class="h-1 rounded-full bg-turquoise transition-all"></div>
|
||||||
{/if}
|
{/if}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div
|
<div
|
||||||
data-block-wrapper
|
data-block-wrapper
|
||||||
onblur={handleBlur}
|
onblur={autoSave.handleBlur}
|
||||||
onpointerdown={(e) => handleGripDown(e, block.id)}
|
onpointerdown={(e) => dragDrop.handleGripDown(e, block.id)}
|
||||||
class="relative transition-all duration-150 {draggedBlockId === block.id ? 'z-10 rounded-lg shadow-xl ring-2 ring-turquoise/40' : ''}"
|
class="relative transition-all duration-150 {dragDrop.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;` : ''}
|
style={dragDrop.draggedBlockId === block.id
|
||||||
|
? `transform: translateY(${dragDrop.dragOffsetY}px) scale(1.02); opacity: 0.9;`
|
||||||
|
: ''}
|
||||||
>
|
>
|
||||||
<TranscriptionBlock
|
<TranscriptionBlock
|
||||||
blockId={block.id}
|
blockId={block.id}
|
||||||
@@ -332,13 +186,13 @@ $effect(() => {
|
|||||||
label={block.label}
|
label={block.label}
|
||||||
active={activeBlockId === block.id}
|
active={activeBlockId === block.id}
|
||||||
reviewed={block.reviewed ?? false}
|
reviewed={block.reviewed ?? false}
|
||||||
saveState={getSaveState(block.id)}
|
saveState={autoSave.getSaveState(block.id)}
|
||||||
canComment={canComment}
|
canComment={canComment}
|
||||||
currentUserId={currentUserId}
|
currentUserId={currentUserId}
|
||||||
onTextChange={(text) => handleTextChange(block.id, text)}
|
onTextChange={(text) => autoSave.handleTextChange(block.id, text)}
|
||||||
onFocus={() => handleFocus(block.id)}
|
onFocus={() => handleFocus(block.id)}
|
||||||
onDeleteClick={() => handleDelete(block.id)}
|
onDeleteClick={() => handleDelete(block.id)}
|
||||||
onRetry={() => handleRetry(block.id)}
|
onRetry={() => autoSave.handleRetry(block.id, block.text)}
|
||||||
onReviewToggle={() => onReviewToggle(block.id)}
|
onReviewToggle={() => onReviewToggle(block.id)}
|
||||||
onMoveUp={() => handleMoveUp(block.id)}
|
onMoveUp={() => handleMoveUp(block.id)}
|
||||||
onMoveDown={() => handleMoveDown(block.id)}
|
onMoveDown={() => handleMoveDown(block.id)}
|
||||||
@@ -349,7 +203,7 @@ $effect(() => {
|
|||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
{#if dropTargetIdx === sortedBlocks.length}
|
{#if dragDrop.dropTargetIdx === sortedBlocks.length}
|
||||||
<div class="h-1 rounded-full bg-turquoise transition-all"></div>
|
<div class="h-1 rounded-full bg-turquoise transition-all"></div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
@@ -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