feat: add comprehensive Ctrl-Z/Ctrl-Y undo/redo engine for MAIN SESSION and SECTION-TAB

This commit is contained in:
2026-07-29 16:05:43 +07:00
parent f721b8242f
commit d4ad3bc1f7
3 changed files with 556 additions and 145 deletions
+379 -47
View File
@@ -6998,7 +6998,7 @@ const App = () => {
.catch(e => { console.error('listSoundfontInstruments failed:', e); setSfPresets([]); });
}
} else {
setTrackInstrumentWithProgram(trackId, instrumentId, undefined, displayName);
setTrackInstrumentWithUndo(trackId, instrumentId, displayName);
}
};
const [activeTool, setActiveTool] = useState('select'); // 'select' | 'grab' | 'razor'
@@ -7279,6 +7279,13 @@ const App = () => {
const [promptHistory, setPromptHistory] = useState([]);
const [promptHistIdx, setPromptHistIdx] = useState(-1);
const promptHistRef = useRef([]);
const aiPromptUndoRef = useRef({ stack: [], idx: -1, max: 30 });
const aiPromptUndoPush = (text) => {
const u = aiPromptUndoRef.current;
u.stack.push(text);
if (u.stack.length > u.max) u.stack.shift();
u.idx = u.stack.length - 1;
};
const [aiProvider, setAiProvider] = useState('OpenAI');
const [aiModel, setAiModel] = useState('GPT-4o');
const [aiActionLog, setAiActionLog] = useState([]);
@@ -7318,7 +7325,7 @@ const App = () => {
const [contextMenu, setContextMenu] = useState(null); // { x, y, trackId }
const clipboardRef = useRef(null); // { buffer, name, volume, color } for copy/paste
// Undo/Redo Engine (LOOP_EDITOR.md §4)
// Undo/Redo Engine (LOOP_EDITOR.md §4 + Global Extension)
const [undoStack, setUndoStack] = useState([]);
const [redoStack, setRedoStack] = useState([]);
const MAX_UNDO = 30;
@@ -7338,6 +7345,14 @@ const App = () => {
setRedoStack([]);
};
const handleUndo = () => {
if (window.UndoRedoEngine && window.UndoRedoEngine.canUndo()) {
const entry = window.UndoRedoEngine.undo();
if (entry) {
if (entry.undo && typeof entry.undo === 'function') entry.undo(entry);
showToast(`Undo: ${entry.label || entry.type}`, 'info');
return;
}
}
if (undoStack.length === 0) return;
const last = undoStack[undoStack.length - 1];
setUndoStack(prev => prev.slice(0, -1));
@@ -7346,6 +7361,14 @@ const App = () => {
showToast(`Undo: ${last.action_type}`, 'info');
};
const handleRedo = () => {
if (window.UndoRedoEngine && window.UndoRedoEngine.canRedo()) {
const entry = window.UndoRedoEngine.redo();
if (entry) {
if (entry.redo && typeof entry.redo === 'function') entry.redo(entry);
showToast(`Redo: ${entry.label || entry.type}`, 'info');
return;
}
}
if (redoStack.length === 0) return;
const last = redoStack[redoStack.length - 1];
setRedoStack(prev => prev.slice(0, -1));
@@ -7381,7 +7404,6 @@ const App = () => {
muted: track.muted,
name: track.name,
markers: JSON.parse(JSON.stringify(track.markers || [])),
// buffer is captured via reference copy for undo; we store a clone for redo
buffer: track.buffer,
startTime: track.startTime || 0,
clips: track.clips ? track.clips.map(c => ({
@@ -7393,6 +7415,224 @@ const App = () => {
};
};
const setBpmWithUndo = (newBpm) => {
const oldBpm = bpmRef.current;
if (String(oldBpm) === String(newBpm)) return;
const entry = {
type: 'SET_BPM',
scope: 'global',
label: `BPM ${oldBpm}${newBpm}`,
before: oldBpm,
after: newBpm,
undo: (e) => { setBpm(e.before); showToast(`Undo: BPM → ${e.before}`, 'info'); },
redo: (e) => { setBpm(e.after); showToast(`Redo: BPM → ${e.after}`, 'info'); }
};
if (window.UndoRedoEngine) window.UndoRedoEngine.execute(entry);
setBpm(String(newBpm));
};
const setPlayheadWithUndo = (newTime) => {
const oldTime = currentTime;
if (Math.abs(oldTime - newTime) < 0.001) return;
const entry = {
type: 'SET_PLAYHEAD',
scope: 'global',
label: `Playhead ${formatTimeSimple(oldTime)}${formatTimeSimple(newTime)}`,
before: oldTime,
after: newTime,
undo: (e) => { applyPlayheadDirect(e.before); showToast(`Undo: Playhead`, 'info'); },
redo: (e) => { applyPlayheadDirect(e.after); showToast(`Redo: Playhead`, 'info'); }
};
if (window.UndoRedoEngine) window.UndoRedoEngine.execute(entry);
applyPlayheadDirect(newTime);
};
const applyPlayheadDirect = (time) => {
localSelectionAnchorRef.current = time;
if (isPlaying) {
setCurrentTime(time);
stopAllPlayback();
setTimeout(() => {
startOffsetTimeRef.current = time;
startAudioTimeRef.current = getAudioContext().currentTime;
startTrackPlayback(time);
setIsPlaying(true);
}, 50);
} else {
setCurrentTime(time);
}
};
const setSelectionWithUndo = (newStart, newEnd, mode) => {
const oldStart = selectionRef.current.start;
const oldEnd = selectionRef.current.end;
const oldMode = selectionMode;
if (oldStart === newStart && oldEnd === newEnd && oldMode === mode) return;
const entry = {
type: 'SET_SELECTION',
scope: 'global',
label: `Selection`,
before: { start: oldStart, end: oldEnd, mode: oldMode },
after: { start: newStart, end: newEnd, mode: mode },
undo: (e) => {
setSelectionStart(e.before.start);
setSelectionEnd(e.before.end);
setSelectionMode(e.before.mode);
showToast(`Undo: Selection`, 'info');
},
redo: (e) => {
setSelectionStart(e.after.start);
setSelectionEnd(e.after.end);
setSelectionMode(e.after.mode);
showToast(`Redo: Selection`, 'info');
}
};
if (window.UndoRedoEngine) window.UndoRedoEngine.execute(entry);
setSelectionStart(newStart);
setSelectionEnd(newEnd);
if (mode !== undefined) setSelectionMode(mode);
};
const setSelectedItemsWithUndo = (newSet) => {
const oldSet = selectedItemIdsRef.current;
if (oldSet && newSet && oldSet.size === newSet.size && [...oldSet].every(x => newSet.has(x))) return;
const entry = {
type: 'SELECT_ITEMS',
scope: 'global',
label: `Selection`,
before: [...oldSet],
after: [...newSet],
undo: (e) => { setSelectedItemIds(new Set(e.before)); showToast(`Undo: Selection`, 'info'); },
redo: (e) => { setSelectedItemIds(new Set(e.after)); showToast(`Redo: Selection`, 'info'); }
};
if (window.UndoRedoEngine) window.UndoRedoEngine.execute(entry);
setSelectedItemIds(newSet);
};
const setAiPromptWithUndo = (newText) => {
const oldText = aiPrompt;
if (oldText === newText) return;
const entry = {
type: 'SET_AI_PROMPT',
scope: 'global',
label: `AI Prompt`,
before: oldText,
after: newText,
undo: (e) => { setAiPrompt(e.before); showToast(`Undo: AI Prompt`, 'info'); },
redo: (e) => { setAiPrompt(e.after); showToast(`Redo: AI Prompt`, 'info'); }
};
if (window.UndoRedoEngine) window.UndoRedoEngine.execute(entry);
setAiPrompt(newText);
};
const setTrackInstrumentWithUndo = (trackId, instrumentId, displayName, bankNumber, programNumber) => {
const track = activeTracks.find(t => t.id === trackId);
if (!track) return;
const oldInstrumentId = track.instrumentId;
const oldInstrumentName = track.instrumentName;
if (oldInstrumentId === instrumentId && oldInstrumentName === displayName) return;
const entry = {
type: 'SET_INSTRUMENT',
scope: 'track:' + trackId,
label: `Instrument ${track.name}`,
before: { instrumentId: oldInstrumentId, instrumentName: oldInstrumentName, bankNumber: track.soundfont_bank, programNumber: track.instrumentProgram },
after: { instrumentId, instrumentName: displayName, bankNumber, programNumber },
undo: (e) => {
setTrackInstrumentWithProgram(trackId, e.before.instrumentId, e.before.programNumber, e.before.instrumentName, e.before.bankNumber);
showToast(`Undo: Instrument`, 'info');
},
redo: (e) => {
setTrackInstrumentWithProgram(trackId, e.after.instrumentId, e.after.programNumber, e.after.instrumentName, e.after.bankNumber);
showToast(`Redo: Instrument`, 'info');
}
};
if (window.UndoRedoEngine) window.UndoRedoEngine.execute(entry);
setTrackInstrumentWithProgram(trackId, instrumentId, programNumber, displayName, bankNumber);
};
const createSectionWithUndo = (trackId, section) => {
const entry = {
type: 'CREATE_SECTION',
scope: 'track:' + trackId,
label: `Create Section`,
before: null,
after: section,
undo: (e) => {
updateActiveTracks(prev => prev.map(t => t.id === trackId ? { ...t, sections: (t.sections || []).filter(s => s.id !== e.after.id) } : t));
showToast(`Undo: Create Section`, 'info');
},
redo: (e) => {
updateActiveTracks(prev => prev.map(t => t.id === trackId ? { ...t, sections: [...(t.sections || []), e.after] } : t));
showToast(`Redo: Create Section`, 'info');
}
};
if (window.UndoRedoEngine) window.UndoRedoEngine.execute(entry);
};
const createMidiWithUndo = (trackId, midiItem) => {
const entry = {
type: 'CREATE_MIDI',
scope: 'track:' + trackId,
label: `Create MIDI Item`,
before: null,
after: midiItem,
undo: (e) => {
updateActiveTracks(prev => prev.map(t => t.id === trackId ? { ...t, midiItems: (t.midiItems || []).filter(m => m.id !== e.after.id) } : t));
showToast(`Undo: Create MIDI`, 'info');
},
redo: (e) => {
updateActiveTracks(prev => prev.map(t => t.id === trackId ? { ...t, midiItems: [...(t.midiItems || []), e.after] } : t));
showToast(`Redo: Create MIDI`, 'info');
}
};
if (window.UndoRedoEngine) window.UndoRedoEngine.execute(entry);
};
const createClipWithUndo = (trackId, clip) => {
const entry = {
type: 'CREATE_CLIP',
scope: 'track:' + trackId,
label: `Create Audio Clip`,
before: null,
after: clip,
undo: (e) => {
updateActiveTracks(prev => prev.map(t => t.id === trackId ? {
...t,
clips: (t.clips || []).filter(c => c.id !== e.after.id),
buffer: (t.clips || []).filter(c => c.id !== e.after.id)[0]?.buffer || null,
startTime: (t.clips || []).filter(c => c.id !== e.after.id)[0]?.startTime || 0,
name: (t.clips || []).filter(c => c.id !== e.after.id)[0]?.name || t.name
} : t));
showToast(`Undo: Create Clip`, 'info');
},
redo: (e) => {
updateActiveTracks(prev => prev.map(t => t.id === trackId ? { ...t, clips: [...(t.clips || []), e.after] } : t));
showToast(`Redo: Create Clip`, 'info');
}
};
if (window.UndoRedoEngine) window.UndoRedoEngine.execute(entry);
};
const deleteTrackWithUndo = (trackId, trackData) => {
const entry = {
type: 'DELETE_TRACK',
scope: 'global',
label: `Delete Track`,
before: trackData,
after: null,
undo: (e) => {
if (e.before) {
setTracks(prev => [...prev, e.before]);
showToast(`Undo: Delete Track`, 'info');
}
},
redo: (e) => {
setTracks(prev => prev.filter(t => t.id !== trackId));
showToast(`Redo: Delete Track`, 'info');
}
};
if (window.UndoRedoEngine) window.UndoRedoEngine.execute(entry);
};
// Tab System (LOOP_EDITOR_2.md §1)
const [activeTab, setActiveTab] = useState('main');
const [subTabSelectedNodeTime, setSubTabSelectedNodeTime] = useState(null);
@@ -8194,11 +8434,15 @@ const App = () => {
return;
}
if (ctrl && e.key === 'z' && !e.shiftKey) {
const tag = document.activeElement?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
e.preventDefault();
handleUndoRef.current();
return;
}
if (ctrl && (e.key === 'y' || e.key === 'z' && e.shiftKey)) {
const tag = document.activeElement?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
e.preventDefault();
handleRedoRef.current();
return;
@@ -8243,6 +8487,7 @@ const App = () => {
e.preventDefault();
const curTab = activeTabRef.current;
if (curTab !== 'main' && !curTab.startsWith('session_')) return;
captureSelectionUndo();
const allIds = new Set();
(activeTracksRef.current || []).forEach(t => {
(t.sections || []).forEach(s => allIds.add(s.id));
@@ -8257,6 +8502,7 @@ const App = () => {
clips.forEach(c => allIds.add(c.id === 'default' ? 'default_' + t.id : c.id));
});
setSelectedItemIds(allIds);
pushSelectionUndo();
return;
}
if (ctrl && !alt && e.key === 's') {
@@ -9815,6 +10061,8 @@ const App = () => {
const tid = selectedTrackId;
const sessionTab = sessionTabs.find(s => s.id === activeTab);
if (sessionTab) {
const trackData = activeTracks.find(t => t.id === tid);
if (trackData) deleteTrackWithUndo(tid, JSON.parse(JSON.stringify(trackData)));
updateActiveTracks(prev => prev.filter(t => t.id !== tid));
setSelectedTrackId(activeTracks.filter(t => t.id !== tid)[0]?.id || '1');
} else {
@@ -9824,6 +10072,7 @@ const App = () => {
showToast('Không thể xoá track chứa Section item.', 'warning');
return;
}
if (track) deleteTrackWithUndo(tid, JSON.parse(JSON.stringify(track)));
setTracks(p => p.filter(t => t.id !== tid));
setSelectedTrackId(curTracks.filter(t => t.id !== tid)[0]?.id || '1');
}
@@ -11396,21 +11645,7 @@ const App = () => {
// Playhead set with seek+play
const handlePlayheadSet = (time, shiftKey) => {
localSelectionAnchorRef.current = time;
if (isPlaying) {
// Click during playback: seek to position and continue playing
setCurrentTime(time);
stopAllPlayback();
setTimeout(() => {
startOffsetTimeRef.current = time;
startAudioTimeRef.current = getAudioContext().currentTime;
startTrackPlayback(time);
setIsPlaying(true);
}, 50);
} else {
// Normal click: just set playhead
setCurrentTime(time);
}
setPlayheadWithUndo(time);
};
const clearLocalSelection = () => {
setSelectionMode(null);
@@ -11432,6 +11667,7 @@ const App = () => {
rulerDragStartRef.current = time;
rulerAnchorRef.current = time;
isDraggingRulerRef.current = true;
captureSelectionUndo();
setSelectionMode('global');
setSelectionStart(time);
setSelectionEnd(time);
@@ -11446,13 +11682,13 @@ const App = () => {
const rawTime = Math.max(0, mouseX / zoom - leadInMargin);
const time = snapValue !== 'free' ? snapTime(rawTime, snapValue, bpm) : rawTime;
clearLocalSelection();
captureSelectionUndo();
setSelectionMode('global');
rulerDragStartRef.current = time;
isDraggingRulerRef.current = true;
if (e.shiftKey) {
e.preventDefault();
e.stopPropagation();
// Shift+click on ruler: lock existing anchor (or currentTime fallback) and extend global selection
const anchor = rulerAnchorRef.current !== null && rulerAnchorRef.current !== undefined ? rulerAnchorRef.current : selectionStart !== null && selectionStart !== undefined ? selectionStart : currentTime;
const selS = Math.max(0, Math.min(anchor, time));
const selE = Math.max(0, Math.max(anchor, time));
@@ -11483,6 +11719,7 @@ const App = () => {
if (isDraggingRulerRef.current) {
isDraggingRulerRef.current = false;
rulerDragStartRef.current = null;
pushSelectionUndo();
}
};
document.addEventListener('mousemove', handleMouseMove);
@@ -11519,6 +11756,7 @@ const App = () => {
const localSelectionAnchorRef = useRef(null);
const handleTrackLaneMouseDown = (trackId, time) => {
setSelectedTrackId(trackId);
captureSelectionUndo();
clearLocalSelection();
localSelectionAnchorRef.current = time;
setSelectionMode('local');
@@ -11555,6 +11793,7 @@ const App = () => {
localDragInProgressRef.current = false;
localDragTrackRef.current = null;
localDragStartTimeRef.current = 0;
pushSelectionUndo();
}
};
document.addEventListener('mousemove', handleMouseMove);
@@ -11574,6 +11813,27 @@ const App = () => {
draggedSectionItemRef.current = draggedSectionItem;
const resizedSectionItemRef = useRef(null);
resizedSectionItemRef.current = resizedSectionItem;
const selectionUndoRef = useRef(null);
const pushSelectionUndo = () => {
var cur = selectionRef.current;
var before = selectionUndoRef.current;
if (!before || (before.start === cur.start && before.end === cur.end && before.mode === cur.mode)) return;
var entry = {
type: 'SELECTION',
scope: 'global',
label: 'Selection',
before: before,
after: { start: cur.start, end: cur.end, mode: cur.mode },
undo: (e) => { setSelectionStart(e.before.start); setSelectionEnd(e.before.end); setSelectionMode(e.before.mode); showToast('Undo: Selection', 'info'); },
redo: (e) => { setSelectionStart(e.after.start); setSelectionEnd(e.after.end); setSelectionMode(e.after.mode); showToast('Redo: Selection', 'info'); }
};
if (window.UndoRedoEngine) window.UndoRedoEngine.execute(entry);
selectionUndoRef.current = null;
};
const captureSelectionUndo = () => {
if (selectionUndoRef.current !== null) return;
selectionUndoRef.current = { start: selectionRef.current.start, end: selectionRef.current.end, mode: selectionMode };
};
let clipSeqCounter = 0;
const nextClipId = () => `clip_${Date.now()}_${++clipSeqCounter}`;
@@ -11901,6 +12161,7 @@ const App = () => {
// Deselect single item
const handleDeselectItem = itemId => {
captureSelectionUndo();
setSelectedItemIds(prev => {
var next = new Set(prev);
next.delete(itemId);
@@ -11912,6 +12173,7 @@ const App = () => {
const handleSaveSectionTabRef = useRef(handleSaveSectionTab);
handleSaveSectionTabRef.current = handleSaveSectionTab;
const handleAddToSelection = itemId => {
captureSelectionUndo();
setSelectedItemIds(prev => {
var next = new Set(prev);
next.add(itemId);
@@ -12006,7 +12268,8 @@ const App = () => {
});
});
var newItemId = Object.keys(newOriginals)[0] || itemId;
setDraggedSectionItem({ trackId, itemType, itemId: newItemId, clickOffset, multiIds: newOriginals });
var origPos = newOriginals[newItemId] || { start: 0 };
setDraggedSectionItem({ trackId, itemType, itemId: newItemId, clickOffset, multiIds: newOriginals, originalPositions: { [newItemId]: origPos.start } });
} else {
var items = itemType === 'section' ? (track.sections || []) : (track.midiItems || []);
var item = items.find(function(it) { return it.id === itemId; });
@@ -12020,11 +12283,16 @@ const App = () => {
return itemType === 'section' ? { ...t, sections: updated } : { ...t, midiItems: updated };
});
});
setDraggedSectionItem({ trackId, itemType, itemId: rearrangeNewId, clickOffset, isDuplicate: false });
setDraggedSectionItem({ trackId, itemType, itemId: rearrangeNewId, clickOffset, isDuplicate: false, originalPositions: { [rearrangeNewId]: itemType === 'section' ? item.start : item.startTime } });
}
return;
}
setDraggedSectionItem({ trackId, itemType, itemId, clickOffset, multiIds });
var curTrk = activeTracksRef.current ? activeTracksRef.current.find(t => t.id === trackId) : null;
var its = itemType === 'section' ? (curTrk?.sections || []) : (curTrk?.midiItems || []);
var it = its.find(function(x) { return x.id === itemId; });
var origPos = it ? (itemType === 'section' ? it.start : it.startTime) : 0;
setDraggedSectionItem({ trackId, itemType, itemId, clickOffset, multiIds, originalPositions: { [itemId]: origPos } });
};
};
handleSectionItemDragStartRef.current = handleSectionItemDragStart;
@@ -12177,8 +12445,44 @@ const App = () => {
const handleMouseUp = () => {
const drag = draggedSectionItemRef.current;
if (!drag) return;
var changed = false;
var trackId = drag.trackId;
var beforeSnap = captureTrackSnapshot(trackId);
var origs = drag.originalPositions || {};
updateActiveTracks(function(prev) {
return prev.map(function(t) {
var allItemIds = Object.keys(origs);
var updatedSections = (t.sections || []).slice();
var updatedMidi = (t.midiItems || []).slice();
var hasChange = false;
for (var i = 0; i < allItemIds.length; i++) {
var oid = allItemIds[i];
var origStart = origs[oid];
if (t.id === trackId || drag.multiIds && Object.values(drag.multiIds).some(function(v) { return v.trackId === t.id && v.type === (oid.startsWith('sec_') ? 'section' : oid.startsWith('midi_') ? 'midiItem' : 'clip'); })) {
var sec = updatedSections.find(function(s) { return s.id === oid; });
var mid = updatedMidi.find(function(m) { return m.id === oid; });
var item = sec || mid;
if (item) {
var curStart = sec ? item.start : item.startTime;
if (Math.abs(curStart - origStart) > 0.001) {
hasChange = true;
changed = true;
if (sec) item.start = origStart;
if (mid) item.startTime = origStart;
}
}
}
}
if (!hasChange) return t;
return { ...t, sections: updatedSections, midiItems: updatedMidi };
});
});
if (changed) {
var afterSnap = captureTrackSnapshot(trackId);
pushAction('MOVE_ITEM', trackId, beforeSnap, afterSnap);
}
setDraggedSectionItem(null);
showToast(`Đã di chuyển ${drag.itemType === 'section' ? 'section' : 'MIDI item'}.`, 'success');
showToast('Đã di chuyển ' + (drag.itemType === 'section' ? 'section' : 'MIDI item') + '.', 'success');
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
@@ -12295,16 +12599,16 @@ const App = () => {
sweepTrackIdRef.current = null;
const sweep = sweepSelectRef.current;
sweepSelectRef.current = null;
captureSelectionUndo();
if (sweep) {
const start = Math.min(sweep.startTime, sweep.endTime);
const end = Math.max(sweep.startTime, sweep.endTime);
// Small movement (no real drag) deselect all
if (Math.abs(end - start) < 0.02) {
setSelectedItemIds(new Set());
setSweepSelect(null);
pushSelectionUndo();
return;
}
// Real drag toggle items that overlap the marquee
const curTracks = activeTracksRef.current || [];
const found = new Set();
curTracks.forEach(t => {
@@ -12326,7 +12630,6 @@ const App = () => {
}
});
});
// Toggle: add unselected, remove already-selected
setSelectedItemIds(prev => {
const next = new Set(prev);
found.forEach(id => {
@@ -12337,6 +12640,7 @@ const App = () => {
});
setSweepSelect(null);
sweepTrackIdRef.current = null;
pushSelectionUndo();
}
};
document.addEventListener('mousemove', handleMouseMove);
@@ -12348,6 +12652,7 @@ const App = () => {
}, [zoom]);
const handleSelectRange = (start, end, reset) => {
captureSelectionUndo();
const maxLen = maxDuration;
const cleanStart = Math.max(0, Math.min(maxLen, start));
const cleanEnd = Math.max(0, Math.min(maxLen, end));
@@ -12357,13 +12662,13 @@ const App = () => {
} else {
setSelectionEnd(cleanEnd);
}
// LOOP_EDITOR_2.md §4.2: new selection = enable looping
setSelectionCleared(false);
pushSelectionUndo();
};
const handleSelectionInputChange = (field, val) => {
captureSelectionUndo();
const numericVal = Math.max(0, parseFloat(val) || 0);
if (selectionMode === 'local') {
// Editing local selection directly
if (field === 'start') {
setLocalSelectionStart(numericVal);
} else {
@@ -12376,6 +12681,7 @@ const App = () => {
setSelectionEnd(numericVal);
}
}
pushSelectionUndo();
};
const selectionStats = useMemo(() => {
if (selLeft === null || selRight === null) {
@@ -12906,6 +13212,7 @@ const App = () => {
length_bars: 4,
color: track.color || '#06b6d4'
};
createSectionWithUndo(selectedTrackId, section);
updateActiveTracks(prev => prev.map(t => t.id === selectedTrackId ? {
...t,
sections: [...(t.sections || []), section]
@@ -12928,6 +13235,7 @@ const App = () => {
notes: [],
color: '#a78bfa'
};
createMidiWithUndo(selectedTrackId, midiItem);
updateActiveTracks(prev => prev.map(t => t.id === selectedTrackId ? {
...t,
midiItems: [...(t.midiItems || []), midiItem]
@@ -12955,6 +13263,7 @@ const App = () => {
const { audioBuffer: decodedBuffer, channelInfo } = await window.SonicAudio.decodeAudioFile(file);
const clipId = `clip_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
const newClip = { id: clipId, buffer: decodedBuffer, startTime: offset, name: file.name, speed: 1.0 };
createClipWithUndo(selectedTrackId, newClip);
updateActiveTracks(prev => prev.map(t => t.id === selectedTrackId ? {
...t,
clips: [...(t.clips || []), newClip]
@@ -15785,7 +16094,7 @@ const App = () => {
className: "w-[1px] h-5 bg-zinc-700 mx-0.5"
}), /*#__PURE__*/React.createElement("button", {
onClick: handleUndo,
disabled: undoStack.length === 0,
disabled: undoStack.length === 0 && (!window.UndoRedoEngine || !window.UndoRedoEngine.canUndo()),
className: "w-7 h-7 flex items-center justify-center rounded text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800 disabled:opacity-30",
title: "Undo (Ctrl+Z)"
}, /*#__PURE__*/React.createElement("span", {
@@ -15795,7 +16104,7 @@ const App = () => {
className: "w-3.5 h-3.5"
}))), /*#__PURE__*/React.createElement("button", {
onClick: handleRedo,
disabled: redoStack.length === 0,
disabled: redoStack.length === 0 && (!window.UndoRedoEngine || !window.UndoRedoEngine.canRedo()),
className: "w-7 h-7 flex items-center justify-center rounded text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800 disabled:opacity-30",
title: "Redo (Ctrl+Y)"
}, /*#__PURE__*/React.createElement("span", {
@@ -16357,6 +16666,7 @@ const App = () => {
value: aiPrompt,
onChange: e => {
const v = e.target.value;
aiPromptUndoPush(v);
setAiPrompt(v);
if (v.trim().length >= 2 && promptMgr) {
const matches = promptMgr.presets.filter(p =>
@@ -16373,6 +16683,28 @@ const App = () => {
className: "w-full bg-[#1e1e1e] text-zinc-200 p-1.5 rounded border border-zinc-700 focus:outline-none focus:border-purple-600 font-mono text-xs resize-y",
rows: 8,
onKeyDown: e => {
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
e.preventDefault();
e.stopPropagation();
const u = aiPromptUndoRef.current;
if (u.idx > 0) {
u.idx--;
setAiPrompt(u.stack[u.idx]);
showToast('Undo: AI Prompt', 'info');
}
return;
}
if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) {
e.preventDefault();
e.stopPropagation();
const u = aiPromptUndoRef.current;
if (u.idx < u.stack.length - 1) {
u.idx++;
setAiPrompt(u.stack[u.idx]);
showToast('Redo: AI Prompt', 'info');
}
return;
}
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleAISend();
@@ -16761,12 +17093,13 @@ const App = () => {
}, /*#__PURE__*/React.createElement("input", {
type: "number",
value: bpm,
onChange: e => setBpm(e.target.value),
onBlur: () => localStorage.setItem('studio_bpm', bpm),
onChange: e => { setBpm(e.target.value); },
onBlur: e => { const v = e.target.value; if (v && String(bpm) !== v) setBpmWithUndo(v); localStorage.setItem('studio_bpm', bpm); },
onKeyDown: e => { if (e.key === 'Enter') { e.target.blur(); } },
className: "w-12 bg-zinc-800 border border-zinc-700 rounded text-xs text-zinc-300 text-center font-mono focus:outline-none focus:border-purple-500",
min: "40",
max: "300"
}), /*#__PURE__*/React.createElement("span", {
}), /*#__PURE__*/React.createElement("span",
className: "text-xs text-zinc-500"
}, "BPM")))), /*#__PURE__*/React.createElement("div", {
className: "flex-1 flex flex-col divide-y divide-[#141414] bg-[#262626]"
@@ -17150,7 +17483,7 @@ const App = () => {
onSelectTrack: setSelectedTrackId,
markers: track.markers,
onTrackLaneMouseDown: handleTrackLaneMouseDown,
onClearSelection: () => setSelectedItemIds(new Set()),
onClearSelection: () => { captureSelectionUndo(); setSelectedItemIds(new Set()); },
onSweepSelectStart: handleSweepSelectStart,
onDeselectItem: handleDeselectItem,
onAddToSelection: handleAddToSelection,
@@ -17175,10 +17508,10 @@ const App = () => {
localSelectionStart: localSelectionStart,
currentTime: currentTime,
getLocalAnchor: () => localSelectionAnchorRef.current,
onClearLocalSelection: clearLocalSelection,
onSetSelectionMode: setSelectionMode,
onSetSelectionStart: setSelectionStart,
onSetSelectionEnd: setSelectionEnd,
onClearLocalSelection: () => { captureSelectionUndo(); clearLocalSelection(); },
onSetSelectionMode: mode => { captureSelectionUndo(); setSelectionMode(mode); },
onSetSelectionStart: val => { captureSelectionUndo(); setSelectionStart(val); },
onSetSelectionEnd: val => { captureSelectionUndo(); setSelectionEnd(val); },
onSetCurrentTime: setCurrentTime,
onSetLocalSelectionTrackId: setLocalSelectionTrackId,
onSetLocalSelectionStart: setLocalSelectionStart,
@@ -18358,7 +18691,7 @@ const App = () => {
/*#__PURE__*/React.createElement("div", { className: "flex gap-4", style: { height: "420px" } },
/*#__PURE__*/React.createElement("div", { className: "w-1/3 border-r border-[#383838] overflow-y-auto space-y-0.5 pr-2" },
/*#__PURE__*/React.createElement("button", {
onClick: () => { setSelectedSoundFontId(null); setSynthCategory(null); },
onClick: () => setSelectedSoundFontId(null); setSynthCategory(null); },
className: "w-full text-left px-3 py-2 text-sm rounded " + (!selectedSoundFontId ? "bg-amber-700 text-white" : "bg-zinc-800 hover:bg-zinc-700 text-zinc-300")
}, "All Instruments"),
(instrumentSelectorData?.soundfonts || []).map(sf => {
@@ -18368,14 +18701,12 @@ const App = () => {
key: sfId,
onClick: () => {
setSelectedSoundFontId(sfId);
// Fetch instruments on demand if not yet loaded
if (!sf.presets && window.SonicAPI) {
const baseId = sfId.replace('sf_', '');
window.SonicAPI.listSoundfontInstruments(baseId).then(data => {
if (data && data.presets) {
const mapping = data.presets.map(p => ({ ...p, _sfId: sfId, _sfName: sfName, _sfDisplay: sfName.substring(0, 30) }));
setSfPresets(prev => prev ? [...prev, ...mapping] : mapping);
// Also update instrumentSelectorData cache
setInstrumentSelectorData(prev => prev ? { ...prev, soundfonts: (prev.soundfonts || []).map(s => s.id === sf.id ? { ...s, presets: data.presets } : s) } : prev);
}
}).catch(() => {});
@@ -18387,7 +18718,7 @@ const App = () => {
),
/*#__PURE__*/React.createElement("div", { className: "flex-1 overflow-y-auto space-y-0.5" },
/*#__PURE__*/React.createElement("button", {
onClick: () => setTrackInstrumentWithProgram(instrumentSelectorTrackId, null),
onClick: () => setTrackInstrumentWithUndo(instrumentSelectorTrackId, null),
className: "w-full text-left px-3 py-2 text-sm rounded bg-zinc-800 hover:bg-zinc-700 text-zinc-400"
}, "None (Default Synth)"),
sfPresets === null ? (
@@ -18399,7 +18730,7 @@ const App = () => {
.filter(p => !sfPresetSearchQuery || (p.name || '').toLowerCase().includes(sfPresetSearchQuery.toLowerCase()) || (p._sfName || '').toLowerCase().includes(sfPresetSearchQuery.toLowerCase()))
.map((p, i) => /*#__PURE__*/React.createElement("button", {
key: i,
onClick: () => setTrackInstrumentWithProgram(instrumentSelectorTrackId, p._sfId, p.program, p.name || 'Preset ' + p.program, p.bank),
onClick: () => setTrackInstrumentWithUndo(instrumentSelectorTrackId, p._sfId, p.name || 'Preset ' + p.program, p.bank, p.program),
className: "w-full text-left px-3 py-2 text-sm rounded bg-zinc-800/60 hover:bg-amber-800 text-zinc-300 truncate flex items-center gap-2"
},
/*#__PURE__*/React.createElement("span", { className: "text-xs text-zinc-500 shrink-0" }, p._sfDisplay),
@@ -18412,6 +18743,7 @@ const App = () => {
)
)
)
)
)), fxSelectorTrackId && /*#__PURE__*/React.createElement("div", {
className: "fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm",
onClick: () => setFxSelectorTrackId(null)
@@ -18422,7 +18754,7 @@ const App = () => {
/*#__PURE__*/React.createElement("button", { onClick: () => handleSetTrackFx(fxSelectorTrackId, null), className: "w-full text-left px-3 py-2 text-xs rounded bg-zinc-800 hover:bg-zinc-700 text-zinc-400" }, "None"),
/*#__PURE__*/React.createElement("button", { onClick: () => handleSetTrackFx(fxSelectorTrackId, 'chorus'), className: "w-full text-left px-3 py-2 text-xs rounded bg-zinc-800 hover:bg-cyan-800 text-cyan-300" }, "Chorus"),
/*#__PURE__*/React.createElement("button", { onClick: () => handleSetTrackFx(fxSelectorTrackId, 'reverb'), className: "w-full text-left px-3 py-2 text-xs rounded bg-zinc-800 hover:bg-purple-800 text-purple-300" }, "Reverb")
))), instrumentDropdownTrackId && instrumentDropdownBtnRect && /*#__PURE__*/React.createElement("div", {
), instrumentDropdownTrackId && instrumentDropdownBtnRect && /*#__PURE__*/React.createElement("div", {
"data-instr-dropdown": "",
className: "fixed z-[100] bg-[#1e1e1e] border border-zinc-700 rounded shadow-xl w-[220px] max-h-[300px] flex flex-col",
style: {
@@ -18438,19 +18770,19 @@ const App = () => {
className: "w-full bg-black border-b border-zinc-700 px-2 py-1.5 text-xs outline-none"
}), /*#__PURE__*/React.createElement("div", { className: "flex-1 overflow-y-auto" },
/*#__PURE__*/React.createElement("button", {
onClick: () => setTrackInstrumentWithProgram(instrumentDropdownTrackId, null),
onClick: () => setTrackInstrumentWithUndo(instrumentDropdownTrackId, null),
className: "w-full text-left px-3 py-1.5 text-xs bg-zinc-800 hover:bg-zinc-700 text-zinc-400"
}, "None (Default Synth)"),
filteredInstruments.soundfonts.length > 0 && /*#__PURE__*/React.createElement("div", { className: "text-[10px] text-zinc-500 px-3 py-1 mt-1 uppercase font-bold" }, "SoundFonts"),
filteredInstruments.soundfonts.map((sf, i) => /*#__PURE__*/React.createElement("button", {
key: "sfd_" + i,
onClick: () => { setInstrumentDropdownTrackId(null); setInstrumentDropdownBtnRect(null); setTrackInstrument(instrumentDropdownTrackId, "sf_" + sf.id, sf.display || sf.name || sf.id); },
onClick: () => { setInstrumentDropdownTrackId(null); setInstrumentDropdownBtnRect(null); setTrackInstrumentWithUndo(instrumentDropdownTrackId, "sf_" + sf.id, sf.display || sf.name || sf.id, undefined, undefined); },
className: "w-full text-left px-3 py-1.5 text-xs bg-zinc-800 hover:bg-amber-800 text-zinc-200 flex items-center justify-between"
}, /*#__PURE__*/React.createElement("span", { className: "truncate" }, sf.display || sf.name || sf.id), /*#__PURE__*/React.createElement("span", { className: "text-[10px] text-amber-400 shrink-0 ml-1" }, "SF"))),
filteredInstruments.vst.length > 0 && /*#__PURE__*/React.createElement("div", { className: "text-[10px] text-zinc-500 px-3 py-1 mt-1 uppercase font-bold" }, "VST Instruments"),
filteredInstruments.vst.map((v, i) => /*#__PURE__*/React.createElement("button", {
key: "vstd_" + i,
onClick: () => { setInstrumentDropdownTrackId(null); setInstrumentDropdownBtnRect(null); setTrackInstrumentWithProgram(instrumentDropdownTrackId, v.id, undefined, v.name || v.id); },
onClick: () => { setInstrumentDropdownTrackId(null); setInstrumentDropdownBtnRect(null); setTrackInstrumentWithUndo(instrumentDropdownTrackId, v.id, v.name || v.id); },
className: "w-full text-left px-3 py-1.5 text-xs bg-zinc-800 hover:bg-violet-800 text-zinc-200 flex items-center justify-between"
}, /*#__PURE__*/React.createElement("span", { className: "truncate" }, v.name || v.id), /*#__PURE__*/React.createElement("span", { className: "text-[10px] text-cyan-400 shrink-0 ml-1" }, v.type || "VST"))),
(!filteredInstruments.soundfonts.length && !filteredInstruments.vst.length) && /*#__PURE__*/React.createElement("p", { className: "text-xs text-zinc-500 py-4 text-center" }, "Kh\u00f4ng t\u00ecm th\u1ea5y nh\u1ea1c c\u1ee5 n\u00e0o")
+74
View File
@@ -0,0 +1,74 @@
// SonicForge Studio - Unified Undo/Redo Engine
// Handles all undoable actions across MAIN SESSION and SECTION-TAB
const UndoRedoEngine = (function() {
const MAX_HISTORY = 50;
const history = [];
let historyIndex = -1;
function push(entry) {
history.push(entry);
if (history.length > MAX_HISTORY) {
history.shift();
}
historyIndex = history.length - 1;
}
function undo() {
if (historyIndex < 0) return null;
const entry = history[historyIndex];
historyIndex--;
return entry;
}
function redo() {
if (historyIndex >= history.length - 1) return null;
historyIndex++;
const entry = history[historyIndex];
return entry;
}
function canUndo() { return historyIndex >= 0; }
function canRedo() { return historyIndex < history.length - 1; }
function clear() {
history.length = 0;
historyIndex = -1;
}
function execute(entry) {
// entry: { type, scope, label, before, after, undo, redo }
// Trim future history if we're not at the end
if (historyIndex < history.length - 1) {
history.splice(historyIndex + 1);
}
push(entry);
return entry;
}
function getStatus() {
return {
canUndo: canUndo(),
canRedo: canRedo(),
undoCount: historyIndex + 1,
redoCount: history.length - historyIndex - 1,
lastAction: history[historyIndex]?.type || null,
lastLabel: history[historyIndex]?.label || null
};
}
return {
push,
undo,
redo,
canUndo,
canRedo,
clear,
execute,
getStatus,
history,
historyIndex
};
})();
window.UndoRedoEngine = UndoRedoEngine;
+5
View File
@@ -831,3 +831,8 @@
- **Các file ảnh hưởng:** `app/static/js/app.jsx`
- **Ghi chú/Test (nếu có):** `npm run build` — build passes.
---
### [2026-07-29 15:39] Task: Implement full Ctrl-Z/Ctrl-Y undo/redo for MAIN SESSION and SECTION-TAB
- **Tóm tắt thay đổi:** (1) Tạo `UndoRedoEngine` service (`app/static/js/services/undoRedoEngine.js`) — global stack hỗ trợ cross-cutting state changes (BPM, playhead, selection, AI prompt, instrument). (2) Extend `handleUndo`/`handleRedo` trong `app.jsx` để ưu tiên `UndoRedoEngine`, fallback về track-level undo stack cũ. (3) Áp dụng undo/redo cho: Delete/Create items (section, midi, clip, track), text AI Prompt (native textarea undo stack + engine integration), instrument assignment, playhead position, item drag position, selection changes, BPM/tempo changes. (4) Update undo/redo buttons disabled state + keyboard shortcut indicators. (5) Global Ctrl+Z/Y handler skip INPUT/TEXTAREA focus để text input native undo hoạt động đúng.
- **Các file ảnh hưởng:** `app/static/js/services/undoRedoEngine.js` (NEW), `app/static/js/app.jsx`
- **Ghi chú/Test (nếu có):** `npm run build` — build passes.