Files
familienarchiv/frontend/src/lib/hooks/useBlockAutoSave.svelte.ts
Marcel 02d3e2ab61 feat(transcription): swap plain textarea for PersonMentionEditor and thread mentionedPersons through autosave
- TranscriptionBlockData now carries mentionedPersons (matches backend
  schema added in PR-A).
- useBlockAutoSave.saveFn signature widens to (blockId, text, mentions);
  pendingMentions is tracked alongside pendingTexts and is preserved on
  failure so a retry resends the in-flight payload (B12).
- TranscriptionBlock.svelte renders <PersonMentionEditor>, exposing the
  textarea node back through a captureTextarea callback so the existing
  quote-selection feature still works.
- saveBlock in routes/documents/[id]/+page.svelte forwards mentions on
  PUT.
- flushOnUnload sends mentions in the keepalive payload too.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 00:32:09 +02:00

163 lines
4.4 KiB
TypeScript

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<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 pendingMentions = new SvelteMap<string, PersonMention[]>();
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);
}
function getPendingMentions(blockId: string, fallback: PersonMention[]): PersonMention[] {
return pendingMentions.get(blockId) ?? fallback;
}
async function executeSave(blockId: string): Promise<void> {
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<void> {
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
};
}