FIX: Media Explorer panel preview cho phép zoom in zoom out và copy nội dung

This commit is contained in:
2026-08-02 20:55:39 +07:00
parent 827693dfc4
commit e88ce2e3ea
2 changed files with 488 additions and 78 deletions
+462 -58
View File
@@ -9009,7 +9009,7 @@ const MEDIA_LIBRARY_SAMPLES = [
{ name: "MIDI_Loop_06.mid", events: 88, lengthQn: 16, time: "0:08.000", tpqn: 480, bpm: 130, kind: "midi" }
];
const MediaExplorerPanel = ({ height }) => {
const MediaExplorerPanel = ({ height, clipboardRef }) => {
const [userFiles, setUserFiles] = React.useState([]);
const [folder, setFolder] = React.useState('library');
const [selected, setSelected] = React.useState(null);
@@ -9034,7 +9034,13 @@ const MediaExplorerPanel = ({ height }) => {
const [tempo, setTempo] = React.useState(function() {
var saved = localStorage.getItem('studio_media_explorer_tempo');
return saved ? parseInt(saved) : 120;
}());
});
const [zoom, setZoom] = React.useState(1.0);
const [selStart, setSelStart] = React.useState(null);
const [selEnd, setSelEnd] = React.useState(null);
const [isDragging, setIsDragging] = React.useState(false);
const [previewCtxMenu, setPreviewCtxMenu] = React.useState(null); // { x, y }
// Refs mirror latest state so drawCanvas (also called from rAF clock with a
// stale closure) always draws the currently selected file, not the old one.
const selectedRef = React.useRef(null);
@@ -9065,6 +9071,201 @@ const MediaExplorerPanel = ({ height }) => {
folderRef.current = folder;
tempoRef.current = tempo;
currentTimeRef.current = currentTime;
const getCanvasLayout = () => {
const canvas = canvasRef.current;
if (!canvas) return { w: 300, h: 100, pxPerBeat: 42, pxPerSec: 84, offset: 0, dur: 0, contentW: 0 };
const w = canvas.clientWidth;
const h = canvas.clientHeight;
const f = selectedRef.current;
const isMidi = isMidiFile(f);
const curTempo = tempoRef.current || 120;
const pxPerBeat = 42 * zoom;
const pxPerSec = pxPerBeat * curTempo / 60;
const dur = fileDuration(f);
let contentW = 0;
if (isMidi) {
const isRealMidi = midiNotesRef.current && midiNotesRef.current.length && midiTotalBeatsRef.current > 0;
const totalBeats = isRealMidi ? Math.max(midiTotalBeatsRef.current, 4) : Math.max(4, Math.ceil(dur * curTempo / 60) || 16);
contentW = Math.max(1, totalBeats * pxPerBeat);
} else {
contentW = Math.max(1, dur * pxPerSec);
}
let offset = 0;
if (isPlayingRef.current && contentW > w && dur > 0) {
offset = Math.max(0, Math.min(contentW - w, currentTimeRef.current * pxPerSec - w / 2));
}
return { w, h, pxPerBeat, pxPerSec, offset, dur, contentW };
};
const getSelectedAudioBuffer = async () => {
if (audioBufferRef.current) return audioBufferRef.current;
if (!selectedRef.current) return null;
const buf = await readLocalFileBuffer(selectedRef.current);
if (!buf) return null;
try {
const ctx = getAudioContext();
const decoded = await ctx.decodeAudioData(buf);
setAudioBuffer(decoded);
return decoded;
} catch (e) {
console.error(e);
return null;
}
};
const handleCanvasWheel = (e) => {
if (!selected) return;
e.preventDefault();
const zoomFactor = 1.15;
if (e.deltaY < 0) {
setZoom(prev => Math.min(10.0, prev * zoomFactor));
} else {
setZoom(prev => Math.max(0.2, prev / zoomFactor));
}
};
const handleCanvasMouseDown = (e) => {
if (!selected) return;
if (e.button === 2) return; // Right click context menu
const canvas = canvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const clientX = e.clientX - rect.left;
const { pxPerSec, pxPerBeat, offset } = getCanvasLayout();
const isMidi = isMidiFile(selected);
const value = isMidi
? (clientX + offset) / pxPerBeat // in beats
: (clientX + offset) / pxPerSec; // in seconds
setSelStart(value);
setSelEnd(value);
setIsDragging(true);
setPreviewCtxMenu(null);
};
const handleCanvasMouseMove = (e) => {
if (!isDragging || !selected) return;
const canvas = canvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const clientX = e.clientX - rect.left;
const { pxPerSec, pxPerBeat, offset } = getCanvasLayout();
const isMidi = isMidiFile(selected);
const value = isMidi
? (clientX + offset) / pxPerBeat
: (clientX + offset) / pxPerSec;
setSelEnd(value);
};
const handleCanvasMouseUp = (e) => {
if (isDragging) {
setIsDragging(false);
}
};
const handleCanvasContextMenu = (e) => {
e.preventDefault();
if (!selected || selStart === null || selEnd === null || Math.abs(selStart - selEnd) < 0.01) return;
setPreviewCtxMenu({
x: e.clientX,
y: e.clientY
});
};
const handleCopySelection = async () => {
if (!selected || selStart === null || selEnd === null) return;
const isMidi = isMidiFile(selected);
const startVal = Math.min(selStart, selEnd);
const endVal = Math.max(selStart, selEnd);
if (isMidi) {
if (!midiNotes || !midiNotes.length) {
window.showToast && window.showToast('Không có dữ liệu MIDI để sao chép', 'warning');
return;
}
const copiedNotes = midiNotes
.filter(n => n.start_beat >= startVal && n.start_beat <= endVal)
.map(n => ({
...n,
start_beat: n.start_beat - startVal
}));
if (!copiedNotes.length) {
window.showToast && window.showToast('Không có note MIDI nào trong vùng chọn', 'warning');
return;
}
const selectDurBeats = endVal - startVal;
const secondsPerBeat = 60 / (tempo || 120);
const selectDurSec = selectDurBeats * secondsPerBeat;
const clipObj = {
type: 'midi',
notes: copiedNotes,
duration: selectDurSec,
name: selected.name || 'MIDI Selection',
color: '#a855f7'
};
if (clipboardRef) clipboardRef.current = clipObj;
window.globalStudioClipboard = clipObj;
window.showToast && window.showToast(`Đã sao chép ${copiedNotes.length} notes MIDI.`, 'success');
} else {
const activeBuf = await getSelectedAudioBuffer();
if (!activeBuf) {
window.showToast && window.showToast('Không thể tải dữ liệu âm thanh để sao chép', 'error');
return;
}
const sr = activeBuf.sampleRate;
const startSample = Math.floor(startVal * sr);
const endSample = Math.floor(endVal * sr);
const len = Math.max(1, endSample - startSample);
try {
const ctx = getAudioContext();
const numCh = activeBuf.numberOfChannels || 1;
const clipBuffer = ctx.createBuffer(numCh, len, sr);
for (let ch = 0; ch < numCh; ch++) {
clipBuffer.copyToChannel(activeBuf.getChannelData(ch).subarray(startSample, endSample), ch);
}
const clipObj = {
buffer: clipBuffer,
name: selected.name || 'Audio Selection',
volumeDb: 0,
pan: 0,
color: '#10b981',
sampleRate: sr,
channels: numCh,
speed: 1.0
};
if (clipboardRef) clipboardRef.current = clipObj;
window.globalStudioClipboard = clipObj;
window.showToast && window.showToast(`Đã sao chép đoạn audio dài ${(len / sr).toFixed(2)}s.`, 'success');
} catch (e) {
console.error(e);
window.showToast && window.showToast('Lỗi khi trích xuất âm thanh vùng chọn', 'error');
}
}
};
React.useEffect(() => {
const handleGlobalClick = () => setPreviewCtxMenu(null);
window.addEventListener('click', handleGlobalClick);
return () => window.removeEventListener('click', handleGlobalClick);
}, []);
const [computerRoots, setComputerRoots] = React.useState(null);
const [computerTree, setComputerTree] = React.useState({});
const [computerPath, setComputerPath] = React.useState(null);
@@ -9370,6 +9571,7 @@ const MediaExplorerPanel = ({ height }) => {
const openMyComputer = async () => {
setFolder('computer');
const useClientRoot = async (rootHandle) => {
setComputerTree({}); // Dn sch cache cây thư mc cũ
setComputerMode('client');
setClientRoot(rootHandle);
const rootEntry = { name: rootHandle.name, path: 'root', is_dir: true, handle: rootHandle };
@@ -9524,19 +9726,37 @@ const MediaExplorerPanel = ({ height }) => {
// C gng tìm handle trong cây đã load đ m trc tiếp
const node = computerTree[fav.path];
if (node && node.handle) {
setComputerTree({}); // Reset cache
browseComputerDir({ ...entry, handle: node.handle });
} else if (fav.path && fav.path.startsWith('client:')) {
(async () => {
let favHandle = null;
try { favHandle = await loadClientRootHandle(fav.path); } catch (e) {}
if (favHandle) {
setClientRoot(favHandle);
setComputerMode('client');
const rootEntry = { name: favHandle.name, path: 'root', is_dir: true, handle: favHandle };
setComputerRoots([rootEntry]);
setComputerPath('root');
setComputerFiles([]);
await browseComputerDir(rootEntry);
// Yêu cu quyn đc (permission có th b thu hi)
let permitted = true;
try {
if (typeof favHandle.queryPermission === 'function') {
const st = await favHandle.queryPermission({ mode: 'read' });
if (st !== 'granted' && typeof favHandle.requestPermission === 'function') {
const r = await favHandle.requestPermission({ mode: 'read' });
permitted = r === 'granted';
}
}
} catch (e) { permitted = false; }
if (permitted) {
setComputerTree({}); // Reset cache
setClientRoot(favHandle);
setComputerMode('client');
const rootEntry = { name: favHandle.name, path: 'root', is_dir: true, handle: favHandle };
setComputerRoots([rootEntry]);
setComputerPath('root');
setComputerFiles([]);
await browseComputerDir(rootEntry);
} else {
window.showToast && window.showToast('Không có quyền truy cập thư mục này', 'warning');
}
} else {
window.showToast && window.showToast('Không thể khôi phục quyền truy cập thư mục này', 'error');
}
@@ -9803,7 +10023,7 @@ const MediaExplorerPanel = ({ height }) => {
ctx.scale(2, 2);
ctx.clearRect(0, 0, w, h);
ctx.fillStyle = '#181818'; ctx.fillRect(0, 0, w, h);
// Read from refs so the rAF clock's stale closure still draws the latest file
const f = selectedRef.current;
const curPeaks = peaksRef.current;
const curAudioBuffer = audioBufferRef.current;
@@ -9819,32 +10039,35 @@ const MediaExplorerPanel = ({ height }) => {
return;
}
const dur = fileDuration(f);
// Scale layout parameters using zoom
const pxPerBeat = 42 * zoom;
const pxPerSec = pxPerBeat * (curTempo || 120) / 60;
if (isMidiFile(f)) {
ctx.strokeStyle = '#333';
for (let y = 0; y < h - 14; y += 10) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke(); }
ctx.strokeStyle = '#444';
// Content scrolls when wider than frame: playhead stays at frame center
const bpmV = curTempo || 120;
const pxPerBeat = 42;
const totalBeats = (curMidiNotes && curMidiNotes.length && curMidiTotalBeats > 0)
? curMidiTotalBeats
: Math.max(curMidiTotal * bpmV / 60, 4);
const isRealMidi = curMidiNotes && curMidiNotes.length && curMidiTotalBeats > 0;
const totalBeats = isRealMidi ? curMidiTotalBeats : Math.max(curMidiTotal * (curTempo || 120) / 60, 4);
const beats = Math.max(totalBeats, 4);
const contentW = Math.max(w, beats * pxPerBeat);
const pxPerSec = pxPerBeat * bpmV / 60;
let offset = 0;
if (playing && contentW > w && dur > 0) {
offset = Math.max(0, Math.min(contentW - w, t * pxPerSec - w / 2));
}
// bar lines (every 4 beats) + beat labels
// bar lines (every 4 beats)
for (let b = 0; b <= beats; b += 4) {
const x = b * pxPerBeat - offset;
if (x < -10 || x > w + 10) continue;
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h - 14); ctx.stroke();
}
ctx.fillStyle = '#9ca3af';
if (curMidiNotes && curMidiNotes.length) {
// Real piano-roll: rows = pitches (48..84), columns = beats
const pitchMin = 48, pitchMax = 84;
const pitchRange = Math.max(1, pitchMax - pitchMin);
curMidiNotes.forEach(n => {
@@ -9862,8 +10085,23 @@ const MediaExplorerPanel = ({ height }) => {
ctx.fillRect(nx, ny, Math.max(8, (i % 5 + 1) * 10), 3);
}
}
// playhead (drawn over MIDI): moves to frame center, then stops there & content scrolls.
// Khi content không scroll đưc na (offset đt max) thì playhead tiếp tc chy ti cui canvas.
// Draw selection overlay
if (selStart !== null && selEnd !== null && Math.abs(selStart - selEnd) > 0.01) {
const startVal = Math.min(selStart, selEnd);
const endVal = Math.max(selStart, selEnd);
const xStart = startVal * pxPerBeat - offset;
const xEnd = endVal * pxPerBeat - offset;
ctx.fillStyle = 'rgba(59, 130, 246, 0.25)';
ctx.fillRect(xStart, 0, xEnd - xStart, h - 14);
ctx.strokeStyle = '#3b82f6';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(xStart, 0); ctx.lineTo(xStart, h - 14);
ctx.moveTo(xEnd, 0); ctx.lineTo(xEnd, h - 14);
ctx.stroke();
}
const midiPlayheadX = contentW > w ? Math.max(0, Math.min(w, t * pxPerSec - offset)) : Math.min(w, t * pxPerSec);
if (playing && dur > 0) {
ctx.fillStyle = '#ef4444';
@@ -9872,10 +10110,6 @@ const MediaExplorerPanel = ({ height }) => {
} else {
const pk = curPeaks && curPeaks.length > 0 ? curPeaks : null;
if (pk) {
// Audio hin th theo t l tempo (pxPerSec), không giãn đy khung preview
const audioBpm = curTempo || 120;
const pxPerBeat = 42;
const pxPerSec = pxPerBeat * audioBpm / 60;
const contentW = Math.max(1, dur * pxPerSec);
let offset = 0;
if (playing && contentW > w && dur > 0) {
@@ -9890,8 +10124,23 @@ const MediaExplorerPanel = ({ height }) => {
const ph = Math.max(2, pk[i] * (h / 2 - 4));
ctx.fillRect(x, mid - ph, barW, ph * 2);
}
// Playhead theo t l tempo; khi content rng hơn frame thì khóa gia + scroll.
// Khi content không scroll đưc na (offset đt max) thì playhead tiếp tc chy ti cui canvas.
// Draw selection overlay
if (selStart !== null && selEnd !== null && Math.abs(selStart - selEnd) > 0.01) {
const startVal = Math.min(selStart, selEnd);
const endVal = Math.max(selStart, selEnd);
const xStart = startVal * pxPerSec - offset;
const xEnd = endVal * pxPerSec - offset;
ctx.fillStyle = 'rgba(59, 130, 246, 0.25)';
ctx.fillRect(xStart, 0, xEnd - xStart, h - 14);
ctx.strokeStyle = '#3b82f6';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(xStart, 0); ctx.lineTo(xStart, h - 14);
ctx.moveTo(xEnd, 0); ctx.lineTo(xEnd, h - 14);
ctx.stroke();
}
const audioPlayheadX = contentW > w ? Math.max(0, Math.min(w, t * pxPerSec - offset)) : (t * pxPerSec);
if (playing && dur > 0) {
ctx.fillStyle = '#ef4444';
@@ -9902,25 +10151,22 @@ const MediaExplorerPanel = ({ height }) => {
ctx.fillText('Waveform unavailable', 10, h / 2);
}
}
// ruler
ctx.fillStyle = '#111'; ctx.fillRect(0, h - 14, w, 14);
ctx.fillStyle = '#888'; ctx.font = '9px JetBrains Mono, monospace';
const rulerBpm = curTempo || 120;
const rulerPxPerBeat = 42;
const isRealMidi = isMidiFile(f) && curMidiNotes && curMidiNotes.length && curMidiTotalBeats > 0;
const rulerTotalBeats = isRealMidi ? Math.max(curMidiTotalBeats, 4) : Math.max(4, Math.ceil(dur * rulerBpm / 60) || 16);
// T l theo tempo (không giãn đy khung)
const rulerContentW = Math.max(1, rulerTotalBeats * rulerPxPerBeat);
const rulerPxPerSec = rulerPxPerBeat * rulerBpm / 60;
const rulerOffset = (playing && rulerContentW > w && dur > 0) ? Math.max(0, Math.min(rulerContentW - w, t * rulerPxPerSec - w / 2)) : 0;
const rulerTotalBeats = isRealMidi ? Math.max(curMidiTotalBeats, 4) : Math.max(4, Math.ceil(dur * (curTempo || 120) / 60) || 16);
const rulerContentW = Math.max(1, rulerTotalBeats * pxPerBeat);
const rulerOffset = (playing && rulerContentW > w && dur > 0) ? Math.max(0, Math.min(rulerContentW - w, t * pxPerSec - w / 2)) : 0;
for (let b = 0; b <= rulerTotalBeats; b += 4) {
const x = b * rulerPxPerBeat - rulerOffset;
const x = b * pxPerBeat - rulerOffset;
if (x < -20 || x > w + 20) continue;
ctx.fillText(String(Math.floor(b / 4)), x + 2, h - 3);
}
};
React.useEffect(() => { drawCanvas(currentTime); }, [peaks, audioBuffer, audioDuration, midiNotes, selected, folder, isPlaying]);
React.useEffect(() => { drawCanvas(currentTime); }, [peaks, audioBuffer, audioDuration, midiNotes, selected, folder, isPlaying, zoom, selStart, selEnd]);
React.useEffect(() => () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); }, []);
const handleSelect = (f) => {
@@ -9933,6 +10179,9 @@ const MediaExplorerPanel = ({ height }) => {
setAudioBuffer(null);
setAudioDuration(0);
setMidiNotes(null);
setSelStart(null);
setSelEnd(null);
setPreviewCtxMenu(null);
stopMediaPlayback();
if (f.kind === 'other') return;
if (autoPlay) { playSelected(f, token); }
@@ -10030,25 +10279,17 @@ const MediaExplorerPanel = ({ height }) => {
<div className="space-y-0.5 font-sans">
<div className="flex items-center gap-1 px-1 py-0.5 text-slate-700"><span className="w-3"></span> &lt;Track Templates&gt;</div>
<div className="flex items-center gap-1 px-1 py-0.5 text-slate-700"><span className="w-3"></span> &lt;Project Directory&gt;</div>
<div className={`flex items-center gap-1 px-1 py-0.5 cursor-pointer rounded-sm ${folder === 'computer' && computerPath !== 'favorited' ? 'bg-slate-300 text-slate-900 font-semibold' : 'hover:bg-slate-200 text-slate-800'}`} onClick={openMyComputer}>
<i className="fa-solid fa-computer text-[11px] text-slate-600"></i> My Computer
</div>
{folder === 'computer' && computerPath !== 'favorited' && computerRoots && (
<div className="pl-3 space-y-0.5">
{computerRoots.map(root => renderComputerNode(root, 0, true))}
</div>
)}
<div className={`flex items-center gap-1 px-1 py-0.5 cursor-pointer rounded-sm ${folder === 'computer' && computerPath === 'favorited' ? 'bg-slate-300 text-slate-900 font-semibold' : 'hover:bg-slate-200 text-slate-800'}`} onClick={openFavorited}>
<i className={`fa-solid ${favoritedExpanded ? 'fa-minus' : 'fa-plus'} text-[9px] text-slate-500 w-3 text-center shrink-0`} onClick={e => { e.stopPropagation(); setFavoritedExpanded(!favoritedExpanded); }}></i>
<i className="fa-solid fa-star text-amber-500 text-[10px]"></i> Favorited
{/* FAVORITED */}
<div className={`flex items-center gap-1 px-1 py-0.5 cursor-pointer rounded-sm font-bold ${folder === 'computer' && computerPath === 'favorited' ? 'bg-slate-300 text-slate-900' : 'hover:bg-slate-200 text-slate-800'}`}
onClick={() => { openFavorited(); setFavoritedExpanded(!favoritedExpanded); }}>
<i className="fa-solid fa-star text-amber-500 text-[11px] font-bold"></i> Favorited
</div>
{favoritedExpanded && (
<div className="pl-3 space-y-0.5">
{favorites.map((fav, fi) => (
<div key={fav.path + fi}
className="flex items-center gap-1 px-1 py-0.5 cursor-pointer pl-4 rounded-sm hover:bg-amber-100 text-slate-800"
style={{ paddingLeft: 24 }}
className="flex items-center gap-1 px-1 py-0.5 cursor-pointer rounded-sm hover:bg-amber-100 text-slate-800"
style={{ paddingLeft: 20 }}
onClick={() => openFavorite(fav)}
onContextMenu={e => { e.preventDefault(); e.stopPropagation(); setFavContext(Object.assign({}, fav, { x: e.clientX, y: e.clientY })); }}>
<i className="fa-solid fa-folder text-[#d9a752] shrink-0"></i>
@@ -10062,6 +10303,16 @@ const MediaExplorerPanel = ({ height }) => {
</div>
)}
{/* MY COMPUTER */}
<div className={`flex items-center gap-1 px-1 py-0.5 cursor-pointer rounded-sm ${folder === 'computer' && computerPath !== 'favorited' ? 'bg-slate-300 text-slate-900 font-semibold' : 'hover:bg-slate-200 text-slate-800'}`} onClick={openMyComputer}>
<i className="fa-solid fa-computer text-[11px] text-slate-600"></i> My Computer
</div>
{folder === 'computer' && computerPath !== 'favorited' && computerRoots && (
<div className="pl-3 space-y-0.5">
{computerRoots.map(root => renderComputerNode(root, 0, true))}
</div>
)}
<div className={`flex items-center gap-1 px-1 py-0.5 cursor-pointer font-semibold rounded-sm ${folder === 'library' ? 'bg-slate-300 text-slate-900' : 'hover:bg-slate-200 text-slate-800'}`} onClick={() => setFolder('library')}>
<i className="fa-solid fa-folder-open text-[#d9a752]"></i> Media Library
</div>
@@ -10251,7 +10502,65 @@ const MediaExplorerPanel = ({ height }) => {
{/* CANVAS + METADATA */}
<div className="flex items-stretch gap-2 my-1 flex-1 min-h-0">
<div className="flex-1 bg-[#181818] border border-[#3a3a3a] relative overflow-hidden">
<canvas ref={canvasRef} className="w-full h-full block cursor-pointer"></canvas>
<canvas
ref={canvasRef}
className="w-full h-full block cursor-pointer"
onMouseDown={handleCanvasMouseDown}
onMouseMove={handleCanvasMouseMove}
onMouseUp={handleCanvasMouseUp}
onContextMenu={handleCanvasContextMenu}
onWheel={handleCanvasWheel}
></canvas>
{/* FLOATING ZOOM CONTROLS */}
<div className="absolute top-1.5 right-1.5 flex gap-1 z-10">
<button
className="w-5 h-5 bg-[#2a2a2a] hover:bg-slate-700 border border-[#444] rounded flex items-center justify-center text-white text-xs font-bold select-none cursor-pointer"
title="Zoom In"
onClick={() => setZoom(prev => Math.min(10.0, prev * 1.25))}
>
+
</button>
<button
className="w-5 h-5 bg-[#2a2a2a] hover:bg-slate-700 border border-[#444] rounded flex items-center justify-center text-white text-xs font-bold select-none cursor-pointer"
title="Zoom Out"
onClick={() => setZoom(prev => Math.max(0.2, prev / 1.25))}
>
-
</button>
<button
className="w-5 h-5 bg-[#2a2a2a] hover:bg-slate-700 border border-[#444] rounded flex items-center justify-center text-white text-[9px] select-none cursor-pointer"
title="Reset Zoom"
onClick={() => setZoom(1.0)}
>
1x
</button>
</div>
{/* PREVIEW CONTEXT MENU */}
{previewCtxMenu && (
<div
className="fixed bg-[#1e1e24] border border-[#3e3e4a] rounded shadow-md z-[9999] py-1 font-sans text-xs text-slate-300 w-32 cursor-pointer select-none"
style={{ top: previewCtxMenu.y, left: previewCtxMenu.x }}
onClick={(e) => e.stopPropagation()}
>
<div
className="px-3 py-1.5 hover:bg-slate-700 hover:text-white flex items-center gap-2"
onClick={() => {
handleCopySelection();
setPreviewCtxMenu(null);
}}
>
<i className="fa-solid fa-copy"></i> Copy
</div>
<div
className="px-3 py-1.5 hover:bg-slate-700 hover:text-white border-t border-[#3e3e4a] flex items-center gap-2"
onClick={() => setPreviewCtxMenu(null)}
>
<i className="fa-solid fa-xmark"></i> Cancel
</div>
</div>
)}
</div>
<div className="w-48 bg-[#181818] border border-[#3a3a3a] p-1.5 font-mono text-[10px] text-slate-300 leading-tight overflow-y-auto shrink-0">
{selected ? (
@@ -12176,13 +12485,43 @@ const App = () => {
};
const handleSubTabPaste = tabId => {
const st = subTabsRef.current.find(s => s.id === tabId);
if (!st || !st.buffer) return;
if (!clipboardRef.current || !clipboardRef.current.buffer) {
showToast('Clipboard trống.', 'warning');
if (!st) return;
// Check if Piano Roll Tab
if (st.type === 'PIANO_ROLL') {
const clip = clipboardRef.current || window.globalStudioClipboard;
if (!clip || clip.type !== 'midi' || !clip.notes) {
showToast('Clipboard không chứa dữ liệu MIDI.', 'warning');
return;
}
const secondsPerBeat = 60 / (bpm || 120);
const pasteBeat = (st.currentTime || 0) / secondsPerBeat;
const newNotes = clip.notes.map(n => ({
id: 'note_' + Math.random().toString(36).substr(2, 9),
pitch: n.pitch,
start_beat: pasteBeat + n.start_beat,
duration_beats: n.duration_beats,
velocity: n.velocity || 0.8
}));
setSubTabs(prev => prev.map(s => s.id === tabId ? {
...s,
notes: [...(s.notes || []), ...newNotes]
} : s));
showToast('Đã dán note MIDI vào Piano Roll.', 'success');
return;
}
// Default Waveform Audio Paste
if (!st.buffer) return;
const clip = clipboardRef.current || window.globalStudioClipboard;
if (!clip || !clip.buffer) {
showToast('Clipboard trống hoặc không chứa dữ liệu âm thanh.', 'warning');
return;
}
const ctx = getAudioContext();
const clipBuf = clipboardRef.current.buffer;
const clipBuf = clip.buffer;
const sr = st.buffer.sampleRate;
const data = st.buffer.getChannelData(0);
const insertTime = st.currentTime || 0;
@@ -13730,10 +14069,74 @@ const App = () => {
contextMenuDelete();
};
const doPaste = (targetTrackId, pasteTime) => {
if (!clipboardRef.current || !clipboardRef.current.buffer) {
const clip = clipboardRef.current || window.globalStudioClipboard;
if (!clip) {
showToast('Clipboard trống.', 'warning');
return null;
}
// Support MIDI Clip Paste
if (clip.type === 'midi') {
const { notes, name, duration, color } = clip;
const targetTrack = activeTracks.find(t => t.id === targetTrackId);
const newMidiItem = {
id: 'midi_' + Date.now(),
startTime: pasteTime,
duration: duration || 4,
name: (name || 'Pasted MIDI').replace(/\.\w+$/, '') + ' (Pasted)',
notes: notes.map(n => ({
id: 'note_' + Math.random().toString(36).substr(2, 9),
pitch: n.pitch,
start_beat: n.start_beat,
duration_beats: n.duration_beats,
velocity: n.velocity || 0.8
}))
};
if (targetTrack) {
updateActiveTracks(p => p.map(t => {
if (t.id === targetTrackId) {
const existingMidi = t.midiItems || [];
return {
...t,
midiItems: [...existingMidi, newMidiItem]
};
}
return t;
}));
setCurrentTime(pasteTime);
showToast('Đã dán MIDI vào track.', 'success');
return targetTrackId;
} else {
const colors = ['#0f766e', '#1d4ed8', '#701a75', '#a21caf', '#b45309'];
const rearrangeNewId = 'track_pasted_midi_' + Date.now();
updateActiveTracks(prev => [...prev, {
id: rearrangeNewId,
name: `Pasted_${name || 'MIDI'}`,
buffer: null,
startTime: 0,
clips: [],
midiItems: [newMidiItem],
volumeDb: 0,
pan: 0,
muted: false,
solo: false,
color: color || colors[prev.length % colors.length],
markers: [],
serverFileId: null
}]);
setSelectedTrackId(rearrangeNewId);
setCurrentTime(pasteTime);
showToast('Đã dán track MIDI mới từ clipboard.', 'success');
return rearrangeNewId;
}
}
// Default Audio Clip Paste
if (!clip.buffer) {
showToast('Clipboard không chứa dữ liệu âm thanh hợp lệ.', 'warning');
return null;
}
const {
buffer: clipBuffer,
name,
@@ -13743,7 +14146,7 @@ const App = () => {
sampleRate,
channels,
speed
} = clipboardRef.current;
} = clip;
const ctx = getAudioContext();
const targetTrack = activeTracks.find(t => t.id === targetTrackId);
const newClip = {
@@ -22664,7 +23067,8 @@ const App = () => {
})))), /*#__PURE__*/React.createElement("div", {
className: "flex-1 min-h-0 overflow-hidden bg-[#262626]"
}, /*#__PURE__*/React.createElement(MediaExplorerPanel, {
height: mediaExplorerPanelHeight
height: mediaExplorerPanelHeight,
clipboardRef: clipboardRef
}))));
})(), /*#__PURE__*/React.createElement("div", {
className: "h-6 bg-[#141414] border-t border-zinc-900 px-4 flex items-center justify-between text-xs text-zinc-500 select-none shrink-0"
File diff suppressed because one or more lines are too long