From 75cced3d23fd059eaa772fe4fff3fb57bf606368 Mon Sep 17 00:00:00 2001 From: eafonyang Date: Thu, 6 Aug 2026 12:01:50 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=20EQ=E6=92=A4=E9=94=80?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=8C=E7=94=A8=E6=88=B7=E8=B0=83=E8=8A=82?= =?UTF-8?q?=E7=9A=84=E5=8F=82=E6=95=B0=E4=B8=8D=E6=BB=A1=E6=84=8F=E5=8F=AF?= =?UTF-8?q?=E4=BB=A5=E7=82=B9=E5=87=BB=E5=9B=9E=E9=80=80=E5=88=B0=E4=B8=8A?= =?UTF-8?q?=E4=B8=80=E6=AC=A1=EF=BC=8C=E6=92=A4=E9=94=80=E6=A0=88=E6=9C=80?= =?UTF-8?q?=E5=A4=9A=E5=AD=98=E6=94=BE=2030=20=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/locales/data-de.json | 17 ++ client/src/locales/data-en.json | 17 ++ client/src/locales/data-es.json | 17 ++ client/src/locales/data-fr.json | 17 ++ client/src/locales/data-zh-HK.json | 17 ++ client/src/locales/data-zh.json | 17 ++ client/src/pages/EQPage.tsx | 139 +++++++++++++- client/src/pages/eq/components/FreqChart.tsx | 7 + .../src/pages/eq/components/PeqUndoButton.tsx | 46 +++++ .../src/pages/eq/hooks/usePeqUndoHistory.ts | 123 +++++++++++++ client/src/pages/eq/peqUndoDiff.ts | 171 ++++++++++++++++++ 11 files changed, 582 insertions(+), 6 deletions(-) create mode 100644 client/src/pages/eq/components/PeqUndoButton.tsx create mode 100644 client/src/pages/eq/hooks/usePeqUndoHistory.ts create mode 100644 client/src/pages/eq/peqUndoDiff.ts diff --git a/client/src/locales/data-de.json b/client/src/locales/data-de.json index 985bb9e..d3f13fa 100644 --- a/client/src/locales/data-de.json +++ b/client/src/locales/data-de.json @@ -270,6 +270,23 @@ "closeDrawer": "Schließen", "toastBatchApplied": "Stapelbearbeitung angewendet", "toastBatchParseError": "Ungültiges Stapelbearbeitungsformat", + "undo": "Rückgängig", + "toastUndo": "Rückgängig: {{detail}}", + "toastUndoEmpty": "Rückgängig gemacht", + "undoBandParam": "{{mode}} Band {{band}} {{param}} {{from}} → {{to}}", + "undoSimpleParam": "{{param}} {{from}} → {{to}}", + "undoMultiple": "{{count}} Änderungen ({{summary}})", + "undoMoreSuffix": " (+{{count}} weitere)", + "undoDetailSep": "; ", + "undoParamFreq": "FREQ", + "undoParamGain": "GAIN", + "undoParamQ": "Q", + "undoParamType": "Filter", + "undoParamPreamp": "Preamp", + "undoParamAuto": "AUTO", + "undoParamAbMode": "A/B", + "undoAutoOn": "EIN", + "undoAutoOff": "AUS", "toastBrandsFail": "Laden der Marken fehlgeschlagen", "toastModelsFail": "Laden der Modelle fehlgeschlagen", "toastCurveFail": "Laden der Kurve fehlgeschlagen", diff --git a/client/src/locales/data-en.json b/client/src/locales/data-en.json index 5360642..9bc24bb 100644 --- a/client/src/locales/data-en.json +++ b/client/src/locales/data-en.json @@ -270,6 +270,23 @@ "closeDrawer": "Close", "toastBatchApplied": "Batch edit applied", "toastBatchParseError": "Invalid batch edit format", + "undo": "Undo", + "toastUndo": "Undone: {{detail}}", + "toastUndoEmpty": "Undone", + "undoBandParam": "{{mode}} Band {{band}} {{param}} {{from}} → {{to}}", + "undoSimpleParam": "{{param}} {{from}} → {{to}}", + "undoMultiple": "{{count}} changes ({{summary}})", + "undoMoreSuffix": " (+{{count}} more)", + "undoDetailSep": "; ", + "undoParamFreq": "FREQ", + "undoParamGain": "GAIN", + "undoParamQ": "Q", + "undoParamType": "Filter", + "undoParamPreamp": "Preamp", + "undoParamAuto": "AUTO", + "undoParamAbMode": "A/B", + "undoAutoOn": "ON", + "undoAutoOff": "OFF", "toastBrandsFail": "Failed to load brands", "toastModelsFail": "Failed to load models", "toastCurveFail": "Failed to load curve", diff --git a/client/src/locales/data-es.json b/client/src/locales/data-es.json index 4db571c..9c94c39 100644 --- a/client/src/locales/data-es.json +++ b/client/src/locales/data-es.json @@ -270,6 +270,23 @@ "closeDrawer": "Cerrar", "toastBatchApplied": "Edición por lotes aplicada", "toastBatchParseError": "Formato de edición por lotes inválido", + "undo": "Deshacer", + "toastUndo": "Deshecho: {{detail}}", + "toastUndoEmpty": "Deshecho", + "undoBandParam": "{{mode}} Banda {{band}} {{param}} {{from}} → {{to}}", + "undoSimpleParam": "{{param}} {{from}} → {{to}}", + "undoMultiple": "{{count}} cambios ({{summary}})", + "undoMoreSuffix": " (+{{count}} más)", + "undoDetailSep": "; ", + "undoParamFreq": "FREQ", + "undoParamGain": "GAIN", + "undoParamQ": "Q", + "undoParamType": "Filtro", + "undoParamPreamp": "Preamp", + "undoParamAuto": "AUTO", + "undoParamAbMode": "A/B", + "undoAutoOn": "ON", + "undoAutoOff": "OFF", "toastBrandsFail": "Error al cargar las marcas", "toastModelsFail": "Error al cargar los modelos", "toastCurveFail": "Error al cargar la curva", diff --git a/client/src/locales/data-fr.json b/client/src/locales/data-fr.json index b61559d..e5241f5 100644 --- a/client/src/locales/data-fr.json +++ b/client/src/locales/data-fr.json @@ -270,6 +270,23 @@ "closeDrawer": "Fermer", "toastBatchApplied": "Édition par lot appliquée", "toastBatchParseError": "Format d'édition par lot invalide", + "undo": "Annuler", + "toastUndo": "Annulé : {{detail}}", + "toastUndoEmpty": "Annulé", + "undoBandParam": "{{mode}} Bande {{band}} {{param}} {{from}} → {{to}}", + "undoSimpleParam": "{{param}} {{from}} → {{to}}", + "undoMultiple": "{{count}} modifications ({{summary}})", + "undoMoreSuffix": " (+{{count}})", + "undoDetailSep": " ; ", + "undoParamFreq": "FREQ", + "undoParamGain": "GAIN", + "undoParamQ": "Q", + "undoParamType": "Filtre", + "undoParamPreamp": "Preamp", + "undoParamAuto": "AUTO", + "undoParamAbMode": "A/B", + "undoAutoOn": "ON", + "undoAutoOff": "OFF", "toastBrandsFail": "Échec du chargement des marques", "toastModelsFail": "Échec du chargement des modèles", "toastCurveFail": "Échec du chargement de la courbe", diff --git a/client/src/locales/data-zh-HK.json b/client/src/locales/data-zh-HK.json index e2a1a98..abc3cf0 100644 --- a/client/src/locales/data-zh-HK.json +++ b/client/src/locales/data-zh-HK.json @@ -263,6 +263,23 @@ "closeDrawer": "關閉", "toastBatchApplied": "批次編輯已套用", "toastBatchParseError": "批次編輯格式錯誤", + "undo": "復原", + "toastUndo": "已復原:{{detail}}", + "toastUndoEmpty": "已復原", + "undoBandParam": "{{mode}} 頻段{{band}} {{param}} {{from}} → {{to}}", + "undoSimpleParam": "{{param}} {{from}} → {{to}}", + "undoMultiple": "{{count}} 項變更({{summary}})", + "undoMoreSuffix": "(另有 {{count}} 項)", + "undoDetailSep": ";", + "undoParamFreq": "頻率", + "undoParamGain": "增益", + "undoParamQ": "Q", + "undoParamType": "濾波器", + "undoParamPreamp": "總增益", + "undoParamAuto": "AUTO", + "undoParamAbMode": "A/B", + "undoAutoOn": "開", + "undoAutoOff": "關", "toastBrandsFail": "取得品牌列表失敗", "toastModelsFail": "取得型號列表失敗", "toastCurveFail": "取得曲線失敗", diff --git a/client/src/locales/data-zh.json b/client/src/locales/data-zh.json index 4f699f9..d31fdcd 100644 --- a/client/src/locales/data-zh.json +++ b/client/src/locales/data-zh.json @@ -263,6 +263,23 @@ "closeDrawer": "关闭", "toastBatchApplied": "批量编辑已应用", "toastBatchParseError": "批量编辑格式错误", + "undo": "撤销", + "toastUndo": "已撤销:{{detail}}", + "toastUndoEmpty": "已撤销", + "undoBandParam": "{{mode}} 频段{{band}} {{param}} {{from}} → {{to}}", + "undoSimpleParam": "{{param}} {{from}} → {{to}}", + "undoMultiple": "{{count}} 项变更({{summary}})", + "undoMoreSuffix": "(另有 {{count}} 项)", + "undoDetailSep": ";", + "undoParamFreq": "频率", + "undoParamGain": "增益", + "undoParamQ": "Q", + "undoParamType": "滤波器", + "undoParamPreamp": "总增益", + "undoParamAuto": "AUTO", + "undoParamAbMode": "A/B", + "undoAutoOn": "开", + "undoAutoOff": "关", "toastBrandsFail": "获取品牌列表失败", "toastModelsFail": "获取型号列表失败", "toastCurveFail": "获取曲线失败", diff --git a/client/src/pages/EQPage.tsx b/client/src/pages/EQPage.tsx index e733307..ebbe6fe 100644 --- a/client/src/pages/EQPage.tsx +++ b/client/src/pages/EQPage.tsx @@ -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 (
+
@@ -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)) }) } diff --git a/client/src/pages/eq/components/FreqChart.tsx b/client/src/pages/eq/components/FreqChart.tsx index 858071e..bf6d133 100644 --- a/client/src/pages/eq/components/FreqChart.tsx +++ b/client/src/pages/eq/components/FreqChart.tsx @@ -40,6 +40,8 @@ export function FreqChart({ onApplyB, onSaveB, onBandDrag, + onBandDragStart, + onBandDragEnd, onBandSelect, eqUi, }: { @@ -53,6 +55,8 @@ export function FreqChart({ onApplyB: () => void; onSaveB: () => void; onBandDrag: (idx: number, patch: Partial<{ freq: number; gain: number }>) => void; + onBandDragStart?: () => void; + onBandDragEnd?: () => void; onBandSelect: (idx: number) => void; eqUi: PeqEqUi; }) { @@ -118,6 +122,7 @@ export function FreqChart({ e.stopPropagation(); draggingBandRef.current = idx; setIsBandDragging(true); + onBandDragStart?.(); onBandSelect(idx); updateBandFromPointer(idx, e.clientX, e.clientY); }; @@ -130,8 +135,10 @@ export function FreqChart({ }; const stopDragging = () => { + if (draggingBandRef.current === null) return; draggingBandRef.current = null; setIsBandDragging(false); + onBandDragEnd?.(); }; const curveData = useMemo( diff --git a/client/src/pages/eq/components/PeqUndoButton.tsx b/client/src/pages/eq/components/PeqUndoButton.tsx new file mode 100644 index 0000000..23294b7 --- /dev/null +++ b/client/src/pages/eq/components/PeqUndoButton.tsx @@ -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 ( + + ); +} diff --git a/client/src/pages/eq/hooks/usePeqUndoHistory.ts b/client/src/pages/eq/hooks/usePeqUndoHistory.ts new file mode 100644 index 0000000..ee3d5a7 --- /dev/null +++ b/client/src/pages/eq/hooks/usePeqUndoHistory.ts @@ -0,0 +1,123 @@ +import { useCallback, useRef, useState } from "react"; +import { cloneBands } from "../utils"; + +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([]); + const pendingBeforeRef = useRef(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, + }; +} diff --git a/client/src/pages/eq/peqUndoDiff.ts b/client/src/pages/eq/peqUndoDiff.ts new file mode 100644 index 0000000..0366bf7 --- /dev/null +++ b/client/src/pages/eq/peqUndoDiff.ts @@ -0,0 +1,171 @@ +import { + formatBandFreqDisplay, + formatBandGainDisplay, + formatBandQDisplay, + eqInterp, +} from "./utils"; +import type { PeqEqUi } from "./constants"; +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}`; +}