feat: tạo menu hệ thống
This commit is contained in:
+84
-36
@@ -460,35 +460,66 @@
|
|||||||
const isDraggingRulerRef = useRef(false);
|
const isDraggingRulerRef = useRef(false);
|
||||||
const handlePlayPauseRef = useRef(null);
|
const handlePlayPauseRef = useRef(null);
|
||||||
|
|
||||||
// ── Keyboard Shortcuts (LOOP_EDITOR.md §4, LOOP_EDITOR_2.md §4.3) ──
|
// ── Keyboard Shortcuts (LOOP_EDITOR_2.md §4.3, menu shortcuts) ──
|
||||||
const handleUndoRef = useRef(handleUndo);
|
const handleUndoRef = useRef(handleUndo);
|
||||||
const handleRedoRef = useRef(handleRedo);
|
const handleRedoRef = useRef(handleRedo);
|
||||||
|
const handlePlayPauseRef = useRef(null);
|
||||||
|
const keyHandlersRef = useRef({});
|
||||||
handleUndoRef.current = handleUndo;
|
handleUndoRef.current = handleUndo;
|
||||||
handleRedoRef.current = handleRedo;
|
handleRedoRef.current = handleRedo;
|
||||||
|
|
||||||
|
// Keep key handlers up-to-date via refs (avoids stale closures in useEffect)
|
||||||
|
useEffect(() => {
|
||||||
|
keyHandlersRef.current = {
|
||||||
|
addNewTrack, handleSplitTrack, handleMergeTracks, handleCopyTrack,
|
||||||
|
handleCutTrack, handleDeleteTrack, contextMenuPaste, openTempTab,
|
||||||
|
openTempTabImport: () => {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'file'; input.accept = 'audio/*';
|
||||||
|
input.onchange = async (e) => {
|
||||||
|
if (e.target.files[0]) {
|
||||||
|
addNewTrack();
|
||||||
|
const newId = (tracks.length + 1).toString();
|
||||||
|
setTimeout(() => loadFileOnTrack(newId, e.target.files[0]), 100);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
input.click();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e) => {
|
const handleKeyDown = (e) => {
|
||||||
// Space key: toggle play/pause (LOOP_EDITOR_2.md §4.3)
|
const k = keyHandlersRef.current;
|
||||||
|
const ctrl = e.ctrlKey || e.metaKey;
|
||||||
|
const alt = e.altKey;
|
||||||
|
|
||||||
|
// Space: play/pause
|
||||||
if (e.key === ' ' || e.code === 'Space') {
|
if (e.key === ' ' || e.code === 'Space') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (handlePlayPauseRef.current) handlePlayPauseRef.current();
|
if (handlePlayPauseRef.current) handlePlayPauseRef.current();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Space key: toggle play/pause (LOOP_EDITOR_2.md §4.3)
|
|
||||||
if (e.key === ' ' || e.code === 'Space') {
|
// Ctrl+Z: Undo, Ctrl+Y or Ctrl+Shift+Z: Redo
|
||||||
e.preventDefault();
|
if (ctrl && e.key === 'z' && !e.shiftKey) { e.preventDefault(); handleUndoRef.current(); return; }
|
||||||
if (handlePlayPauseRef.current) handlePlayPauseRef.current();
|
if (ctrl && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) { e.preventDefault(); handleRedoRef.current(); return; }
|
||||||
return;
|
|
||||||
}
|
// File menu shortcuts
|
||||||
if (e.ctrlKey || e.metaKey) {
|
if (ctrl && !alt && e.key === 'o') { e.preventDefault(); showToast('Open Project dialog','info'); return; }
|
||||||
if (e.key === 'z' && !e.shiftKey) {
|
if (ctrl && !alt && e.key === 's') { e.preventDefault(); showToast('Project saved','success'); return; }
|
||||||
e.preventDefault();
|
if (ctrl && alt && e.key === 's') { e.preventDefault(); showToast('Save As dialog','info'); return; }
|
||||||
handleUndoRef.current();
|
|
||||||
} else if (e.key === 'y' || (e.key === 'z' && e.shiftKey)) {
|
// Edit menu shortcuts
|
||||||
e.preventDefault();
|
if (ctrl && !alt && e.key === 'i') { e.preventDefault(); k.addNewTrack(); return; }
|
||||||
handleRedoRef.current();
|
if (ctrl && alt && e.key === 'i') { e.preventDefault(); k.openTempTabImport(); return; }
|
||||||
}
|
if (ctrl && !alt && e.key === 'e') { e.preventDefault(); k.openTempTab(); return; }
|
||||||
}
|
if (!ctrl && !alt && (e.key === 's' || e.key === 'S')) { e.preventDefault(); k.handleSplitTrack(selectedTrackId); return; }
|
||||||
|
if (ctrl && !alt && e.key === 'm') { e.preventDefault(); k.handleMergeTracks(); return; }
|
||||||
|
if (ctrl && !alt && e.key === 'c') { e.preventDefault(); k.handleCopyTrack(); return; }
|
||||||
|
if (ctrl && !alt && e.key === 'x') { e.preventDefault(); k.handleCutTrack(); return; }
|
||||||
|
if (ctrl && !alt && e.key === 'v') { e.preventDefault(); k.contextMenuPaste(); return; }
|
||||||
|
if (e.key === 'Delete' || e.key === 'Del') { e.preventDefault(); k.handleDeleteTrack(); return; }
|
||||||
};
|
};
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
@@ -782,7 +813,6 @@
|
|||||||
mergedData[i] += data[i] * t.volume;
|
mergedData[i] += data[i] * t.volume;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
// Normalize
|
|
||||||
let maxPeak = 0;
|
let maxPeak = 0;
|
||||||
for (let i = 0; i < mergedData.length; i++) {
|
for (let i = 0; i < mergedData.length; i++) {
|
||||||
const abs = Math.abs(mergedData[i]);
|
const abs = Math.abs(mergedData[i]);
|
||||||
@@ -794,25 +824,43 @@
|
|||||||
const colors = ['#0f766e', '#1d4ed8', '#701a75', '#a21caf', '#b45309'];
|
const colors = ['#0f766e', '#1d4ed8', '#701a75', '#a21caf', '#b45309'];
|
||||||
const newId = 'track_merged_' + Date.now();
|
const newId = 'track_merged_' + Date.now();
|
||||||
const names = activeTracks.map(t => t.name).join('+').slice(0, 30);
|
const names = activeTracks.map(t => t.name).join('+').slice(0, 30);
|
||||||
setTracks(prev => {
|
setTracks(prev => [...prev, {
|
||||||
const keep = prev.filter(t => !t.buffer || t.muted || t.id === contextMenu.trackId);
|
id: newId, name: `Merged_${names}.wav`, buffer: merged,
|
||||||
return [...keep, {
|
volume: 0.8, muted: false, solo: false,
|
||||||
id: newId,
|
color: colors[prev.length % colors.length], markers: [], serverFileId: null,
|
||||||
name: `Merged_${names}.wav`,
|
}]);
|
||||||
buffer: merged,
|
|
||||||
volume: 0.8,
|
|
||||||
muted: false,
|
|
||||||
solo: false,
|
|
||||||
color: colors[prev.length % colors.length],
|
|
||||||
markers: [],
|
|
||||||
serverFileId: null,
|
|
||||||
}];
|
|
||||||
});
|
|
||||||
setSelectedTrackId(newId);
|
setSelectedTrackId(newId);
|
||||||
closeContextMenu();
|
closeContextMenu();
|
||||||
showToast(`Đã merge ${activeTracks.length} tracks.`, 'success');
|
showToast(`Đã merge ${activeTracks.length} tracks.`, 'success');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Menu bar direct handlers (don't rely on contextMenu state)
|
||||||
|
const handleMergeTracks = () => {
|
||||||
|
const at = tracks.filter(t => t.buffer && !t.muted);
|
||||||
|
if (at.length < 2) { showToast('Cần 2+ tracks để merge.','warning'); return; }
|
||||||
|
const actx = getAudioContext();
|
||||||
|
const maxDur = Math.max(...at.map(t => t.buffer.duration));
|
||||||
|
const sr = at[0].buffer.sampleRate;
|
||||||
|
const mb = actx.createBuffer(1, Math.ceil(maxDur * sr), sr);
|
||||||
|
const mdata = mb.getChannelData(0);
|
||||||
|
at.forEach(t => { const d = t.buffer.getChannelData(0); for (let i=0;i<d.length;i++) mdata[i] += d[i] * t.volume; });
|
||||||
|
let mp = 0; for (let i=0;i<mdata.length;i++) { const a=Math.abs(mdata[i]); if (a>mp) mp=a; }
|
||||||
|
if (mp > 1.0) for (let i=0;i<mdata.length;i++) mdata[i] /= mp;
|
||||||
|
setTracks(p => [...p, { id:'merged_'+Date.now(), name:'Merged_mix.wav', buffer:mb, volume:0.8, muted:false, solo:false, color:['#0f766e','#1d4ed8'][p.length%2], markers:[], serverFileId:null }]);
|
||||||
|
showToast('Merged all unmuted tracks.','success');
|
||||||
|
};
|
||||||
|
const handleCopyTrack = () => {
|
||||||
|
const t = tracks.find(x => x.id === selectedTrackId);
|
||||||
|
if (t) { clipboardRef.current = { buffer:t.buffer, name:t.name, volume:t.volume, color:t.color }; showToast('Copied track.','info'); }
|
||||||
|
};
|
||||||
|
const handleCutTrack = () => { handleCopyTrack(); handleDeleteTrack(); };
|
||||||
|
const handleDeleteTrack = () => {
|
||||||
|
const tid = selectedTrackId;
|
||||||
|
setTracks(p => p.filter(t => t.id !== tid));
|
||||||
|
setSelectedTrackId(tracks.filter(t => t.id !== tid)[0]?.id || '1');
|
||||||
|
showToast('Deleted track.','info');
|
||||||
|
};
|
||||||
|
|
||||||
// ── Server Health Check ──
|
// ── Server Health Check ──
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch(API_BASE_URL)
|
fetch(API_BASE_URL)
|
||||||
@@ -1862,13 +1910,13 @@
|
|||||||
{ sep: true },
|
{ sep: true },
|
||||||
{ label: 'Edit in New Tab', icon: 'file-edit', action: () => openTempTab() },
|
{ label: 'Edit in New Tab', icon: 'file-edit', action: () => openTempTab() },
|
||||||
{ label: 'Split at Playhead', icon: 'scissors', action: () => handleSplitTrack(selectedTrackId) },
|
{ label: 'Split at Playhead', icon: 'scissors', action: () => handleSplitTrack(selectedTrackId) },
|
||||||
{ label: 'Merge Tracks', icon: 'combine', action: () => { setContextMenu({x:0,y:0,trackId:selectedTrackId}); contextMenuMerge(); } },
|
{ label: 'Merge Tracks', icon: 'combine', action: () => { handleMergeTracks(); } },
|
||||||
{ sep: true },
|
{ sep: true },
|
||||||
{ label: 'Copy', icon: 'copy', action: () => { setContextMenu({x:0,y:0,trackId:selectedTrackId}); contextMenuCopy(); } },
|
{ label: 'Copy', icon: 'copy', action: () => { handleCopyTrack(); } },
|
||||||
{ label: 'Cut', icon: 'scissors', action: () => { setContextMenu({x:0,y:0,trackId:selectedTrackId}); contextMenuCut(); } },
|
{ label: 'Cut', icon: 'scissors', action: () => { handleCutTrack(); } },
|
||||||
{ label: 'Paste', icon: 'clipboard', action: contextMenuPaste },
|
{ label: 'Paste', icon: 'clipboard', action: contextMenuPaste },
|
||||||
{ sep: true },
|
{ sep: true },
|
||||||
{ label: 'Delete Track', icon: 'trash-2', action: () => { setContextMenu({x:0,y:0,trackId:selectedTrackId}); contextMenuDelete(); } },
|
{ label: 'Delete Track', icon: 'trash-2', action: () => { handleDeleteTrack(); } },
|
||||||
]},
|
]},
|
||||||
{ label: 'View', items: [
|
{ label: 'View', items: [
|
||||||
{ label: 'Master Track', icon: 'disc', action: () => showToast('Master track view','info') },
|
{ label: 'Master Track', icon: 'disc', action: () => showToast('Master track view','info') },
|
||||||
|
|||||||
Reference in New Issue
Block a user