fix: sửa lỗi cắt audio mà bị xóa track
This commit is contained in:
+124
-27
@@ -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));
|
||||
@@ -1858,30 +1940,30 @@
|
||||
<header className="h-9 bg-[#2e2e2e] border-b border-[#181818] flex items-center px-1 shrink-0 select-none">
|
||||
{[
|
||||
{ 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: '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 => (
|
||||
<div key={menu.label} className="relative">
|
||||
@@ -1916,7 +1997,9 @@
|
||||
) : (
|
||||
<button key={item.label} onClick={(e) => { e.stopPropagation(); item.action(); setMenuOpen(null); }}
|
||||
className="w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2">
|
||||
<i data-lucide={item.icon} className="w-3.5 h-3.5 text-zinc-500"></i> {item.label}
|
||||
<i data-lucide={item.icon} className="w-3.5 h-3.5 text-zinc-500 shrink-0"></i>
|
||||
<span className="flex-1">{item.label}</span>
|
||||
{item.shortcut && <span className="text-zinc-600 text-[9px] font-mono ml-2">{item.shortcut}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -2455,30 +2538,44 @@
|
||||
|
||||
{/* ── Context Menu ── */}
|
||||
{contextMenu && (
|
||||
<div className="fixed z-[60] bg-[#2a2a2a] border border-zinc-700 rounded-lg shadow-2xl py-1 w-44" style={{ left: contextMenu.x, top: contextMenu.y }}
|
||||
<div className="fixed z-[60] bg-[#2a2a2a] border border-zinc-700 rounded-lg shadow-2xl py-1 w-48" style={{ left: contextMenu.x, top: contextMenu.y }}
|
||||
onClick={(e) => e.stopPropagation()}>
|
||||
<button onClick={contextMenuEdit} className="w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2">
|
||||
<i data-lucide="file-edit" className="w-3.5 h-3.5 text-amber-400"></i> Edit
|
||||
<i data-lucide="file-edit" className="w-3.5 h-3.5 text-amber-400 shrink-0"></i>
|
||||
<span className="flex-1">Edit</span>
|
||||
<span className="text-zinc-600 text-[9px] font-mono">Ctrl+E</span>
|
||||
</button>
|
||||
<button onClick={contextMenuSplit} className="w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2">
|
||||
<i data-lucide="scissors" className="w-3.5 h-3.5 text-cyan-400"></i> Split
|
||||
<i data-lucide="scissors" className="w-3.5 h-3.5 text-cyan-400 shrink-0"></i>
|
||||
<span className="flex-1">Split</span>
|
||||
<span className="text-zinc-600 text-[9px] font-mono">S</span>
|
||||
</button>
|
||||
<button onClick={contextMenuMerge} className="w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2">
|
||||
<i data-lucide="combine" className="w-3.5 h-3.5 text-purple-400"></i> Merge
|
||||
<i data-lucide="combine" className="w-3.5 h-3.5 text-purple-400 shrink-0"></i>
|
||||
<span className="flex-1">Merge</span>
|
||||
<span className="text-zinc-600 text-[9px] font-mono">Ctrl+M</span>
|
||||
</button>
|
||||
<div className="h-px bg-zinc-700 my-1"></div>
|
||||
<button onClick={contextMenuCopy} className="w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2">
|
||||
<i data-lucide="copy" className="w-3.5 h-3.5 text-zinc-400"></i> Copy
|
||||
<i data-lucide="copy" className="w-3.5 h-3.5 text-zinc-400 shrink-0"></i>
|
||||
<span className="flex-1">Copy</span>
|
||||
<span className="text-zinc-600 text-[9px] font-mono">Ctrl+C</span>
|
||||
</button>
|
||||
<button onClick={contextMenuCut} className="w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2">
|
||||
<i data-lucide="scissors" className="w-3.5 h-3.5 text-rose-400"></i> Cut
|
||||
<i data-lucide="scissors" className="w-3.5 h-3.5 text-rose-400 shrink-0"></i>
|
||||
<span className="flex-1">Cut</span>
|
||||
<span className="text-zinc-600 text-[9px] font-mono">Ctrl+X</span>
|
||||
</button>
|
||||
<button onClick={contextMenuPaste} className="w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2">
|
||||
<i data-lucide="clipboard" className="w-3.5 h-3.5 text-emerald-400"></i> Paste
|
||||
<i data-lucide="clipboard" className="w-3.5 h-3.5 text-emerald-400 shrink-0"></i>
|
||||
<span className="flex-1">Paste</span>
|
||||
<span className="text-zinc-600 text-[9px] font-mono">Ctrl+V</span>
|
||||
</button>
|
||||
<div className="h-px bg-zinc-700 my-1"></div>
|
||||
<button onClick={contextMenuDelete} className="w-full px-3 py-1.5 text-xs text-red-300 hover:bg-red-950 text-left flex items-center gap-2">
|
||||
<i data-lucide="trash-2" className="w-3.5 h-3.5 text-red-400"></i> Delete
|
||||
<i data-lucide="trash-2" className="w-3.5 h-3.5 text-red-400 shrink-0"></i>
|
||||
<span className="flex-1">Delete</span>
|
||||
<span className="text-zinc-600 text-[9px] font-mono">Del</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user