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

This commit is contained in:
eafonyang
2026-08-06 12:01:44 +08:00
parent 3d0f9fa254
commit 93c2cb7681
11 changed files with 582 additions and 6 deletions
+133 -6
View File
@@ -87,7 +87,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";
/* ── Default bands imported from eq/constants ── */
@@ -199,6 +205,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("");
@@ -261,6 +275,32 @@ export default function EQPage() {
const preampValue = Number(currentPeq?.preamp ?? 0);
const autoPreOn = (currentPeq?.autoPre ?? 0) === 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(
@@ -436,6 +476,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;
@@ -1062,6 +1103,17 @@ export default function EQPage() {
].join("\u0001");
}, [headphoneIdx, peqItems]);
// 切换预设或离开页面时清空撤销栈
useEffect(() => {
clearPeqUndo();
}, [headphoneSwitchKey, clearPeqUndo]);
useEffect(() => {
return () => {
clearPeqUndo();
};
}, [clearPeqUndo]);
// 切换耳机型号后,从 peqItems 加载该型号的 filters(暂停一次上报避免错写)
useEffect(() => {
const peq = peqItems[headphoneIdx];
@@ -1241,6 +1293,7 @@ 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;
@@ -1250,10 +1303,12 @@ export default function EQPage() {
[
abMode,
bandsByMode,
captureBefore,
copyModeParams,
headphoneIdx,
peqItems,
schedulePeqSync,
takeUndoSnapshot,
]
);
@@ -1310,6 +1365,8 @@ export default function EQPage() {
return;
}
captureBefore(takeUndoSnapshot());
const bBands = cloneBands(bandsByMode.B);
const filters = bBands.map(bandToPeqFilter);
const payload: PeqChangePayload = {
@@ -1355,11 +1412,13 @@ export default function EQPage() {
}
}, [
bandsByMode.B,
captureBefore,
eqUi.toastApplyBFail,
eqUi.toastApplyBSuccess,
headphoneIdx,
peqItems,
rememberPeqPresetCache,
takeUndoSnapshot,
upgradePeqChange,
]);
@@ -1385,17 +1444,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(
@@ -1485,6 +1549,7 @@ export default function EQPage() {
toast.error(parsed.message);
return;
}
captureBefore(takeUndoSnapshot());
bandSliderDragRef.current = null;
if (bandParamDialog === "freq")
updateBand(selectedBand, { freq: parsed.value });
@@ -1514,6 +1579,54 @@ 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
@@ -1627,7 +1740,7 @@ export default function EQPage() {
<div
ref={headphoneMenuRef}
className={cn(
"relative min-w-0 flex-1 max-w-[calc(100%-5.5rem)] rounded-[12px] overflow-visible backdrop-blur-md transition-all",
"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={{
@@ -1682,6 +1795,11 @@ export default function EQPage() {
)}
</div>
<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={{
@@ -1733,6 +1851,8 @@ export default function EQPage() {
prev.map((b, i) => (i === idx ? { ...b, ...patch } : b))
)
}
onBandDragStart={() => beginGesture(takeUndoSnapshot())}
onBandDragEnd={() => commitGesture(takeUndoSnapshot())}
onBandSelect={setSelectedBand}
eqUi={eqUi}
/>
@@ -1827,6 +1947,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);
}}
@@ -1947,7 +2072,7 @@ export default function EQPage() {
</span>
<IOSToggle
checked={autoPreOn}
onChange={v => updateCurrentPeqMeta({ autoPre: v ? 1 : 0 })}
onChange={handleAutoPreChange}
/>
</div>
</div>
@@ -1957,6 +2082,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)) })
}