124 lines
3.2 KiB
TypeScript
124 lines
3.2 KiB
TypeScript
|
|
import { useCallback, useRef, useState } from "react";
|
||
|
|
import { cloneBands } from "../peqMappers";
|
||
|
|
|
||
|
|
export const PEQ_UNDO_MAX_STEPS = 30;
|
||
|
|
|
||
|
|
export type PeqUndoBand = {
|
||
|
|
freq: number;
|
||
|
|
gain: number;
|
||
|
|
q: number;
|
||
|
|
type: string;
|
||
|
|
enabled: boolean;
|
||
|
|
};
|
||
|
|
|
||
|
|
export type PeqUndoSnapshot = {
|
||
|
|
abMode: "A" | "B";
|
||
|
|
bandsByMode: { A: PeqUndoBand[]; B: PeqUndoBand[] };
|
||
|
|
preamp: number;
|
||
|
|
autoPre: number;
|
||
|
|
};
|
||
|
|
|
||
|
|
function cloneSnapshot(snapshot: PeqUndoSnapshot): PeqUndoSnapshot {
|
||
|
|
return {
|
||
|
|
abMode: snapshot.abMode,
|
||
|
|
bandsByMode: {
|
||
|
|
A: cloneBands(snapshot.bandsByMode.A),
|
||
|
|
B: cloneBands(snapshot.bandsByMode.B),
|
||
|
|
},
|
||
|
|
preamp: snapshot.preamp,
|
||
|
|
autoPre: snapshot.autoPre,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
export function peqUndoSnapshotsEqual(
|
||
|
|
a: PeqUndoSnapshot,
|
||
|
|
b: PeqUndoSnapshot,
|
||
|
|
): boolean {
|
||
|
|
if (a.abMode !== b.abMode) return false;
|
||
|
|
if (a.preamp !== b.preamp || a.autoPre !== b.autoPre) return false;
|
||
|
|
return (
|
||
|
|
JSON.stringify(a.bandsByMode.A) === JSON.stringify(b.bandsByMode.A) &&
|
||
|
|
JSON.stringify(a.bandsByMode.B) === JSON.stringify(b.bandsByMode.B)
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Undo stack for EQ parameter edits.
|
||
|
|
* - One stack entry = one complete gesture (drag release / confirmed change)
|
||
|
|
* - Max {@link PEQ_UNDO_MAX_STEPS} entries
|
||
|
|
* - No redo
|
||
|
|
*/
|
||
|
|
export function usePeqUndoHistory(maxSteps = PEQ_UNDO_MAX_STEPS) {
|
||
|
|
const stackRef = useRef<PeqUndoSnapshot[]>([]);
|
||
|
|
const pendingBeforeRef = useRef<PeqUndoSnapshot | null>(null);
|
||
|
|
const [canUndo, setCanUndo] = useState(false);
|
||
|
|
|
||
|
|
const syncCanUndo = useCallback(() => {
|
||
|
|
setCanUndo(stackRef.current.length > 0);
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
const clear = useCallback(() => {
|
||
|
|
stackRef.current = [];
|
||
|
|
pendingBeforeRef.current = null;
|
||
|
|
setCanUndo(false);
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
const push = useCallback(
|
||
|
|
(snapshot: PeqUndoSnapshot) => {
|
||
|
|
stackRef.current.push(cloneSnapshot(snapshot));
|
||
|
|
while (stackRef.current.length > maxSteps) {
|
||
|
|
stackRef.current.shift();
|
||
|
|
}
|
||
|
|
syncCanUndo();
|
||
|
|
},
|
||
|
|
[maxSteps, syncCanUndo],
|
||
|
|
);
|
||
|
|
|
||
|
|
/** Discrete edit: push current state as undo target before mutating. */
|
||
|
|
const captureBefore = useCallback(
|
||
|
|
(snapshot: PeqUndoSnapshot) => {
|
||
|
|
pendingBeforeRef.current = null;
|
||
|
|
push(snapshot);
|
||
|
|
},
|
||
|
|
[push],
|
||
|
|
);
|
||
|
|
|
||
|
|
/** Continuous gesture start (slider / curve drag). */
|
||
|
|
const beginGesture = useCallback((snapshot: PeqUndoSnapshot) => {
|
||
|
|
pendingBeforeRef.current = cloneSnapshot(snapshot);
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
/** Continuous gesture end: commit only if state actually changed. */
|
||
|
|
const commitGesture = useCallback(
|
||
|
|
(current: PeqUndoSnapshot) => {
|
||
|
|
const before = pendingBeforeRef.current;
|
||
|
|
pendingBeforeRef.current = null;
|
||
|
|
if (!before) return;
|
||
|
|
if (peqUndoSnapshotsEqual(before, current)) return;
|
||
|
|
push(before);
|
||
|
|
},
|
||
|
|
[push],
|
||
|
|
);
|
||
|
|
|
||
|
|
const cancelGesture = useCallback(() => {
|
||
|
|
pendingBeforeRef.current = null;
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
const undo = useCallback((): PeqUndoSnapshot | null => {
|
||
|
|
pendingBeforeRef.current = null;
|
||
|
|
const next = stackRef.current.pop() ?? null;
|
||
|
|
syncCanUndo();
|
||
|
|
return next ? cloneSnapshot(next) : null;
|
||
|
|
}, [syncCanUndo]);
|
||
|
|
|
||
|
|
return {
|
||
|
|
canUndo,
|
||
|
|
clear,
|
||
|
|
captureBefore,
|
||
|
|
beginGesture,
|
||
|
|
commitGesture,
|
||
|
|
cancelGesture,
|
||
|
|
undo,
|
||
|
|
};
|
||
|
|
}
|