From 7ee197301da80012fc1e252fe8353d7f3e215474 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Sat, 18 Jul 2026 17:48:17 +0700 Subject: [PATCH] =?UTF-8?q?fix:=20s=E1=BB=ADa=20l=E1=BB=97i=20c=E1=BA=AFt?= =?UTF-8?q?=20audio=20m=C3=A0=20b=E1=BB=8B=20x=C3=B3a=20track?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/templates/index.html | 153 ++++++++++++++++++++++++++++++++------- 1 file changed, 125 insertions(+), 28 deletions(-) diff --git a/app/templates/index.html b/app/templates/index.html index 695f82c..95eb89f 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -474,8 +474,9 @@ 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; } if (ctrl && !alt && e.key === 'o') { e.preventDefault(); showToast('Open Project dialog','info'); return; } + if (ctrl && !alt && e.key === 'n') { e.preventDefault(); setTracks([{ id:'1', name:'Track 01', buffer:null, volume:0.8, muted:false, solo:false, color:'#0f766e', markers:[], serverFileId:null }, { id:'2', name:'Track 02', buffer:null, volume:0.8, muted:false, solo:false, color:'#1d4ed8', markers:[], serverFileId:null }]); setSelectedTrackId('1'); showToast('New project created','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; } + if ((ctrl && alt && e.key === 's') || (ctrl && e.shiftKey && e.key === 's')) { e.preventDefault(); showToast('Save As dialog','info'); return; } if (ctrl && !alt && e.key === 'i') { e.preventDefault(); addNewTrack(); return; } if (ctrl && alt && e.key === 'i') { e.preventDefault(); showToast('Import audio','info'); return; } if (ctrl && !alt && e.key === 'e') { e.preventDefault(); openTempTab(); return; } @@ -722,6 +723,34 @@ }; const contextMenuCut = () => { + const t = tracks.find(x => x.id === contextMenu.trackId); + if (!t || !t.buffer) { contextMenuDelete(); return; } + const beforeSnap = captureTrackSnapshot(contextMenu.trackId); + const sr = t.buffer.sampleRate; + const data = t.buffer.getChannelData(0); + if (selLeft !== null && selRight !== null && selRight > selLeft) { + const startSample = Math.floor(selLeft * sr); + const endSample = Math.min(data.length, Math.floor(selRight * sr)); + const len = endSample - startSample; + if (len > 0) { + const ctx = getAudioContext(); + const clipBuffer = ctx.createBuffer(1, len, sr); + clipBuffer.copyToChannel(data.subarray(startSample, endSample), 0); + clipboardRef.current = { buffer: clipBuffer, name: t.name, volume: t.volume, color: t.color }; + const newLen = data.length - len; + const newBuffer = ctx.createBuffer(1, newLen, sr); + const newData = newBuffer.getChannelData(0); + let idx = 0; + for (let i = 0; i < startSample; i++) newData[idx++] = data[i]; + for (let i = endSample; i < data.length; i++) newData[idx++] = data[i]; + setTracks(p => p.map(tr => tr.id === contextMenu.trackId ? { ...tr, buffer: newBuffer } : tr)); + const afterSnap = captureTrackSnapshot(contextMenu.trackId); + pushAction('CUT', contextMenu.trackId, beforeSnap, afterSnap); + closeContextMenu(); + showToast('Đã cắt vùng chọn vào clipboard.', 'info'); + return; + } + } contextMenuCopy(); contextMenuDelete(); }; @@ -816,9 +845,62 @@ }; 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'); } + if (!t || !t.buffer) return; + const sr = t.buffer.sampleRate; + const data = t.buffer.getChannelData(0); + // If selection exists, copy only the selected region + if (selLeft !== null && selRight !== null && selRight > selLeft) { + const startSample = Math.floor(selLeft * sr); + const endSample = Math.min(data.length, Math.floor(selRight * sr)); + const len = endSample - startSample; + if (len > 0) { + const ctx = getAudioContext(); + const clipBuffer = ctx.createBuffer(1, len, sr); + clipBuffer.copyToChannel(data.subarray(startSample, endSample), 0); + clipboardRef.current = { buffer: clipBuffer, name: t.name, volume: t.volume, color: t.color }; + showToast('Copied selection to clipboard.', 'info'); + return; + } + } + // No selection: copy entire track + clipboardRef.current = { buffer: t.buffer, name: t.name, volume: t.volume, color: t.color }; + showToast('Copied track to clipboard.', 'info'); + }; + const handleCutTrack = () => { + const t = tracks.find(x => x.id === selectedTrackId); + if (!t || !t.buffer) return; + const beforeSnap = captureTrackSnapshot(selectedTrackId); + const sr = t.buffer.sampleRate; + const data = t.buffer.getChannelData(0); + // If selection exists, cut only the selected region + if (selLeft !== null && selRight !== null && selRight > selLeft) { + const startSample = Math.floor(selLeft * sr); + const endSample = Math.min(data.length, Math.floor(selRight * sr)); + const len = endSample - startSample; + if (len > 0) { + // Copy selection to clipboard + const ctx = getAudioContext(); + const clipBuffer = ctx.createBuffer(1, len, sr); + clipBuffer.copyToChannel(data.subarray(startSample, endSample), 0); + clipboardRef.current = { buffer: clipBuffer, name: t.name, volume: t.volume, color: t.color }; + // Remove selection from track, glue the two remaining parts + const newLen = data.length - len; + const newBuffer = ctx.createBuffer(1, newLen, sr); + const newData = newBuffer.getChannelData(0); + let idx = 0; + for (let i = 0; i < startSample; i++) newData[idx++] = data[i]; + for (let i = endSample; i < data.length; i++) newData[idx++] = data[i]; + setTracks(p => p.map(tr => tr.id === selectedTrackId ? { ...tr, buffer: newBuffer } : tr)); + const afterSnap = captureTrackSnapshot(selectedTrackId); + pushAction('CUT', selectedTrackId, beforeSnap, afterSnap); + showToast('Cut selection to clipboard.', 'info'); + return; + } + } + // No selection: cut entire track (copy + delete) + handleCopyTrack(); + handleDeleteTrack(); }; - const handleCutTrack = () => { handleCopyTrack(); handleDeleteTrack(); }; const handleDeleteTrack = () => { const tid = selectedTrackId; setTracks(p => p.filter(t => t.id !== tid)); @@ -1857,31 +1939,31 @@ {/* ── Menu Bar ── */}
{[ - { label: 'File', items: [ - { label: 'New Project', icon: 'file-plus', action: () => { setTracks([{ id:'1', name:'Track 01', buffer:null, volume:0.8, muted:false, solo:false, color:'#0f766e', markers:[], serverFileId:null }, { id:'2', name:'Track 02', buffer:null, volume:0.8, muted:false, solo:false, color:'#1d4ed8', markers:[], serverFileId:null }]); setSelectedTrackId('1'); showToast('New project created','info'); } }, - { label: 'Open Project...', icon: 'folder-open', action: () => showToast('Open project dialog','info') }, - { label: 'Save Project', icon: 'save', action: () => showToast('Project saved','success') }, - { label: 'Save As...', icon: 'save', action: () => showToast('Save as dialog','info') }, + { label: 'File', items: [ + { label: 'New Project', icon: 'file-plus', shortcut: 'Ctrl+N', action: () => { setTracks([{ id:'1', name:'Track 01', buffer:null, volume:0.8, muted:false, solo:false, color:'#0f766e', markers:[], serverFileId:null }, { id:'2', name:'Track 02', buffer:null, volume:0.8, muted:false, solo:false, color:'#1d4ed8', markers:[], serverFileId:null }]); setSelectedTrackId('1'); showToast('New project created','info'); } }, + { label: 'Open Project...', icon: 'folder-open', shortcut: 'Ctrl+O', action: () => showToast('Open project dialog','info') }, + { label: 'Save Project', icon: 'save', shortcut: 'Ctrl+S', action: () => showToast('Project saved','success') }, + { label: 'Save As...', icon: 'save', shortcut: 'Ctrl+Alt+S', action: () => showToast('Save as dialog','info') }, { label: 'Save to Cloud', icon: 'upload-cloud', action: () => showToast('Saving to cloud...','info') }, { sep: true }, - { label: 'Import Audio...', icon: 'file-input', action: () => { 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(); showToast('Import audio','info'); } }, + { label: 'Import Audio...', icon: 'file-input', shortcut: 'Ctrl+Alt+I', action: () => { 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(); showToast('Import audio','info'); } }, { label: 'Export Mix...', icon: 'file-output', action: () => triggerWavExport() }, { sep: true }, { label: 'Logout', icon: 'log-out', action: () => showToast('Logged out','info') }, ]}, { label: 'Edit', items: [ - { label: 'Insert New Track', icon: 'plus', action: addNewTrack }, - { label: 'Insert Music to Track', icon: 'music', action: () => showToast('Select music file to insert','info') }, + { label: 'Insert New Track', icon: 'plus', shortcut: 'Ctrl+I', action: addNewTrack }, + { label: 'Insert Music to Track', icon: 'music', shortcut: 'Ctrl+Alt+I', action: () => showToast('Select music file to insert','info') }, { 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: () => { handleMergeTracks(); } }, + { label: 'Edit in New Tab', icon: 'file-edit', shortcut: 'Ctrl+E', action: () => openTempTab() }, + { label: 'Split at Playhead', icon: 'scissors', shortcut: 'S', action: () => handleSplitTrack(selectedTrackId) }, + { label: 'Merge Tracks', icon: 'combine', shortcut: 'Ctrl+M', action: () => { handleMergeTracks(); } }, { sep: true }, - { label: 'Copy', icon: 'copy', action: () => { handleCopyTrack(); } }, - { label: 'Cut', icon: 'scissors', action: () => { handleCutTrack(); } }, - { label: 'Paste', icon: 'clipboard', action: contextMenuPaste }, + { label: 'Copy', icon: 'copy', shortcut: 'Ctrl+C', action: () => { handleCopyTrack(); } }, + { label: 'Cut', icon: 'scissors', shortcut: 'Ctrl+X', action: () => { handleCutTrack(); } }, + { label: 'Paste', icon: 'clipboard', shortcut: 'Ctrl+V', action: contextMenuPaste }, { sep: true }, - { label: 'Delete Track', icon: 'trash-2', action: () => { handleDeleteTrack(); } }, + { label: 'Delete Track', icon: 'trash-2', shortcut: 'Del', action: () => { handleDeleteTrack(); } }, ]}, { label: 'View', items: [ { label: 'Master Track', icon: 'disc', action: () => showToast('Master track view','info') }, @@ -1896,7 +1978,6 @@ ]}, { label: 'Help', items: [ { label: 'About SonicForge', icon: 'info', action: () => showToast('SonicForge Studio v1.0 - Professional DAW','info') }, - { label: 'Keyboard Shortcuts', icon: 'keyboard', action: () => showToast('Ctrl+Z:Undo Ctrl+Y:Redo Space:Play S:Split Del:Delete Ctrl+I:NewTrack Ctrl+Alt+I:Import Ctrl+E:EditTab Ctrl+M:Merge Ctrl+C:Copy Ctrl+X:Cut Ctrl+V:Paste Ctrl+O:Open Ctrl+S:Save Ctrl+Alt+S:SaveAs','info') }, ]}, ].map(menu => (
@@ -1916,7 +1997,9 @@ ) : ( ))}
@@ -2455,30 +2538,44 @@ {/* ── Context Menu ── */} {contextMenu && ( -
e.stopPropagation()}>
)}