feat: tạo menu hệ thống

This commit is contained in:
2026-07-18 16:38:43 +07:00
parent 6402033c93
commit cd644a3a1f
+84 -36
View File
@@ -460,35 +460,66 @@
const isDraggingRulerRef = useRef(false);
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 handleRedoRef = useRef(handleRedo);
const handlePlayPauseRef = useRef(null);
const keyHandlersRef = useRef({});
handleUndoRef.current = handleUndo;
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(() => {
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') {
e.preventDefault();
if (handlePlayPauseRef.current) handlePlayPauseRef.current();
return;
}
// Space key: toggle play/pause (LOOP_EDITOR_2.md §4.3)
if (e.key === ' ' || e.code === 'Space') {
e.preventDefault();
if (handlePlayPauseRef.current) handlePlayPauseRef.current();
return;
}
if (e.ctrlKey || e.metaKey) {
if (e.key === 'z' && !e.shiftKey) {
e.preventDefault();
handleUndoRef.current();
} else if (e.key === 'y' || (e.key === 'z' && e.shiftKey)) {
e.preventDefault();
handleRedoRef.current();
}
}
// Ctrl+Z: Undo, Ctrl+Y or Ctrl+Shift+Z: Redo
if (ctrl && e.key === 'z' && !e.shiftKey) { e.preventDefault(); handleUndoRef.current(); return; }
if (ctrl && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) { e.preventDefault(); handleRedoRef.current(); return; }
// File menu shortcuts
if (ctrl && !alt && e.key === 'o') { e.preventDefault(); showToast('Open Project dialog','info'); return; }
if (ctrl && !alt && e.key === 's') { e.preventDefault(); showToast('Project saved','success'); return; }
if (ctrl && alt && e.key === 's') { e.preventDefault(); showToast('Save As dialog','info'); return; }
// Edit menu shortcuts
if (ctrl && !alt && e.key === 'i') { e.preventDefault(); k.addNewTrack(); return; }
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);
return () => window.removeEventListener('keydown', handleKeyDown);
@@ -782,7 +813,6 @@
mergedData[i] += data[i] * t.volume;
}
});
// Normalize
let maxPeak = 0;
for (let i = 0; i < mergedData.length; i++) {
const abs = Math.abs(mergedData[i]);
@@ -794,25 +824,43 @@
const colors = ['#0f766e', '#1d4ed8', '#701a75', '#a21caf', '#b45309'];
const newId = 'track_merged_' + Date.now();
const names = activeTracks.map(t => t.name).join('+').slice(0, 30);
setTracks(prev => {
const keep = prev.filter(t => !t.buffer || t.muted || t.id === contextMenu.trackId);
return [...keep, {
id: newId,
name: `Merged_${names}.wav`,
buffer: merged,
volume: 0.8,
muted: false,
solo: false,
color: colors[prev.length % colors.length],
markers: [],
serverFileId: null,
}];
});
setTracks(prev => [...prev, {
id: newId, name: `Merged_${names}.wav`, buffer: merged,
volume: 0.8, muted: false, solo: false,
color: colors[prev.length % colors.length], markers: [], serverFileId: null,
}]);
setSelectedTrackId(newId);
closeContextMenu();
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 ──
useEffect(() => {
fetch(API_BASE_URL)
@@ -1862,13 +1910,13 @@
{ sep: true },
{ label: 'Edit in New Tab', icon: 'file-edit', action: () => openTempTab() },
{ 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 },
{ label: 'Copy', icon: 'copy', action: () => { setContextMenu({x:0,y:0,trackId:selectedTrackId}); contextMenuCopy(); } },
{ label: 'Cut', icon: 'scissors', action: () => { setContextMenu({x:0,y:0,trackId:selectedTrackId}); contextMenuCut(); } },
{ label: 'Copy', icon: 'copy', action: () => { handleCopyTrack(); } },
{ label: 'Cut', icon: 'scissors', action: () => { handleCutTrack(); } },
{ label: 'Paste', icon: 'clipboard', action: contextMenuPaste },
{ 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: 'Master Track', icon: 'disc', action: () => showToast('Master track view','info') },