feat: add comprehensive Ctrl-Z/Ctrl-Y undo/redo engine for MAIN SESSION and SECTION-TAB
This commit is contained in:
+477
-145
@@ -6998,7 +6998,7 @@ const App = () => {
|
|||||||
.catch(e => { console.error('listSoundfontInstruments failed:', e); setSfPresets([]); });
|
.catch(e => { console.error('listSoundfontInstruments failed:', e); setSfPresets([]); });
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setTrackInstrumentWithProgram(trackId, instrumentId, undefined, displayName);
|
setTrackInstrumentWithUndo(trackId, instrumentId, displayName);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const [activeTool, setActiveTool] = useState('select'); // 'select' | 'grab' | 'razor'
|
const [activeTool, setActiveTool] = useState('select'); // 'select' | 'grab' | 'razor'
|
||||||
@@ -7279,6 +7279,13 @@ const App = () => {
|
|||||||
const [promptHistory, setPromptHistory] = useState([]);
|
const [promptHistory, setPromptHistory] = useState([]);
|
||||||
const [promptHistIdx, setPromptHistIdx] = useState(-1);
|
const [promptHistIdx, setPromptHistIdx] = useState(-1);
|
||||||
const promptHistRef = useRef([]);
|
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 [aiProvider, setAiProvider] = useState('OpenAI');
|
||||||
const [aiModel, setAiModel] = useState('GPT-4o');
|
const [aiModel, setAiModel] = useState('GPT-4o');
|
||||||
const [aiActionLog, setAiActionLog] = useState([]);
|
const [aiActionLog, setAiActionLog] = useState([]);
|
||||||
@@ -7318,7 +7325,7 @@ const App = () => {
|
|||||||
const [contextMenu, setContextMenu] = useState(null); // { x, y, trackId }
|
const [contextMenu, setContextMenu] = useState(null); // { x, y, trackId }
|
||||||
const clipboardRef = useRef(null); // { buffer, name, volume, color } for copy/paste
|
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 [undoStack, setUndoStack] = useState([]);
|
||||||
const [redoStack, setRedoStack] = useState([]);
|
const [redoStack, setRedoStack] = useState([]);
|
||||||
const MAX_UNDO = 30;
|
const MAX_UNDO = 30;
|
||||||
@@ -7338,6 +7345,14 @@ const App = () => {
|
|||||||
setRedoStack([]);
|
setRedoStack([]);
|
||||||
};
|
};
|
||||||
const handleUndo = () => {
|
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;
|
if (undoStack.length === 0) return;
|
||||||
const last = undoStack[undoStack.length - 1];
|
const last = undoStack[undoStack.length - 1];
|
||||||
setUndoStack(prev => prev.slice(0, -1));
|
setUndoStack(prev => prev.slice(0, -1));
|
||||||
@@ -7346,6 +7361,14 @@ const App = () => {
|
|||||||
showToast(`Undo: ${last.action_type}`, 'info');
|
showToast(`Undo: ${last.action_type}`, 'info');
|
||||||
};
|
};
|
||||||
const handleRedo = () => {
|
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;
|
if (redoStack.length === 0) return;
|
||||||
const last = redoStack[redoStack.length - 1];
|
const last = redoStack[redoStack.length - 1];
|
||||||
setRedoStack(prev => prev.slice(0, -1));
|
setRedoStack(prev => prev.slice(0, -1));
|
||||||
@@ -7381,7 +7404,6 @@ const App = () => {
|
|||||||
muted: track.muted,
|
muted: track.muted,
|
||||||
name: track.name,
|
name: track.name,
|
||||||
markers: JSON.parse(JSON.stringify(track.markers || [])),
|
markers: JSON.parse(JSON.stringify(track.markers || [])),
|
||||||
// buffer is captured via reference copy for undo; we store a clone for redo
|
|
||||||
buffer: track.buffer,
|
buffer: track.buffer,
|
||||||
startTime: track.startTime || 0,
|
startTime: track.startTime || 0,
|
||||||
clips: track.clips ? track.clips.map(c => ({
|
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) ──
|
// ── Tab System (LOOP_EDITOR_2.md §1) ──
|
||||||
const [activeTab, setActiveTab] = useState('main');
|
const [activeTab, setActiveTab] = useState('main');
|
||||||
const [subTabSelectedNodeTime, setSubTabSelectedNodeTime] = useState(null);
|
const [subTabSelectedNodeTime, setSubTabSelectedNodeTime] = useState(null);
|
||||||
@@ -8194,11 +8434,15 @@ const App = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (ctrl && e.key === 'z' && !e.shiftKey) {
|
if (ctrl && e.key === 'z' && !e.shiftKey) {
|
||||||
|
const tag = document.activeElement?.tagName;
|
||||||
|
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleUndoRef.current();
|
handleUndoRef.current();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (ctrl && (e.key === 'y' || e.key === 'z' && e.shiftKey)) {
|
if (ctrl && (e.key === 'y' || e.key === 'z' && e.shiftKey)) {
|
||||||
|
const tag = document.activeElement?.tagName;
|
||||||
|
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleRedoRef.current();
|
handleRedoRef.current();
|
||||||
return;
|
return;
|
||||||
@@ -8243,6 +8487,7 @@ const App = () => {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const curTab = activeTabRef.current;
|
const curTab = activeTabRef.current;
|
||||||
if (curTab !== 'main' && !curTab.startsWith('session_')) return;
|
if (curTab !== 'main' && !curTab.startsWith('session_')) return;
|
||||||
|
captureSelectionUndo();
|
||||||
const allIds = new Set();
|
const allIds = new Set();
|
||||||
(activeTracksRef.current || []).forEach(t => {
|
(activeTracksRef.current || []).forEach(t => {
|
||||||
(t.sections || []).forEach(s => allIds.add(s.id));
|
(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));
|
clips.forEach(c => allIds.add(c.id === 'default' ? 'default_' + t.id : c.id));
|
||||||
});
|
});
|
||||||
setSelectedItemIds(allIds);
|
setSelectedItemIds(allIds);
|
||||||
|
pushSelectionUndo();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (ctrl && !alt && e.key === 's') {
|
if (ctrl && !alt && e.key === 's') {
|
||||||
@@ -9815,6 +10061,8 @@ const App = () => {
|
|||||||
const tid = selectedTrackId;
|
const tid = selectedTrackId;
|
||||||
const sessionTab = sessionTabs.find(s => s.id === activeTab);
|
const sessionTab = sessionTabs.find(s => s.id === activeTab);
|
||||||
if (sessionTab) {
|
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));
|
updateActiveTracks(prev => prev.filter(t => t.id !== tid));
|
||||||
setSelectedTrackId(activeTracks.filter(t => t.id !== tid)[0]?.id || '1');
|
setSelectedTrackId(activeTracks.filter(t => t.id !== tid)[0]?.id || '1');
|
||||||
} else {
|
} else {
|
||||||
@@ -9824,6 +10072,7 @@ const App = () => {
|
|||||||
showToast('Không thể xoá track chứa Section item.', 'warning');
|
showToast('Không thể xoá track chứa Section item.', 'warning');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (track) deleteTrackWithUndo(tid, JSON.parse(JSON.stringify(track)));
|
||||||
setTracks(p => p.filter(t => t.id !== tid));
|
setTracks(p => p.filter(t => t.id !== tid));
|
||||||
setSelectedTrackId(curTracks.filter(t => t.id !== tid)[0]?.id || '1');
|
setSelectedTrackId(curTracks.filter(t => t.id !== tid)[0]?.id || '1');
|
||||||
}
|
}
|
||||||
@@ -11396,21 +11645,7 @@ const App = () => {
|
|||||||
|
|
||||||
// ── Playhead set with seek+play ──
|
// ── Playhead set with seek+play ──
|
||||||
const handlePlayheadSet = (time, shiftKey) => {
|
const handlePlayheadSet = (time, shiftKey) => {
|
||||||
localSelectionAnchorRef.current = time;
|
setPlayheadWithUndo(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);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
const clearLocalSelection = () => {
|
const clearLocalSelection = () => {
|
||||||
setSelectionMode(null);
|
setSelectionMode(null);
|
||||||
@@ -11432,6 +11667,7 @@ const App = () => {
|
|||||||
rulerDragStartRef.current = time;
|
rulerDragStartRef.current = time;
|
||||||
rulerAnchorRef.current = time;
|
rulerAnchorRef.current = time;
|
||||||
isDraggingRulerRef.current = true;
|
isDraggingRulerRef.current = true;
|
||||||
|
captureSelectionUndo();
|
||||||
setSelectionMode('global');
|
setSelectionMode('global');
|
||||||
setSelectionStart(time);
|
setSelectionStart(time);
|
||||||
setSelectionEnd(time);
|
setSelectionEnd(time);
|
||||||
@@ -11446,13 +11682,13 @@ const App = () => {
|
|||||||
const rawTime = Math.max(0, mouseX / zoom - leadInMargin);
|
const rawTime = Math.max(0, mouseX / zoom - leadInMargin);
|
||||||
const time = snapValue !== 'free' ? snapTime(rawTime, snapValue, bpm) : rawTime;
|
const time = snapValue !== 'free' ? snapTime(rawTime, snapValue, bpm) : rawTime;
|
||||||
clearLocalSelection();
|
clearLocalSelection();
|
||||||
|
captureSelectionUndo();
|
||||||
setSelectionMode('global');
|
setSelectionMode('global');
|
||||||
rulerDragStartRef.current = time;
|
rulerDragStartRef.current = time;
|
||||||
isDraggingRulerRef.current = true;
|
isDraggingRulerRef.current = true;
|
||||||
if (e.shiftKey) {
|
if (e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
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 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 selS = Math.max(0, Math.min(anchor, time));
|
||||||
const selE = Math.max(0, Math.max(anchor, time));
|
const selE = Math.max(0, Math.max(anchor, time));
|
||||||
@@ -11483,6 +11719,7 @@ const App = () => {
|
|||||||
if (isDraggingRulerRef.current) {
|
if (isDraggingRulerRef.current) {
|
||||||
isDraggingRulerRef.current = false;
|
isDraggingRulerRef.current = false;
|
||||||
rulerDragStartRef.current = null;
|
rulerDragStartRef.current = null;
|
||||||
|
pushSelectionUndo();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
document.addEventListener('mousemove', handleMouseMove);
|
document.addEventListener('mousemove', handleMouseMove);
|
||||||
@@ -11519,6 +11756,7 @@ const App = () => {
|
|||||||
const localSelectionAnchorRef = useRef(null);
|
const localSelectionAnchorRef = useRef(null);
|
||||||
const handleTrackLaneMouseDown = (trackId, time) => {
|
const handleTrackLaneMouseDown = (trackId, time) => {
|
||||||
setSelectedTrackId(trackId);
|
setSelectedTrackId(trackId);
|
||||||
|
captureSelectionUndo();
|
||||||
clearLocalSelection();
|
clearLocalSelection();
|
||||||
localSelectionAnchorRef.current = time;
|
localSelectionAnchorRef.current = time;
|
||||||
setSelectionMode('local');
|
setSelectionMode('local');
|
||||||
@@ -11555,6 +11793,7 @@ const App = () => {
|
|||||||
localDragInProgressRef.current = false;
|
localDragInProgressRef.current = false;
|
||||||
localDragTrackRef.current = null;
|
localDragTrackRef.current = null;
|
||||||
localDragStartTimeRef.current = 0;
|
localDragStartTimeRef.current = 0;
|
||||||
|
pushSelectionUndo();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
document.addEventListener('mousemove', handleMouseMove);
|
document.addEventListener('mousemove', handleMouseMove);
|
||||||
@@ -11574,6 +11813,27 @@ const App = () => {
|
|||||||
draggedSectionItemRef.current = draggedSectionItem;
|
draggedSectionItemRef.current = draggedSectionItem;
|
||||||
const resizedSectionItemRef = useRef(null);
|
const resizedSectionItemRef = useRef(null);
|
||||||
resizedSectionItemRef.current = resizedSectionItem;
|
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;
|
let clipSeqCounter = 0;
|
||||||
const nextClipId = () => `clip_${Date.now()}_${++clipSeqCounter}`;
|
const nextClipId = () => `clip_${Date.now()}_${++clipSeqCounter}`;
|
||||||
|
|
||||||
@@ -11901,6 +12161,7 @@ const App = () => {
|
|||||||
|
|
||||||
// ── Deselect single item ──
|
// ── Deselect single item ──
|
||||||
const handleDeselectItem = itemId => {
|
const handleDeselectItem = itemId => {
|
||||||
|
captureSelectionUndo();
|
||||||
setSelectedItemIds(prev => {
|
setSelectedItemIds(prev => {
|
||||||
var next = new Set(prev);
|
var next = new Set(prev);
|
||||||
next.delete(itemId);
|
next.delete(itemId);
|
||||||
@@ -11912,6 +12173,7 @@ const App = () => {
|
|||||||
const handleSaveSectionTabRef = useRef(handleSaveSectionTab);
|
const handleSaveSectionTabRef = useRef(handleSaveSectionTab);
|
||||||
handleSaveSectionTabRef.current = handleSaveSectionTab;
|
handleSaveSectionTabRef.current = handleSaveSectionTab;
|
||||||
const handleAddToSelection = itemId => {
|
const handleAddToSelection = itemId => {
|
||||||
|
captureSelectionUndo();
|
||||||
setSelectedItemIds(prev => {
|
setSelectedItemIds(prev => {
|
||||||
var next = new Set(prev);
|
var next = new Set(prev);
|
||||||
next.add(itemId);
|
next.add(itemId);
|
||||||
@@ -12005,26 +12267,32 @@ const App = () => {
|
|||||||
return { ...t, sections: updatedSections, midiItems: updatedMidi, clips: updatedClips };
|
return { ...t, sections: updatedSections, midiItems: updatedMidi, clips: updatedClips };
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
var newItemId = Object.keys(newOriginals)[0] || itemId;
|
var newItemId = Object.keys(newOriginals)[0] || itemId;
|
||||||
setDraggedSectionItem({ trackId, itemType, itemId: newItemId, clickOffset, multiIds: newOriginals });
|
var origPos = newOriginals[newItemId] || { start: 0 };
|
||||||
} else {
|
setDraggedSectionItem({ trackId, itemType, itemId: newItemId, clickOffset, multiIds: newOriginals, originalPositions: { [newItemId]: origPos.start } });
|
||||||
var items = itemType === 'section' ? (track.sections || []) : (track.midiItems || []);
|
} else {
|
||||||
var item = items.find(function(it) { return it.id === itemId; });
|
var items = itemType === 'section' ? (track.sections || []) : (track.midiItems || []);
|
||||||
if (!item) return;
|
var item = items.find(function(it) { return it.id === itemId; });
|
||||||
var rearrangeNewId = itemType + '_dup_' + Date.now();
|
if (!item) return;
|
||||||
var newItem = { ...item, id: rearrangeNewId, name: item.name + ' (Copy)' };
|
var rearrangeNewId = itemType + '_dup_' + Date.now();
|
||||||
updateActiveTracks(function(prev) {
|
var newItem = { ...item, id: rearrangeNewId, name: item.name + ' (Copy)' };
|
||||||
return prev.map(function(t) {
|
updateActiveTracks(function(prev) {
|
||||||
if (t.id !== trackId) return t;
|
return prev.map(function(t) {
|
||||||
var updated = itemType === 'section' ? [...(t.sections || []), newItem] : [...(t.midiItems || []), newItem];
|
if (t.id !== trackId) return t;
|
||||||
return itemType === 'section' ? { ...t, sections: updated } : { ...t, midiItems: updated };
|
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 });
|
});
|
||||||
}
|
setDraggedSectionItem({ trackId, itemType, itemId: rearrangeNewId, clickOffset, isDuplicate: false, originalPositions: { [rearrangeNewId]: itemType === 'section' ? item.start : item.startTime } });
|
||||||
return;
|
}
|
||||||
}
|
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;
|
handleSectionItemDragStartRef.current = handleSectionItemDragStart;
|
||||||
|
|
||||||
@@ -12177,8 +12445,44 @@ const App = () => {
|
|||||||
const handleMouseUp = () => {
|
const handleMouseUp = () => {
|
||||||
const drag = draggedSectionItemRef.current;
|
const drag = draggedSectionItemRef.current;
|
||||||
if (!drag) return;
|
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);
|
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('mousemove', handleMouseMove);
|
||||||
document.addEventListener('mouseup', handleMouseUp);
|
document.addEventListener('mouseup', handleMouseUp);
|
||||||
@@ -12295,16 +12599,16 @@ const App = () => {
|
|||||||
sweepTrackIdRef.current = null;
|
sweepTrackIdRef.current = null;
|
||||||
const sweep = sweepSelectRef.current;
|
const sweep = sweepSelectRef.current;
|
||||||
sweepSelectRef.current = null;
|
sweepSelectRef.current = null;
|
||||||
|
captureSelectionUndo();
|
||||||
if (sweep) {
|
if (sweep) {
|
||||||
const start = Math.min(sweep.startTime, sweep.endTime);
|
const start = Math.min(sweep.startTime, sweep.endTime);
|
||||||
const end = Math.max(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) {
|
if (Math.abs(end - start) < 0.02) {
|
||||||
setSelectedItemIds(new Set());
|
setSelectedItemIds(new Set());
|
||||||
setSweepSelect(null);
|
setSweepSelect(null);
|
||||||
|
pushSelectionUndo();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Real drag → toggle items that overlap the marquee
|
|
||||||
const curTracks = activeTracksRef.current || [];
|
const curTracks = activeTracksRef.current || [];
|
||||||
const found = new Set();
|
const found = new Set();
|
||||||
curTracks.forEach(t => {
|
curTracks.forEach(t => {
|
||||||
@@ -12326,7 +12630,6 @@ const App = () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
// Toggle: add unselected, remove already-selected
|
|
||||||
setSelectedItemIds(prev => {
|
setSelectedItemIds(prev => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
found.forEach(id => {
|
found.forEach(id => {
|
||||||
@@ -12337,6 +12640,7 @@ const App = () => {
|
|||||||
});
|
});
|
||||||
setSweepSelect(null);
|
setSweepSelect(null);
|
||||||
sweepTrackIdRef.current = null;
|
sweepTrackIdRef.current = null;
|
||||||
|
pushSelectionUndo();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
document.addEventListener('mousemove', handleMouseMove);
|
document.addEventListener('mousemove', handleMouseMove);
|
||||||
@@ -12348,6 +12652,7 @@ const App = () => {
|
|||||||
}, [zoom]);
|
}, [zoom]);
|
||||||
|
|
||||||
const handleSelectRange = (start, end, reset) => {
|
const handleSelectRange = (start, end, reset) => {
|
||||||
|
captureSelectionUndo();
|
||||||
const maxLen = maxDuration;
|
const maxLen = maxDuration;
|
||||||
const cleanStart = Math.max(0, Math.min(maxLen, start));
|
const cleanStart = Math.max(0, Math.min(maxLen, start));
|
||||||
const cleanEnd = Math.max(0, Math.min(maxLen, end));
|
const cleanEnd = Math.max(0, Math.min(maxLen, end));
|
||||||
@@ -12357,13 +12662,13 @@ const App = () => {
|
|||||||
} else {
|
} else {
|
||||||
setSelectionEnd(cleanEnd);
|
setSelectionEnd(cleanEnd);
|
||||||
}
|
}
|
||||||
// LOOP_EDITOR_2.md §4.2: new selection = enable looping
|
|
||||||
setSelectionCleared(false);
|
setSelectionCleared(false);
|
||||||
|
pushSelectionUndo();
|
||||||
};
|
};
|
||||||
const handleSelectionInputChange = (field, val) => {
|
const handleSelectionInputChange = (field, val) => {
|
||||||
|
captureSelectionUndo();
|
||||||
const numericVal = Math.max(0, parseFloat(val) || 0);
|
const numericVal = Math.max(0, parseFloat(val) || 0);
|
||||||
if (selectionMode === 'local') {
|
if (selectionMode === 'local') {
|
||||||
// Editing local selection directly
|
|
||||||
if (field === 'start') {
|
if (field === 'start') {
|
||||||
setLocalSelectionStart(numericVal);
|
setLocalSelectionStart(numericVal);
|
||||||
} else {
|
} else {
|
||||||
@@ -12376,6 +12681,7 @@ const App = () => {
|
|||||||
setSelectionEnd(numericVal);
|
setSelectionEnd(numericVal);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
pushSelectionUndo();
|
||||||
};
|
};
|
||||||
const selectionStats = useMemo(() => {
|
const selectionStats = useMemo(() => {
|
||||||
if (selLeft === null || selRight === null) {
|
if (selLeft === null || selRight === null) {
|
||||||
@@ -12906,6 +13212,7 @@ const App = () => {
|
|||||||
length_bars: 4,
|
length_bars: 4,
|
||||||
color: track.color || '#06b6d4'
|
color: track.color || '#06b6d4'
|
||||||
};
|
};
|
||||||
|
createSectionWithUndo(selectedTrackId, section);
|
||||||
updateActiveTracks(prev => prev.map(t => t.id === selectedTrackId ? {
|
updateActiveTracks(prev => prev.map(t => t.id === selectedTrackId ? {
|
||||||
...t,
|
...t,
|
||||||
sections: [...(t.sections || []), section]
|
sections: [...(t.sections || []), section]
|
||||||
@@ -12928,6 +13235,7 @@ const App = () => {
|
|||||||
notes: [],
|
notes: [],
|
||||||
color: '#a78bfa'
|
color: '#a78bfa'
|
||||||
};
|
};
|
||||||
|
createMidiWithUndo(selectedTrackId, midiItem);
|
||||||
updateActiveTracks(prev => prev.map(t => t.id === selectedTrackId ? {
|
updateActiveTracks(prev => prev.map(t => t.id === selectedTrackId ? {
|
||||||
...t,
|
...t,
|
||||||
midiItems: [...(t.midiItems || []), midiItem]
|
midiItems: [...(t.midiItems || []), midiItem]
|
||||||
@@ -12955,6 +13263,7 @@ const App = () => {
|
|||||||
const { audioBuffer: decodedBuffer, channelInfo } = await window.SonicAudio.decodeAudioFile(file);
|
const { audioBuffer: decodedBuffer, channelInfo } = await window.SonicAudio.decodeAudioFile(file);
|
||||||
const clipId = `clip_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
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 };
|
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 ? {
|
updateActiveTracks(prev => prev.map(t => t.id === selectedTrackId ? {
|
||||||
...t,
|
...t,
|
||||||
clips: [...(t.clips || []), newClip]
|
clips: [...(t.clips || []), newClip]
|
||||||
@@ -15785,7 +16094,7 @@ const App = () => {
|
|||||||
className: "w-[1px] h-5 bg-zinc-700 mx-0.5"
|
className: "w-[1px] h-5 bg-zinc-700 mx-0.5"
|
||||||
}), /*#__PURE__*/React.createElement("button", {
|
}), /*#__PURE__*/React.createElement("button", {
|
||||||
onClick: handleUndo,
|
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",
|
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)"
|
title: "Undo (Ctrl+Z)"
|
||||||
}, /*#__PURE__*/React.createElement("span", {
|
}, /*#__PURE__*/React.createElement("span", {
|
||||||
@@ -15795,7 +16104,7 @@ const App = () => {
|
|||||||
className: "w-3.5 h-3.5"
|
className: "w-3.5 h-3.5"
|
||||||
}))), /*#__PURE__*/React.createElement("button", {
|
}))), /*#__PURE__*/React.createElement("button", {
|
||||||
onClick: handleRedo,
|
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",
|
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)"
|
title: "Redo (Ctrl+Y)"
|
||||||
}, /*#__PURE__*/React.createElement("span", {
|
}, /*#__PURE__*/React.createElement("span", {
|
||||||
@@ -16357,6 +16666,7 @@ const App = () => {
|
|||||||
value: aiPrompt,
|
value: aiPrompt,
|
||||||
onChange: e => {
|
onChange: e => {
|
||||||
const v = e.target.value;
|
const v = e.target.value;
|
||||||
|
aiPromptUndoPush(v);
|
||||||
setAiPrompt(v);
|
setAiPrompt(v);
|
||||||
if (v.trim().length >= 2 && promptMgr) {
|
if (v.trim().length >= 2 && promptMgr) {
|
||||||
const matches = promptMgr.presets.filter(p =>
|
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",
|
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,
|
rows: 8,
|
||||||
onKeyDown: e => {
|
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) {
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleAISend();
|
handleAISend();
|
||||||
@@ -16758,15 +17090,16 @@ const App = () => {
|
|||||||
className: "text-xs font-semibold text-zinc-300"
|
className: "text-xs font-semibold text-zinc-300"
|
||||||
}, "Tempo")), /*#__PURE__*/React.createElement("div", {
|
}, "Tempo")), /*#__PURE__*/React.createElement("div", {
|
||||||
className: "flex items-center gap-1"
|
className: "flex items-center gap-1"
|
||||||
}, /*#__PURE__*/React.createElement("input", {
|
}, /*#__PURE__*/React.createElement("input", {
|
||||||
type: "number",
|
type: "number",
|
||||||
value: bpm,
|
value: bpm,
|
||||||
onChange: e => setBpm(e.target.value),
|
onChange: e => { setBpm(e.target.value); },
|
||||||
onBlur: () => localStorage.setItem('studio_bpm', bpm),
|
onBlur: e => { const v = e.target.value; if (v && String(bpm) !== v) setBpmWithUndo(v); 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",
|
onKeyDown: e => { if (e.key === 'Enter') { e.target.blur(); } },
|
||||||
min: "40",
|
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",
|
||||||
max: "300"
|
min: "40",
|
||||||
}), /*#__PURE__*/React.createElement("span", {
|
max: "300"
|
||||||
|
}), /*#__PURE__*/React.createElement("span",
|
||||||
className: "text-xs text-zinc-500"
|
className: "text-xs text-zinc-500"
|
||||||
}, "BPM")))), /*#__PURE__*/React.createElement("div", {
|
}, "BPM")))), /*#__PURE__*/React.createElement("div", {
|
||||||
className: "flex-1 flex flex-col divide-y divide-[#141414] bg-[#262626]"
|
className: "flex-1 flex flex-col divide-y divide-[#141414] bg-[#262626]"
|
||||||
@@ -17150,36 +17483,36 @@ const App = () => {
|
|||||||
onSelectTrack: setSelectedTrackId,
|
onSelectTrack: setSelectedTrackId,
|
||||||
markers: track.markers,
|
markers: track.markers,
|
||||||
onTrackLaneMouseDown: handleTrackLaneMouseDown,
|
onTrackLaneMouseDown: handleTrackLaneMouseDown,
|
||||||
onClearSelection: () => setSelectedItemIds(new Set()),
|
onClearSelection: () => { captureSelectionUndo(); setSelectedItemIds(new Set()); },
|
||||||
onSweepSelectStart: handleSweepSelectStart,
|
onSweepSelectStart: handleSweepSelectStart,
|
||||||
onDeselectItem: handleDeselectItem,
|
onDeselectItem: handleDeselectItem,
|
||||||
onAddToSelection: handleAddToSelection,
|
onAddToSelection: handleAddToSelection,
|
||||||
onSetPendingDrag: handleSetPendingDrag,
|
onSetPendingDrag: handleSetPendingDrag,
|
||||||
onContextMenu: handleContextMenu,
|
onContextMenu: handleContextMenu,
|
||||||
onClipDragStart: handleClipDragStart,
|
onClipDragStart: handleClipDragStart,
|
||||||
onClipStretchStart: handleClipStretchStart,
|
onClipStretchStart: handleClipStretchStart,
|
||||||
onSectionItemDragStart: handleSectionItemDragStart,
|
onSectionItemDragStart: handleSectionItemDragStart,
|
||||||
onSectionItemResizeStart: handleSectionItemResizeStart,
|
onSectionItemResizeStart: handleSectionItemResizeStart,
|
||||||
onSelectionEdgeDragStart: handleSelectionEdgeDragStart,
|
onSelectionEdgeDragStart: handleSelectionEdgeDragStart,
|
||||||
setSelectedClipId: setSelectedClipId,
|
setSelectedClipId: setSelectedClipId,
|
||||||
selectedClipId: selectedClipId,
|
selectedClipId: selectedClipId,
|
||||||
activeTool: activeTool,
|
activeTool: activeTool,
|
||||||
onSplitTrackAtTime: handleSplitTrackAtTime,
|
onSplitTrackAtTime: handleSplitTrackAtTime,
|
||||||
onEditClipInSubTab: handleEditClipInSubTab,
|
onEditClipInSubTab: handleEditClipInSubTab,
|
||||||
onEditSectionInTab: handleEditSectionInTab,
|
onEditSectionInTab: handleEditSectionInTab,
|
||||||
onEditMidiInTab: handleEditMidiInTab,
|
onEditMidiInTab: handleEditMidiInTab,
|
||||||
snapValue: snapValue,
|
snapValue: snapValue,
|
||||||
bpm: bpm,
|
bpm: bpm,
|
||||||
selectionMode: selectionMode,
|
selectionMode: selectionMode,
|
||||||
localSelectionTrackId: localSelectionTrackId,
|
localSelectionTrackId: localSelectionTrackId,
|
||||||
localSelectionStart: localSelectionStart,
|
localSelectionStart: localSelectionStart,
|
||||||
currentTime: currentTime,
|
currentTime: currentTime,
|
||||||
getLocalAnchor: () => localSelectionAnchorRef.current,
|
getLocalAnchor: () => localSelectionAnchorRef.current,
|
||||||
onClearLocalSelection: clearLocalSelection,
|
onClearLocalSelection: () => { captureSelectionUndo(); clearLocalSelection(); },
|
||||||
onSetSelectionMode: setSelectionMode,
|
onSetSelectionMode: mode => { captureSelectionUndo(); setSelectionMode(mode); },
|
||||||
onSetSelectionStart: setSelectionStart,
|
onSetSelectionStart: val => { captureSelectionUndo(); setSelectionStart(val); },
|
||||||
onSetSelectionEnd: setSelectionEnd,
|
onSetSelectionEnd: val => { captureSelectionUndo(); setSelectionEnd(val); },
|
||||||
onSetCurrentTime: setCurrentTime,
|
onSetCurrentTime: setCurrentTime,
|
||||||
onSetLocalSelectionTrackId: setLocalSelectionTrackId,
|
onSetLocalSelectionTrackId: setLocalSelectionTrackId,
|
||||||
onSetLocalSelectionStart: setLocalSelectionStart,
|
onSetLocalSelectionStart: setLocalSelectionStart,
|
||||||
onSetLocalSelectionEnd: setLocalSelectionEnd,
|
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') + ')'); })),
|
}, 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("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("input", {
|
||||||
/*#__PURE__*/React.createElement("button", {
|
type: "text",
|
||||||
onClick: () => setTrackInstrumentWithProgram(instrumentSelectorTrackId, null),
|
placeholder: "T\u00ecm nh\u1ea1c c\u1ee5...",
|
||||||
className: "w-full text-left px-3 py-2 text-sm rounded bg-zinc-800 hover:bg-zinc-700 text-zinc-400"
|
value: sfPresetSearchQuery,
|
||||||
}, "None (Default Synth)"),
|
onChange: e => setSfPresetSearchQuery(e.target.value),
|
||||||
sfPresets === null ? (
|
autoFocus: true,
|
||||||
/*#__PURE__*/React.createElement("p", { className: "text-sm text-zinc-500 py-2" }, "Loading instruments...")
|
className: "w-full bg-black border border-zinc-700 rounded px-2 py-1.5 outline-none my-3 text-sm"
|
||||||
) : (
|
}),
|
||||||
sfPresets.length > 0 ? (
|
/*#__PURE__*/React.createElement("div", { className: "flex gap-4", style: { height: "420px" } },
|
||||||
sfPresets
|
/*#__PURE__*/React.createElement("div", { className: "w-1/3 border-r border-[#383838] overflow-y-auto space-y-0.5 pr-2" },
|
||||||
.filter(p => !selectedSoundFontId || p._sfId === selectedSoundFontId)
|
/*#__PURE__*/React.createElement("button", {
|
||||||
.filter(p => !sfPresetSearchQuery || (p.name || '').toLowerCase().includes(sfPresetSearchQuery.toLowerCase()) || (p._sfName || '').toLowerCase().includes(sfPresetSearchQuery.toLowerCase()))
|
onClick: () => setSelectedSoundFontId(null); setSynthCategory(null); },
|
||||||
.map((p, i) => /*#__PURE__*/React.createElement("button", {
|
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")
|
||||||
key: i,
|
}, "All Instruments"),
|
||||||
onClick: () => setTrackInstrumentWithProgram(instrumentSelectorTrackId, p._sfId, p.program, p.name || 'Preset ' + p.program, p.bank),
|
(instrumentSelectorData?.soundfonts || []).map(sf => {
|
||||||
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"
|
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),
|
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")
|
||||||
p.bank === 128 ? /*#__PURE__*/React.createElement("span", { className: "mr-1" }, "🥁") : null,
|
}, sfName);
|
||||||
p.name || 'Preset ' + p.program
|
})
|
||||||
))
|
),
|
||||||
|
/*#__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, 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, '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")
|
/*#__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": "",
|
"data-instr-dropdown": "",
|
||||||
className: "fixed z-[100] bg-[#1e1e1e] border border-zinc-700 rounded shadow-xl w-[220px] max-h-[300px] flex flex-col",
|
className: "fixed z-[100] bg-[#1e1e1e] border border-zinc-700 rounded shadow-xl w-[220px] max-h-[300px] flex flex-col",
|
||||||
style: {
|
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"
|
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("div", { className: "flex-1 overflow-y-auto" },
|
||||||
/*#__PURE__*/React.createElement("button", {
|
/*#__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"
|
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)"),
|
}, "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.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", {
|
filteredInstruments.soundfonts.map((sf, i) => /*#__PURE__*/React.createElement("button", {
|
||||||
key: "sfd_" + i,
|
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"
|
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"))),
|
}, /*#__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.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", {
|
filteredInstruments.vst.map((v, i) => /*#__PURE__*/React.createElement("button", {
|
||||||
key: "vstd_" + i,
|
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"
|
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"))),
|
}, /*#__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")
|
(!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`
|
- **Các file ảnh hưởng:** `app/static/js/app.jsx`
|
||||||
- **Ghi chú/Test (nếu có):** `npm run build` — build passes.
|
- **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