feat: tạo menu hệ thống
This commit is contained in:
+84
-36
@@ -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') },
|
||||
|
||||
Reference in New Issue
Block a user