import { SvelteMap } from 'svelte/reactivity'; import type { PersonMention } from '$lib/types'; export type SaveState = 'idle' | 'saving' | 'saved' | 'fading' | 'error'; type Options = { saveFn: (blockId: string, text: string, mentionedPersons: PersonMention[]) => Promise; documentId: string; }; export function createBlockAutoSave({ saveFn, documentId }: Options) { const saveStates = new SvelteMap(); const debounceTimers = new SvelteMap>(); const pendingTexts = new SvelteMap(); const pendingMentions = 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); } function getPendingMentions(blockId: string, fallback: PersonMention[]): PersonMention[] { return pendingMentions.get(blockId) ?? fallback; } async function executeSave(blockId: string): Promise { const text = pendingTexts.get(blockId); if (text === undefined) return; const mentions = pendingMentions.get(blockId) ?? []; pendingTexts.delete(blockId); pendingMentions.delete(blockId); setSaveState(blockId, 'saving'); try { await saveFn(blockId, text, mentions); setSaveState(blockId, 'saved'); scheduleSavedFade(blockId); } catch { // Preserve in-flight payload so the user can retry without re-typing. pendingTexts.set(blockId, text); pendingMentions.set(blockId, mentions); 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, mentionedPersons: PersonMention[] ): void { pendingTexts.set(blockId, text); pendingMentions.set(blockId, mentionedPersons); scheduleDebounce(blockId); } function handleMentionsChange(blockId: string, mentionedPersons: PersonMention[]): void { pendingMentions.set(blockId, mentionedPersons); // Mentions changes always accompany text changes from the editor, so the // text-debounce timer covers them too. } function handleBlur(): void { for (const [blockId] of [...debounceTimers]) { clearDebounce(blockId); executeSave(blockId); } } async function handleRetry( blockId: string, currentText: string, currentMentions: PersonMention[] ): Promise { const text = pendingTexts.get(blockId) ?? currentText; const mentions = pendingMentions.get(blockId) ?? currentMentions; pendingTexts.set(blockId, text); pendingMentions.set(blockId, mentions); await executeSave(blockId); } function clearBlock(blockId: string): void { clearDebounce(blockId); pendingTexts.delete(blockId); pendingMentions.delete(blockId); saveStates.delete(blockId); } function flushOnUnload(): void { for (const [blockId, text] of pendingTexts) { const mentions = pendingMentions.get(blockId) ?? []; clearDebounce(blockId); void fetch(`/api/documents/${documentId}/transcription-blocks/${blockId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text, mentionedPersons: mentions }), keepalive: true }); pendingTexts.delete(blockId); pendingMentions.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, getPendingMentions, handleTextChange, handleMentionsChange, handleBlur, handleRetry, clearBlock, flushOnUnload, destroy }; }