feat: add comprehensive Ctrl-Z/Ctrl-Y undo/redo engine for MAIN SESSION and SECTION-TAB
This commit is contained in:
+380
-48
@@ -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")
|
||||
|
||||
@@ -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;
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user