diff --git a/app/templates/index.html b/app/templates/index.html index 33b9621..6324b7b 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -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;imp) mp=a; } + if (mp > 1.0) for (let i=0;i [...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') },