import { useCallback, useLayoutEffect, useRef, useState } from "react"; import { useLatest } from "./useLatest"; // A generic, reusable undo/redo container for a single piece of state. // // It keeps a past/present/future snapshot stack: `set` records the current value into the // past and clears the redo stack; `undo`/`redo` walk the stack. Every value change (set, // undo, redo) calls `onApply(value)` — the side effect that makes the change real (e.g. // persist to the server). `reset` installs a fresh value and clears history WITHOUT calling // onApply (use it when the underlying data is reloaded from the source of truth). // // Not playlist-specific: any component with an undoable value (sort order, a draft, a set // of filters…) can use it. The history is state (what render reads) mirrored in a ref (what the // stable callbacks read, so two calls in one tick chain on each other, never on a stale snapshot — // save after a `reset`, which is state only and reaches the mirror at its commit; see `reset`). // Render never reads the ref: a compiled component would otherwise memoize a stale value. export interface Undoable { value: T; set: (next: T) => void; reset: (value: T) => void; rebase: (map: (value: T) => T) => void; undo: () => void; redo: () => void; canUndo: boolean; canRedo: boolean; } interface History { past: T[]; present: T; future: T[]; } export function useUndoable( initial: T, opts?: { onApply?: (value: T) => void; limit?: number }, ): Undoable { const [h, setH] = useState>(() => ({ past: [], present: initial, future: [] })); const ref = useRef(h); // The mirror follows every commit, so a `reset` made during render (a caller re-seeding on a new // selection) reaches the callbacks without a render-phase ref write. useLayoutEffect(() => { ref.current = h; }, [h]); const commit = useCallback((next: History) => { ref.current = next; setH(next); }, []); const onApplyRef = useLatest(opts?.onApply); const limit = opts?.limit ?? 100; const set = useCallback( (next: T) => { const s = ref.current; if (Object.is(next, s.present)) return; const past = [...s.past, s.present]; if (past.length > limit) past.shift(); commit({ past, present: next, future: [] }); onApplyRef.current?.(next); }, [limit, commit, onApplyRef], ); const undo = useCallback(() => { const s = ref.current; if (!s.past.length) return; const prev = s.past[s.past.length - 1]!; // past is non-empty (guarded above) commit({ past: s.past.slice(0, -1), present: prev, future: [s.present, ...s.future], }); onApplyRef.current?.(prev); }, [commit, onApplyRef]); const redo = useCallback(() => { const s = ref.current; if (!s.future.length) return; const next = s.future[0]!; // future is non-empty (guarded above) const rest = s.future.slice(1); commit({ past: [...s.past, s.present], present: next, future: rest }); onApplyRef.current?.(next); }, [commit, onApplyRef]); // State only: safe to call during render. `rebase` builds on it at once; `set`/`undo`/`redo` read // the mirror, so they see it from the next commit on (no caller acts in the tick of a reset — a // set, undo or redo in that tick would commit the history from before it). const reset = useCallback((value: T) => { setH({ past: [], present: value, future: [] }); }, []); // Apply the same transformation to EVERY snapshot — past, present and future — and keep the // history. For changes that are outside the undoable dimension but invalidate the snapshots: // removing a playlist item is not itself undoable, yet dropping it from each stored order keeps // the user's reorder history valid and usable instead of throwing it away (which is what a // `reset` does). No `onApply`: the caller is already persisting the change by its own route. // The STATE through an updater: a `reset` earlier in the same tick is state only, so the mirror // still held the history before it, and rebasing that wrote the old value back over the reset (a // cached playlist remounted under StrictMode re-seeds with reset-then-rebase and painted empty). // The mirror is rebased too, so an `undo` later in the same tick steps back to a rebased snapshot, // not to one that still holds the removed row. Called from handlers and // effects only, never during render. const rebase = useCallback((map: (value: T) => T) => { const over = (s: History) => ({ past: s.past.map(map), present: map(s.present), future: s.future.map(map), }); ref.current = over(ref.current); setH(over); }, []); return { value: h.present, set, reset, rebase, undo, redo, canUndo: h.past.length > 0, canRedo: h.future.length > 0, }; }