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(); } },
]},