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

@@ -1,11 +1,10 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { SvelteMap } from 'svelte/reactivity';
import TranscriptionBlock from './TranscriptionBlock.svelte';
import OcrTrigger from './OcrTrigger.svelte';
import type { TranscriptionBlockData } from '$lib/types';
type SaveState = 'idle' | 'saving' | 'saved' | 'fading' | 'error';
import { createBlockAutoSave } from '$lib/hooks/useBlockAutoSave.svelte';
import { createBlockDragDrop } from '$lib/hooks/useBlockDragDrop.svelte';
type Props = {
documentId: string;
@@ -45,6 +44,13 @@ let {
let activeBlockId: string | null = $state(null);
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
$effect(() => {
@@ -52,104 +58,37 @@ $effect(() => {
const block = blocks.find((b) => b.annotationId === activeAnnotationId);
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 {
return saveStates.get(blockId) ?? 'idle';
}
const autoSave = createBlockAutoSave({ saveFn: onSaveBlock, documentId });
function setSaveState(blockId: string, state: SaveState) {
saveStates.set(blockId, state);
}
const dragDrop = createBlockDragDrop({
getSortedBlocks: () => sortedBlocks,
onReorder: reorder
});
async function executeSave(blockId: string) {
const text = pendingTexts.get(blockId);
if (text === undefined) return;
// Wire listEl to drag-drop module
$effect(() => {
dragDrop.setListElement(listEl);
});
pendingTexts.delete(blockId);
setSaveState(blockId, 'saving');
try {
await onSaveBlock(blockId, text);
setSaveState(blockId, 'saved');
scheduleSavedFade(blockId);
} catch {
setSaveState(blockId, 'error');
$effect(() => {
function onBeforeUnload() {
autoSave.flushViaBeacon();
}
}
function scheduleSavedFade(blockId: string) {
setTimeout(() => {
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);
}
window.addEventListener('beforeunload', onBeforeUnload);
return () => {
window.removeEventListener('beforeunload', onBeforeUnload);
autoSave.destroy();
};
});
function handleFocus(blockId: string) {
activeBlockId = 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) {
clearDebounce(blockId);
pendingTexts.delete(blockId);
saveStates.delete(blockId);
autoSave.clearBlock(blockId);
onDeleteBlock(blockId);
}
@@ -162,7 +101,6 @@ async function reorder(newOrder: string[]) {
});
if (!res.ok) return;
const updated = await res.json();
// Update blocks with new sort orders from server
for (const b of updated) {
const existing = blocks.find((x) => x.id === b.id);
if (existing) existing.sortOrder = b.sortOrder;
@@ -188,69 +126,9 @@ function handleMoveDown(blockId: string) {
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) {
if (!onToggleTrainingLabel) return;
const enrolled = !localLabels.includes(label);
// Optimistic update
if (enrolled) {
localLabels = [...localLabels, label];
} else {
@@ -259,35 +137,9 @@ async function handleLabelToggle(label: string) {
try {
await onToggleTrainingLabel(label, enrolled);
} catch {
// Revert on failure
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>
<div class="flex h-full flex-col overflow-y-auto bg-surface">
@@ -309,20 +161,22 @@ $effect(() => {
<div
class="flex flex-col gap-3"
bind:this={listEl}
onpointermove={handlePointerMove}
onpointerup={handlePointerUp}
onpointermove={dragDrop.handlePointerMove}
onpointerup={dragDrop.handlePointerUp}
>
{#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>
{/if}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
data-block-wrapper
onblur={handleBlur}
onpointerdown={(e) => 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;`
: ''}
>
<TranscriptionBlock
blockId={block.id}
@@ -332,13 +186,13 @@ $effect(() => {
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(() => {
</div>
{/each}
{#if dropTargetIdx === sortedBlocks.length}
{#if dragDrop.dropTargetIdx === sortedBlocks.length}
<div class="h-1 rounded-full bg-turquoise transition-all"></div>
{/if}