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') },