新增 EQ撤销功能,用户调节的参数不满意可以点击回退到上一次,撤销栈最多存放 30 步

This commit is contained in:
eafonyang
2026-08-06 12:01:47 +08:00
parent 2cd61ea7b5
commit 6bab63ab3d
11 changed files with 593 additions and 9 deletions
@@ -35,6 +35,8 @@ export type FreqChartProps = {
onApplyB: () => void;
onSaveB: () => void;
onBandDrag: (idx: number, patch: Partial<{ freq: number; gain: number }>) => void;
onBandDragStart?: () => void;
onBandDragEnd?: () => void;
onBandSelect: (idx: number) => void;
eqUi: PeqEqUi;
};
@@ -50,6 +52,8 @@ export function FreqChart({
onApplyB,
onSaveB,
onBandDrag,
onBandDragStart,
onBandDragEnd,
onBandSelect,
eqUi,
}: FreqChartProps) {
@@ -117,6 +121,7 @@ export function FreqChart({
e.stopPropagation();
draggingBandRef.current = idx;
setIsBandDragging(true);
onBandDragStart?.();
onBandSelect(idx);
updateBandFromPointer(idx, e.clientX, e.clientY);
};
@@ -129,8 +134,10 @@ export function FreqChart({
};
const stopDragging = () => {
if (draggingBandRef.current === null) return;
draggingBandRef.current = null;
setIsBandDragging(false);
onBandDragEnd?.();
};
const curveData = useMemo(
@@ -0,0 +1,46 @@
import { Undo2 } from "lucide-react";
import { cn } from "@/lib/utils";
type PeqUndoButtonProps = {
disabled?: boolean;
label: string;
onClick: () => void;
className?: string;
};
/**
* EQ page undo control — disabled when the undo stack is empty.
*/
export function PeqUndoButton({
disabled = false,
label,
onClick,
className,
}: PeqUndoButtonProps) {
return (
<button
type="button"
disabled={disabled}
aria-label={label}
title={label}
onClick={onClick}
className={cn(
"w-9 h-9 rounded-full flex items-center justify-center transition-colors active:scale-95",
disabled
? "text-white/25 cursor-not-allowed"
: "text-white/70 active:text-white",
className,
)}
style={{
background: disabled
? "rgba(44,44,46,0.45)"
: "rgba(44,44,46,0.8)",
border: disabled
? "1px solid rgba(255,255,255,0.06)"
: "1px solid rgba(255,255,255,0.1)",
}}
>
<Undo2 size={18} />
</button>
);
}
@@ -0,0 +1,123 @@
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,
};
}
+171
View File
@@ -0,0 +1,171 @@
import {
formatBandFreqDisplay,
formatBandGainDisplay,
formatBandQDisplay,
eqInterp,
} from "./eqFormatters";
import type { PeqEqUi } from "./types";
import type { PeqUndoBand, PeqUndoSnapshot } from "./hooks/usePeqUndoHistory";
export type PeqUndoDiffItem = {
/** Display label already localized where possible */
text: string;
};
function formatAuto(on: boolean, eqUi: PeqEqUi): string {
return on ? (eqUi.undoAutoOn ?? "ON") : (eqUi.undoAutoOff ?? "OFF");
}
function bandParamLabel(
param: "freq" | "gain" | "q" | "type",
eqUi: PeqEqUi,
): string {
switch (param) {
case "freq":
return eqUi.undoParamFreq ?? "FREQ";
case "gain":
return eqUi.undoParamGain ?? "GAIN";
case "q":
return eqUi.undoParamQ ?? "Q";
case "type":
return eqUi.undoParamType ?? "Filter";
}
}
function formatBandValue(
param: "freq" | "gain" | "q" | "type",
band: PeqUndoBand,
): string {
switch (param) {
case "freq":
return formatBandFreqDisplay(band.freq);
case "gain":
return formatBandGainDisplay(band.gain);
case "q":
return formatBandQDisplay(band.q);
case "type":
return String(band.type);
}
}
function diffBands(
fromBands: PeqUndoBand[],
toBands: PeqUndoBand[],
mode: "A" | "B",
eqUi: PeqEqUi,
): PeqUndoDiffItem[] {
const items: PeqUndoDiffItem[] = [];
const len = Math.max(fromBands.length, toBands.length);
for (let i = 0; i < len; i++) {
const from = fromBands[i];
const to = toBands[i];
if (!from || !to) continue;
const params = ["freq", "gain", "q", "type"] as const;
for (const param of params) {
const fromVal = formatBandValue(param, from);
const toVal = formatBandValue(param, to);
if (fromVal === toVal) continue;
const text =
eqInterp(eqUi.undoBandParam, {
mode,
band: i + 1,
param: bandParamLabel(param, eqUi),
from: fromVal,
to: toVal,
}) ||
`${mode} Band ${i + 1} ${bandParamLabel(param, eqUi)} ${fromVal} → ${toVal}`;
items.push({ text });
}
}
return items;
}
/**
* Diff current state (before clicking Undo) vs restored snapshot (after Undo).
* `from` is the value user just undid away from; `to` is the restored value.
*/
export function diffPeqUndoSnapshots(
current: PeqUndoSnapshot,
restored: PeqUndoSnapshot,
eqUi: PeqEqUi,
): PeqUndoDiffItem[] {
const items: PeqUndoDiffItem[] = [];
if (current.abMode !== restored.abMode) {
items.push({
text:
eqInterp(eqUi.undoSimpleParam, {
param: eqUi.undoParamAbMode ?? "A/B",
from: current.abMode,
to: restored.abMode,
}) ||
`A/B ${current.abMode} → ${restored.abMode}`,
});
}
if (current.preamp !== restored.preamp) {
items.push({
text:
eqInterp(eqUi.undoSimpleParam, {
param: eqUi.undoParamPreamp ?? "Preamp",
from: formatBandGainDisplay(current.preamp),
to: formatBandGainDisplay(restored.preamp),
}) ||
`Preamp ${formatBandGainDisplay(current.preamp)} → ${formatBandGainDisplay(restored.preamp)}`,
});
}
if (current.autoPre !== restored.autoPre) {
items.push({
text:
eqInterp(eqUi.undoSimpleParam, {
param: eqUi.undoParamAuto ?? "AUTO",
from: formatAuto(current.autoPre === 1, eqUi),
to: formatAuto(restored.autoPre === 1, eqUi),
}) ||
`AUTO ${formatAuto(current.autoPre === 1, eqUi)} → ${formatAuto(restored.autoPre === 1, eqUi)}`,
});
}
items.push(
...diffBands(current.bandsByMode.A, restored.bandsByMode.A, "A", eqUi),
);
items.push(
...diffBands(current.bandsByMode.B, restored.bandsByMode.B, "B", eqUi),
);
return items;
}
/** Build toast body for an undo action. */
export function formatPeqUndoToast(
current: PeqUndoSnapshot,
restored: PeqUndoSnapshot,
eqUi: PeqEqUi,
): string {
const diffs = diffPeqUndoSnapshots(current, restored, eqUi);
if (diffs.length === 0) {
return eqUi.toastUndoEmpty ?? eqUi.undo ?? "Undo";
}
if (diffs.length === 1) {
return (
eqInterp(eqUi.toastUndo, { detail: diffs[0]!.text }) ||
`Undone: ${diffs[0]!.text}`
);
}
const summary = diffs
.slice(0, 3)
.map((d) => d.text)
.join(eqUi.undoDetailSep ?? "; ");
const extra =
diffs.length > 3
? eqInterp(eqUi.undoMoreSuffix, { count: diffs.length - 3 }) ||
` (+${diffs.length - 3})`
: "";
const detail =
eqInterp(eqUi.undoMultiple, {
count: diffs.length,
summary: `${summary}${extra}`,
}) || `${diffs.length} changes: ${summary}${extra}`;
return eqInterp(eqUi.toastUndo, { detail }) || `Undone: ${detail}`;
}