import { useEffect, useRef, useState } from "react"; import type { ActionRegistry, EditAction } from "./types.js"; interface HistoryState { past: EditAction[][]; future: EditAction[][]; } interface CurrentState { url: string; blob: Blob | null; /** True if `url` is an object URL we created and must eventually revoke. */ generated: boolean; } export interface UseImageHistoryResult { currentImageUrl: string; currentBlob: Blob | null; /** Synchronous read of the current image URL — valid immediately after `applyActions`. */ getCurrentImageUrl: () => string; /** Synchronous read of the current blob — valid immediately after `applyActions`. */ getCurrentBlob: () => Blob | null; canUndo: boolean; canRedo: boolean; /** True while an async replay (undo/redo) is in progress. */ replaying: boolean; /** Applies a new blob to the current state and records it in history. Returns the new object URL. */ applyActions: (blob: Blob, actions: EditAction[]) => string; undo: () => void; redo: () => void; reset: () => void; } /** * Manages undo/redo history for the image editor using action replay. * * Only action lists are stored — no intermediate blobs. Undo/redo replays * all remaining actions from the source image, keeping memory usage minimal * regardless of history depth. Only one blob is in memory at a time. */ export function useImageHistory(sourceUrl: string, registry: ActionRegistry): UseImageHistoryResult { let [history, setHistory] = useState({ past: [], future: [] }); let [current, setCurrent] = useState({ url: sourceUrl, blob: null, generated: false }); let [replaying, setReplaying] = useState(false); let historyRef = useRef(history); historyRef.current = history; // Written synchronously by `replaceCurrent` so imperative callers can read the current // image/blob before React has committed the corresponding render. let currentRef = useRef(current); let isReplaying = useRef(false); let replayAbort = useRef(null); function replaceCurrent(next: CurrentState) { let prev = currentRef.current; if (prev.generated && prev.url !== next.url) URL.revokeObjectURL(prev.url); currentRef.current = next; setCurrent(next); } // When source changes (e.g. component remount with new image), reset fully. useEffect(() => { replayAbort.current?.abort(); isReplaying.current = false; setReplaying(false); replaceCurrent({ url: sourceUrl, blob: null, generated: false }); setHistory({ past: [], future: [] }); }, [sourceUrl]); useEffect(() => { return () => { if (currentRef.current.generated) URL.revokeObjectURL(currentRef.current.url); replayAbort.current?.abort(); }; }, []); function applyActions(blob: Blob, actions: EditAction[]) { // A direct apply always takes priority — abort any in-flight replay. replayAbort.current?.abort(); isReplaying.current = false; setReplaying(false); let newUrl = URL.createObjectURL(blob); replaceCurrent({ url: newUrl, blob, generated: true }); setHistory((prev) => ({ past: [...prev.past, actions], future: [] })); return newUrl; } async function doReplay(newPast: EditAction[][], newFuture: EditAction[][]) { replayAbort.current?.abort(); let abort = new AbortController(); replayAbort.current = abort; isReplaying.current = true; setReplaying(true); try { let result = await replayAll(sourceUrl, newPast, registry, abort.signal); if (abort.signal.aborted) return; if (result) { replaceCurrent({ url: result.url, blob: result.blob, generated: true }); } else { replaceCurrent({ url: sourceUrl, blob: null, generated: false }); } setHistory({ past: newPast, future: newFuture }); } finally { if (!abort.signal.aborted) { isReplaying.current = false; setReplaying(false); } } } function undo() { if (isReplaying.current) return; let { past, future } = historyRef.current; if (past.length === 0) return; doReplay(past.slice(0, -1), [past.at(-1)!, ...future]); } function redo() { if (isReplaying.current) return; let { past, future } = historyRef.current; if (future.length === 0) return; doReplay([...past, future[0]], future.slice(1)); } function reset() { replayAbort.current?.abort(); isReplaying.current = false; setReplaying(false); replaceCurrent({ url: sourceUrl, blob: null, generated: false }); setHistory({ past: [], future: [] }); } return { currentImageUrl: current.url, currentBlob: current.blob, getCurrentImageUrl: () => currentRef.current.url, getCurrentBlob: () => currentRef.current.blob, canUndo: history.past.length > 0, canRedo: history.future.length > 0, replaying, applyActions, undo, redo, reset, }; } /** * Replays a sequence of action groups from a source URL. * Intermediate object URLs are revoked as soon as a new step succeeds. * Returns the final `{ url, blob }` or `null` if there were no actions. */ async function replayAll( sourceUrl: string, actionGroups: EditAction[][], registry: ActionRegistry, signal: AbortSignal, ): Promise<{ url: string; blob: Blob } | null> { let prevUrl = sourceUrl; let prevGenerated = false; let lastBlob: Blob | null = null; for (let group of actionGroups) { for (let action of group) { if (signal.aborted) { if (prevGenerated) URL.revokeObjectURL(prevUrl); return null; } let apply = registry.get(action.type); if (!apply) { console.warn(`[ImageHistory] No replay handler for action type "${action.type}" — skipping`); continue; } let blob = await apply(prevUrl, action.params); if (signal.aborted) { if (prevGenerated) URL.revokeObjectURL(prevUrl); return null; } if (prevGenerated) URL.revokeObjectURL(prevUrl); lastBlob = blob; prevUrl = URL.createObjectURL(blob); prevGenerated = true; } } if (!lastBlob || !prevGenerated) return null; return { url: prevUrl, blob: lastBlob }; }