FIX: Media Explorer panel preview cho phép zoom in zoom out và copy nội dung
This commit is contained in:
+455
-51
@@ -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({}); // Dọn sạch cache cây thư mục cũ
|
||||
setComputerMode('client');
|
||||
setClientRoot(rootHandle);
|
||||
const rootEntry = { name: rootHandle.name, path: 'root', is_dir: true, handle: rootHandle };
|
||||
@@ -9524,12 +9726,27 @@ const MediaExplorerPanel = ({ height }) => {
|
||||
// Cố gắng tìm handle trong cây đã load để mở trực 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) {
|
||||
// Yêu cầu quyền đọc (permission có thể bị thu hồi)
|
||||
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 };
|
||||
@@ -9537,6 +9754,9 @@ const MediaExplorerPanel = ({ height }) => {
|
||||
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 nữa (offset đạt max) thì playhead tiếp tục chạy tới cuối 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 hiển 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 rộng hơn frame thì khóa giữa + scroll.
|
||||
// Khi content không scroll được nữa (offset đạt max) thì playhead tiếp tục chạy tới cuối 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> <Track Templates></div>
|
||||
<div className="flex items-center gap-1 px-1 py-0.5 text-slate-700"><span className="w-3"></span> <Project Directory></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
Reference in New Issue
Block a user