From d4ad3bc1f71ac16bdb1382ddb4c0078a485e9fb2 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Wed, 29 Jul 2026 16:05:43 +0700 Subject: [PATCH] feat: add comprehensive Ctrl-Z/Ctrl-Y undo/redo engine for MAIN SESSION and SECTION-TAB --- app/static/js/app.jsx | 622 +++++++++++++++++------ app/static/js/services/undoRedoEngine.js | 74 +++ wiki.md | 5 + 3 files changed, 556 insertions(+), 145 deletions(-) create mode 100644 app/static/js/services/undoRedoEngine.js diff --git a/app/static/js/app.jsx b/app/static/js/app.jsx index 45a6b29..ed2fb0b 100644 --- a/app/static/js/app.jsx +++ b/app/static/js/app.jsx @@ -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); @@ -12005,26 +12267,32 @@ const App = () => { return { ...t, sections: updatedSections, midiItems: updatedMidi, clips: updatedClips }; }); }); - var newItemId = Object.keys(newOriginals)[0] || itemId; - setDraggedSectionItem({ trackId, itemType, itemId: newItemId, clickOffset, multiIds: newOriginals }); - } else { - var items = itemType === 'section' ? (track.sections || []) : (track.midiItems || []); - var item = items.find(function(it) { return it.id === itemId; }); - if (!item) return; - var rearrangeNewId = itemType + '_dup_' + Date.now(); - var newItem = { ...item, id: rearrangeNewId, name: item.name + ' (Copy)' }; - updateActiveTracks(function(prev) { - return prev.map(function(t) { - if (t.id !== trackId) return t; - var updated = itemType === 'section' ? [...(t.sections || []), newItem] : [...(t.midiItems || []), newItem]; - return itemType === 'section' ? { ...t, sections: updated } : { ...t, midiItems: updated }; - }); - }); - setDraggedSectionItem({ trackId, itemType, itemId: rearrangeNewId, clickOffset, isDuplicate: false }); - } - return; - } - setDraggedSectionItem({ trackId, itemType, itemId, clickOffset, multiIds }); + var newItemId = Object.keys(newOriginals)[0] || itemId; + 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; }); + if (!item) return; + var rearrangeNewId = itemType + '_dup_' + Date.now(); + var newItem = { ...item, id: rearrangeNewId, name: item.name + ' (Copy)' }; + updateActiveTracks(function(prev) { + return prev.map(function(t) { + if (t.id !== trackId) return t; + var updated = itemType === 'section' ? [...(t.sections || []), newItem] : [...(t.midiItems || []), newItem]; + return itemType === 'section' ? { ...t, sections: updated } : { ...t, midiItems: updated }; + }); + }); + setDraggedSectionItem({ trackId, itemType, itemId: rearrangeNewId, clickOffset, isDuplicate: false, originalPositions: { [rearrangeNewId]: itemType === 'section' ? item.start : item.startTime } }); + } + return; +} +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(); @@ -16758,15 +17090,16 @@ const App = () => { className: "text-xs font-semibold text-zinc-300" }, "Tempo")), /*#__PURE__*/React.createElement("div", { className: "flex items-center gap-1" - }, /*#__PURE__*/React.createElement("input", { - type: "number", - value: bpm, - onChange: e => setBpm(e.target.value), - onBlur: () => localStorage.setItem('studio_bpm', bpm), - 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("input", { + type: "number", + value: 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", className: "text-xs text-zinc-500" }, "BPM")))), /*#__PURE__*/React.createElement("div", { className: "flex-1 flex flex-col divide-y divide-[#141414] bg-[#262626]" @@ -17150,36 +17483,36 @@ const App = () => { onSelectTrack: setSelectedTrackId, markers: track.markers, onTrackLaneMouseDown: handleTrackLaneMouseDown, - onClearSelection: () => setSelectedItemIds(new Set()), + onClearSelection: () => { captureSelectionUndo(); setSelectedItemIds(new Set()); }, onSweepSelectStart: handleSweepSelectStart, onDeselectItem: handleDeselectItem, onAddToSelection: handleAddToSelection, onSetPendingDrag: handleSetPendingDrag, - onContextMenu: handleContextMenu, - onClipDragStart: handleClipDragStart, - onClipStretchStart: handleClipStretchStart, - onSectionItemDragStart: handleSectionItemDragStart, - onSectionItemResizeStart: handleSectionItemResizeStart, - onSelectionEdgeDragStart: handleSelectionEdgeDragStart, - setSelectedClipId: setSelectedClipId, - selectedClipId: selectedClipId, - activeTool: activeTool, - onSplitTrackAtTime: handleSplitTrackAtTime, - onEditClipInSubTab: handleEditClipInSubTab, - onEditSectionInTab: handleEditSectionInTab, - onEditMidiInTab: handleEditMidiInTab, - snapValue: snapValue, - bpm: bpm, - selectionMode: selectionMode, - localSelectionTrackId: localSelectionTrackId, - localSelectionStart: localSelectionStart, - currentTime: currentTime, - getLocalAnchor: () => localSelectionAnchorRef.current, - onClearLocalSelection: clearLocalSelection, - onSetSelectionMode: setSelectionMode, - onSetSelectionStart: setSelectionStart, - onSetSelectionEnd: setSelectionEnd, - onSetCurrentTime: setCurrentTime, + onContextMenu: handleContextMenu, + onClipDragStart: handleClipDragStart, + onClipStretchStart: handleClipStretchStart, + onSectionItemDragStart: handleSectionItemDragStart, + onSectionItemResizeStart: handleSectionItemResizeStart, + onSelectionEdgeDragStart: handleSelectionEdgeDragStart, + setSelectedClipId: setSelectedClipId, + selectedClipId: selectedClipId, + activeTool: activeTool, + onSplitTrackAtTime: handleSplitTrackAtTime, + onEditClipInSubTab: handleEditClipInSubTab, + onEditSectionInTab: handleEditSectionInTab, + onEditMidiInTab: handleEditMidiInTab, + snapValue: snapValue, + bpm: bpm, + selectionMode: selectionMode, + localSelectionTrackId: localSelectionTrackId, + localSelectionStart: localSelectionStart, + currentTime: currentTime, + getLocalAnchor: () => localSelectionAnchorRef.current, + onClearLocalSelection: () => { captureSelectionUndo(); clearLocalSelection(); }, + onSetSelectionMode: mode => { captureSelectionUndo(); setSelectionMode(mode); }, + onSetSelectionStart: val => { captureSelectionUndo(); setSelectionStart(val); }, + onSetSelectionEnd: val => { captureSelectionUndo(); setSelectionEnd(val); }, + onSetCurrentTime: setCurrentTime, onSetLocalSelectionTrackId: setLocalSelectionTrackId, onSetLocalSelectionStart: setLocalSelectionStart, onSetLocalSelectionEnd: setLocalSelectionEnd, @@ -18346,68 +18679,67 @@ const App = () => { }, activeTracks.map(function(at) { return /*#__PURE__*/React.createElement("option", { key: at.id, value: at.id }, at.name + ' (' + (at.instrumentName || 'Synth') + ')'); })), /*#__PURE__*/React.createElement("button", { onClick: closeInstrumentSelector, className: "text-slate-400 hover:text-slate-200" }, "\u2715") ) - ), - /*#__PURE__*/React.createElement("input", { - type: "text", - placeholder: "T\u00ecm nh\u1ea1c c\u1ee5...", - value: sfPresetSearchQuery, - onChange: e => setSfPresetSearchQuery(e.target.value), - autoFocus: true, - className: "w-full bg-black border border-zinc-700 rounded px-2 py-1.5 outline-none my-3 text-sm" - }), - /*#__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); }, - 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 => { - const sfId = sf.id.startsWith('sf_') ? sf.id : 'sf_' + sf.id; - const sfName = sf.display || sf.name || sf.id; - return /*#__PURE__*/React.createElement("button", { - 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(() => {}); - } - }, - className: "w-full text-left px-3 py-2 text-sm rounded " + (selectedSoundFontId === sfId ? "bg-amber-700 text-white" : "bg-zinc-800 hover:bg-zinc-700 text-zinc-300") - }, sfName); - }) ), - /*#__PURE__*/React.createElement("div", { className: "flex-1 overflow-y-auto space-y-0.5" }, - /*#__PURE__*/React.createElement("button", { - onClick: () => setTrackInstrumentWithProgram(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 ? ( - /*#__PURE__*/React.createElement("p", { className: "text-sm text-zinc-500 py-2" }, "Loading instruments...") - ) : ( - sfPresets.length > 0 ? ( - sfPresets - .filter(p => !selectedSoundFontId || p._sfId === selectedSoundFontId) - .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), - 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("input", { + type: "text", + placeholder: "T\u00ecm nh\u1ea1c c\u1ee5...", + value: sfPresetSearchQuery, + onChange: e => setSfPresetSearchQuery(e.target.value), + autoFocus: true, + className: "w-full bg-black border border-zinc-700 rounded px-2 py-1.5 outline-none my-3 text-sm" + }), + /*#__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); }, + 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 => { + const sfId = sf.id.startsWith('sf_') ? sf.id : 'sf_' + sf.id; + const sfName = sf.display || sf.name || sf.id; + return /*#__PURE__*/React.createElement("button", { + key: sfId, + onClick: () => { + setSelectedSoundFontId(sfId); + 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); + setInstrumentSelectorData(prev => prev ? { ...prev, soundfonts: (prev.soundfonts || []).map(s => s.id === sf.id ? { ...s, presets: data.presets } : s) } : prev); + } + }).catch(() => {}); + } }, - /*#__PURE__*/React.createElement("span", { className: "text-xs text-zinc-500 shrink-0" }, p._sfDisplay), - p.bank === 128 ? /*#__PURE__*/React.createElement("span", { className: "mr-1" }, "🥁") : null, - p.name || 'Preset ' + p.program - )) + className: "w-full text-left px-3 py-2 text-sm rounded " + (selectedSoundFontId === sfId ? "bg-amber-700 text-white" : "bg-zinc-800 hover:bg-zinc-700 text-zinc-300") + }, sfName); + }) + ), + /*#__PURE__*/React.createElement("div", { className: "flex-1 overflow-y-auto space-y-0.5" }, + /*#__PURE__*/React.createElement("button", { + 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 ? ( + /*#__PURE__*/React.createElement("p", { className: "text-sm text-zinc-500 py-2" }, "Loading instruments...") ) : ( - /*#__PURE__*/React.createElement("p", { className: "text-sm text-zinc-500 py-2" }, sfPresetSearchQuery ? "Kh\u00f4ng t\u00ecm th\u1ea5y nh\u1ea1c c\u1ee5 ph\u00f9 h\u1ee3p." : "No presets found.") + sfPresets.length > 0 ? ( + sfPresets + .filter(p => !selectedSoundFontId || p._sfId === selectedSoundFontId) + .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: () => 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), + p.bank === 128 ? /*#__PURE__*/React.createElement("span", { className: "mr-1" }, "🥁") : null, + p.name || 'Preset ' + p.program + )) + ) : ( + /*#__PURE__*/React.createElement("p", { className: "text-sm text-zinc-500 py-2" }, sfPresetSearchQuery ? "Kh\u00f4ng t\u00ecm th\u1ea5y nh\u1ea1c c\u1ee5 ph\u00f9 h\u1ee3p." : "No presets found.") + ) ) ) ) @@ -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") diff --git a/app/static/js/services/undoRedoEngine.js b/app/static/js/services/undoRedoEngine.js new file mode 100644 index 0000000..d6cdab9 --- /dev/null +++ b/app/static/js/services/undoRedoEngine.js @@ -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; diff --git a/wiki.md b/wiki.md index bef85c5..8c88951 100644 --- a/wiki.md +++ b/wiki.md @@ -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.