新增 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
+144 -9
View File
@@ -44,7 +44,13 @@ import { ShareDialog } from "./eq/components/ShareDialog";
import { BrandDrawer } from "./eq/components/BrandDrawer";
import { PeqOverwriteConfirmDialog } from "./eq/components/PeqOverwriteConfirmDialog";
import { PeqPresetManageDialog } from "./eq/components/PeqPresetManageDialog";
import { PeqUndoButton } from "./eq/components/PeqUndoButton";
import { useRawCurve } from "./eq/hooks/useRawCurve";
import {
usePeqUndoHistory,
type PeqUndoSnapshot,
} from "./eq/hooks/usePeqUndoHistory";
import { formatPeqUndoToast } from "./eq/peqUndoDiff";
import { isPeqPresetNameTooLong } from "./eq/peqPresetName";
import {
BAND_FREQ_MAX,
@@ -180,6 +186,14 @@ export default function EQPage() {
setRawCurveLoading,
loadRawCurveForPeq,
} = useRawCurve();
const {
canUndo,
clear: clearPeqUndo,
captureBefore,
beginGesture,
commitGesture,
undo: popPeqUndo,
} = usePeqUndoHistory();
const allowPeqRemoteSyncRef = useRef(false);
const peqHydrationPendingRef = useRef(true);
const lastPeqCatalogSyncKeyRef = useRef("");
@@ -235,6 +249,32 @@ export default function EQPage() {
const preampValue = Number(currentPeqPreamp);
const autoPreOn = currentPeqAutoPre === 1;
const undoSnapshotSourceRef = useRef({
abMode,
bandsByMode,
preamp: preampValue,
autoPre: autoPreOn ? 1 : 0,
});
undoSnapshotSourceRef.current = {
abMode,
bandsByMode,
preamp: preampValue,
autoPre: autoPreOn ? 1 : 0,
};
const takeUndoSnapshot = useCallback((): PeqUndoSnapshot => {
const src = undoSnapshotSourceRef.current;
return {
abMode: src.abMode,
bandsByMode: {
A: cloneBands(src.bandsByMode.A),
B: cloneBands(src.bandsByMode.B),
},
preamp: src.preamp,
autoPre: src.autoPre,
};
}, []);
const guardPeqPresetCapacity = () => {
if (peqItems.length < PEQ_PRESET_MAX) return true;
toast.error(
@@ -400,6 +440,7 @@ export default function EQPage() {
const handleSaveBatchEdit = () => {
try {
const parsed = parseBatchEditText(batchEditText);
captureBefore(takeUndoSnapshot());
let updatedPeq: { name: string; autoPre?: number; preamp?: number; canDel?: number } | undefined;
setPeqItems((prev) =>
prev.map((item, i) => {
@@ -1021,6 +1062,17 @@ export default function EQPage() {
return [headphoneIdx, peq.name ?? "", peq.brand ?? "", peq.model ?? ""].join("\u0001");
}, [headphoneIdx, peqItems]);
// 切换预设或离开页面时清空撤销栈
useEffect(() => {
clearPeqUndo();
}, [headphoneSwitchKey, clearPeqUndo]);
useEffect(() => {
return () => {
clearPeqUndo();
};
}, [clearPeqUndo]);
// 切换耳机型号后,从 peqItems 加载该型号的 filters(暂停一次上报避免错写)
useEffect(() => {
const peq = peqItems[headphoneIdx];
@@ -1028,6 +1080,7 @@ export default function EQPage() {
const nextBands = normalizeFiltersFromPeq(peq);
if (!nextBands.length) return;
syncingHeadphoneRef.current = true;
skipPeqAutoSyncRef.current = true;
setBandsForBothModes(nextBands);
setSelectedBandByMode({ A: 0, B: 0 });
const shouldFetchModelCurve = !!(peq.brand?.trim() && peq.model?.trim());
@@ -1119,13 +1172,23 @@ export default function EQPage() {
const handleCopyAndSwitchTo = useCallback(
(to: "A" | "B") => {
const from = abMode;
captureBefore(takeUndoSnapshot());
const copied = cloneBands(bandsByMode[from]);
copyModeParams(from, to);
skipBandsSyncFromAbToggleRef.current = true;
setAbMode(to);
schedulePeqSync(peqItems[headphoneIdx], copied, to, 0, "peqApply");
},
[abMode, bandsByMode, copyModeParams, headphoneIdx, peqItems, schedulePeqSync],
[
abMode,
bandsByMode,
captureBefore,
copyModeParams,
headphoneIdx,
peqItems,
schedulePeqSync,
takeUndoSnapshot,
],
);
// 参数编辑防抖:A→peqChangeB→peqApply(不因切换 A/B 误触发)
@@ -1177,6 +1240,8 @@ export default function EQPage() {
return;
}
captureBefore(takeUndoSnapshot());
const bBands = cloneBands(bandsByMode.B);
const filters = bBands.map(bandToPeqFilter);
const payload: PeqChangePayload = {
@@ -1222,10 +1287,13 @@ export default function EQPage() {
}
}, [
bandsByMode.B,
captureBefore,
eqUi.toastApplyBFail,
eqUi.toastApplyBSuccess,
headphoneIdx,
peqItems,
rememberPeqPresetCache,
takeUndoSnapshot,
upgradePeqChange,
]);
@@ -1245,17 +1313,22 @@ export default function EQPage() {
});
}, []);
const beginBandSliderDrag = useCallback((kind: BandParamKind) => {
bandSliderDragRef.current = kind;
}, []);
const beginBandSliderDrag = useCallback(
(kind: BandParamKind) => {
bandSliderDragRef.current = kind;
beginGesture(takeUndoSnapshot());
},
[beginGesture, takeUndoSnapshot],
);
const endBandSliderDrag = useCallback(
(kind: BandParamKind) => {
if (bandSliderDragRef.current !== kind) return;
bandSliderDragRef.current = null;
clearBandSliderPreviewField(kind);
commitGesture(takeUndoSnapshot());
},
[clearBandSliderPreviewField],
[clearBandSliderPreviewField, commitGesture, takeUndoSnapshot],
);
const handleBandSliderChange = useCallback(
@@ -1336,6 +1409,7 @@ export default function EQPage() {
toast.error(parsed.message);
return;
}
captureBefore(takeUndoSnapshot());
bandSliderDragRef.current = null;
if (bandParamDialog === "freq") updateBand(selectedBand, { freq: parsed.value });
else if (bandParamDialog === "gain") updateBand(selectedBand, { gain: parsed.value });
@@ -1359,6 +1433,50 @@ export default function EQPage() {
schedulePeqSync(updatedPeq, bands, abMode, 0, "byMode", headphoneIdx);
};
const beginPreampSliderDrag = () => {
beginGesture(takeUndoSnapshot());
};
const endPreampSliderDrag = () => {
commitGesture(takeUndoSnapshot());
};
const handleAutoPreChange = (v: boolean) => {
captureBefore(takeUndoSnapshot());
updateCurrentPeqMeta({ autoPre: v ? 1 : 0 });
};
const handleUndo = () => {
const current = takeUndoSnapshot();
const snap = popPeqUndo();
if (!snap) return;
const toastMsg = formatPeqUndoToast(current, snap, eqUi);
setAbMode(snap.abMode);
setBandsByMode({
A: cloneBands(snap.bandsByMode.A as unknown as typeof DEFAULT_BANDS),
B: cloneBands(snap.bandsByMode.B as unknown as typeof DEFAULT_BANDS),
});
setPeqItems((prev) =>
prev.map((item, i) => {
if (i !== headphoneIdx) return item;
const filters = snap.bandsByMode.A.map(bandToPeqFilter);
const merged = {
...item,
preamp: snap.preamp,
autoPre: snap.autoPre,
filters,
};
rememberPeqPresetCache(item.name, {
filters,
preamp: snap.preamp,
autoPre: snap.autoPre,
});
return merged;
}),
);
toast.success(toastMsg);
};
return (
<div className="min-h-screen bg-black">
<div style={{
@@ -1434,10 +1552,13 @@ export default function EQPage() {
</div>
{/* ── Headphone model selector ── */}
<div className="flex items-center justify-between px-1">
<div className="flex min-w-0 items-center gap-2 px-1">
<div
ref={headphoneMenuRef}
className="relative z-40 flex-1 mr-2 rounded-[12px] overflow-visible backdrop-blur-md transition-all"
className={cn(
"relative min-w-0 flex-1 max-w-[calc(100%-9.5rem)] rounded-[12px] overflow-visible backdrop-blur-md transition-all",
isHeadphoneMenuOpen && "z-30",
)}
style={{
background: "linear-gradient(180deg, rgba(36,38,42,0.92) 0%, rgba(24,26,30,0.92) 100%)",
border: "1px solid rgba(0,255,246,0.52)",
@@ -1485,7 +1606,12 @@ export default function EQPage() {
</div>
)}
</div>
<div className="flex items-center gap-2">
<div className="flex shrink-0 items-center gap-2">
<PeqUndoButton
disabled={!canUndo}
label={eqUi.undo ?? "Undo"}
onClick={handleUndo}
/>
<button
className="w-9 h-9 rounded-full flex items-center justify-center text-white/60 active:text-white transition-colors"
style={{ background: "rgba(44,44,46,0.8)", border: "1px solid rgba(255,255,255,0.1)" }}
@@ -1524,6 +1650,8 @@ export default function EQPage() {
onApplyB={() => void handleApplyB()}
onSaveB={openSaveBDialog}
onBandDrag={(idx, patch) => setBands((prev) => prev.map((b, i) => i === idx ? { ...b, ...patch } : b))}
onBandDragStart={() => beginGesture(takeUndoSnapshot())}
onBandDragEnd={() => commitGesture(takeUndoSnapshot())}
onBandSelect={setSelectedBand}
eqUi={eqUi}
/>
@@ -1601,6 +1729,11 @@ export default function EQPage() {
}`}
style={active ? { background: "#00FFF6" } : undefined}
onClick={() => {
if (normalizeFilterType(band.type) === type) {
setIsFilterMenuOpen(false);
return;
}
captureBefore(takeUndoSnapshot());
updateBand(selectedBand, { type });
setIsFilterMenuOpen(false);
}}
@@ -1702,7 +1835,7 @@ export default function EQPage() {
<span className="text-[12px] text-white/40 tracking-wider">AUTO</span>
<IOSToggle
checked={autoPreOn}
onChange={(v) => updateCurrentPeqMeta({ autoPre: v ? 1 : 0 })}
onChange={handleAutoPreChange}
/>
</div>
</div>
@@ -1712,6 +1845,8 @@ export default function EQPage() {
max={30}
step={0.1}
disabled={autoPreOn}
onInteractionStart={beginPreampSliderDrag}
onInteractionEnd={endPreampSliderDrag}
onChange={(v) => updateCurrentPeqMeta({ preamp: Number((v - 15).toFixed(1)) })}
/>
</div>