diff --git a/app/templates/index.html b/app/templates/index.html index 95eb89f..d310ba6 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -277,7 +277,12 @@ e.preventDefault(); e.stopPropagation(); onSelectTrack(track.id); - if (onContextMenu) onContextMenu(e, track.id); + const rect = canvasRef.current.getBoundingClientRect(); + const wrapper = canvasRef.current?.parentElement?.parentElement; + const scrollLeft = wrapper ? wrapper.scrollLeft : 0; + const x = e.clientX - rect.left + scrollLeft; + const time = Math.max(0, x / zoom); + if (onContextMenu) onContextMenu(e, track.id, time); }} /> ); @@ -483,7 +488,7 @@ if (ctrl && !alt && e.key === 'm') { e.preventDefault(); handleMergeTracks(); return; } if (ctrl && !alt && e.key === 'c') { e.preventDefault(); handleCopyTrack(); return; } if (ctrl && !alt && e.key === 'x') { e.preventDefault(); handleCutTrack(); return; } - if (ctrl && !alt && e.key === 'v') { e.preventDefault(); contextMenuPaste(); return; } + if (ctrl && !alt && e.key === 'v') { e.preventDefault(); handlePasteTrack(); return; } if (e.key === 'Delete' || e.key === 'Del') { e.preventDefault(); handleDeleteTrack(); return; } if (!ctrl && !alt && e.key === 's') { e.preventDefault(); handleSplitTrack(selectedTrackId); return; } }; @@ -667,10 +672,10 @@ }; // ── Context Menu Handlers ── - const handleContextMenu = (e, trackId) => { + const handleContextMenu = (e, trackId, clickTime) => { e.preventDefault(); e.stopPropagation(); - setContextMenu({ x: e.clientX, y: e.clientY, trackId }); + setContextMenu({ x: e.clientX, y: e.clientY, trackId, time: clickTime || currentTime }); }; const closeContextMenu = () => setContextMenu(null); @@ -755,38 +760,87 @@ contextMenuDelete(); }; - const contextMenuPaste = () => { - if (!clipboardRef.current) { + const doPaste = (targetTrackId, pasteTime) => { + if (!clipboardRef.current || !clipboardRef.current.buffer) { showToast('Clipboard trống.', 'warning'); - closeContextMenu(); - return; + return null; } - const { buffer, name, volume, color } = clipboardRef.current; - if (!buffer) { - showToast('Clipboard không có dữ liệu âm thanh.', 'warning'); - closeContextMenu(); - return; - } - // Create a new buffer copy + const { buffer: clipBuffer, name, volume, color } = clipboardRef.current; const ctx = getAudioContext(); - const newBuffer = ctx.createBuffer(1, buffer.length, buffer.sampleRate); - newBuffer.copyToChannel(buffer.getChannelData(0), 0); + + const targetTrack = tracks.find(t => t.id === targetTrackId); + if (targetTrack && targetTrack.buffer) { + const targetSr = targetTrack.buffer.sampleRate; + const clipSr = clipBuffer.sampleRate; + // Resample clipboard to target sample rate if needed + let pasteData; + let pasteSamples; + if (clipSr === targetSr) { + const newClipBuffer = ctx.createBuffer(1, clipBuffer.length, clipSr); + newClipBuffer.copyToChannel(clipBuffer.getChannelData(0), 0); + pasteData = newClipBuffer.getChannelData(0); + pasteSamples = clipBuffer.length; + } else { + pasteSamples = Math.round(clipBuffer.length * targetSr / clipSr); + const resampled = ctx.createBuffer(1, pasteSamples, targetSr); + const out = resampled.getChannelData(0); + const src = clipBuffer.getChannelData(0); + const srcLen = clipBuffer.length; + for (let i = 0; i < pasteSamples; i++) { + const pos = (i / pasteSamples) * srcLen; + const idx = Math.floor(pos); + const frac = pos - idx; + const s0 = src[Math.min(idx, srcLen - 1)]; + const s1 = src[Math.min(idx + 1, srcLen - 1)]; + out[i] = s0 + (s1 - s0) * frac; + } + pasteData = out; + } + + const insertSample = Math.floor(pasteTime * targetSr); + const origLen = targetTrack.buffer.length; + const newLen = Math.max(origLen, insertSample + pasteSamples); + const combined = ctx.createBuffer(1, newLen, targetSr); + const combinedData = combined.getChannelData(0); + const origData = targetTrack.buffer.getChannelData(0); + for (let i = 0; i < origLen; i++) combinedData[i] = origData[i]; + for (let i = 0; i < pasteSamples; i++) combinedData[insertSample + i] = pasteData[i]; + setTracks(p => p.map(t => t.id === targetTrackId ? { ...t, buffer: combined } : t)); + setCurrentTime(pasteTime); + showToast('Đã dán vào track.', 'success'); + return targetTrackId; + } + if (targetTrack) { + // Track exists but no buffer — use clipboard sample rate + const newClipBuffer = ctx.createBuffer(1, clipBuffer.length, clipBuffer.sampleRate); + newClipBuffer.copyToChannel(clipBuffer.getChannelData(0), 0); + setTracks(p => p.map(t => t.id === targetTrackId ? { ...t, buffer: newClipBuffer, name: `Pasted_${name || t.name}`, volume: volume || t.volume, color: color || t.color } : t)); + setCurrentTime(0); + showToast('Đã dán vào track.', 'success'); + return targetTrackId; + } + // No matching track — create a new one const colors = ['#0f766e', '#1d4ed8', '#701a75', '#a21caf', '#b45309']; const newId = 'track_pasted_' + Date.now(); + const newClipBuffer = ctx.createBuffer(1, clipBuffer.length, clipBuffer.sampleRate); + newClipBuffer.copyToChannel(clipBuffer.getChannelData(0), 0); setTracks(prev => [...prev, { - id: newId, - name: `Pasted_${name || 'track'}`, - buffer: newBuffer, - volume: volume || 0.8, - muted: false, - solo: false, + id: newId, name: `Pasted_${name || 'track'}`, buffer: newClipBuffer, + volume: volume || 0.8, muted: false, solo: false, color: color || colors[prev.length % colors.length], - markers: [], - serverFileId: null, + markers: [], serverFileId: null, }]); setSelectedTrackId(newId); + setCurrentTime(0); + showToast('Đã dán track mới từ clipboard.', 'success'); + return newId; + }; + + const handlePasteTrack = () => doPaste(selectedTrackId, currentTime); + + const contextMenuPaste = () => { + const result = doPaste(contextMenu.trackId, contextMenu.time || currentTime); closeContextMenu(); - showToast('Đã dán track từ clipboard.', 'success'); }; const contextMenuMerge = () => { @@ -1961,7 +2015,7 @@ { sep: true }, { 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 }, + { label: 'Paste', icon: 'clipboard', shortcut: 'Ctrl+V', action: handlePasteTrack }, { sep: true }, { label: 'Delete Track', icon: 'trash-2', shortcut: 'Del', action: () => { handleDeleteTrack(); } }, ]},