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" }
|
{ 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 [userFiles, setUserFiles] = React.useState([]);
|
||||||
const [folder, setFolder] = React.useState('library');
|
const [folder, setFolder] = React.useState('library');
|
||||||
const [selected, setSelected] = React.useState(null);
|
const [selected, setSelected] = React.useState(null);
|
||||||
@@ -9034,7 +9034,13 @@ const MediaExplorerPanel = ({ height }) => {
|
|||||||
const [tempo, setTempo] = React.useState(function() {
|
const [tempo, setTempo] = React.useState(function() {
|
||||||
var saved = localStorage.getItem('studio_media_explorer_tempo');
|
var saved = localStorage.getItem('studio_media_explorer_tempo');
|
||||||
return saved ? parseInt(saved) : 120;
|
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
|
// 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.
|
// stale closure) always draws the currently selected file, not the old one.
|
||||||
const selectedRef = React.useRef(null);
|
const selectedRef = React.useRef(null);
|
||||||
@@ -9065,6 +9071,201 @@ const MediaExplorerPanel = ({ height }) => {
|
|||||||
folderRef.current = folder;
|
folderRef.current = folder;
|
||||||
tempoRef.current = tempo;
|
tempoRef.current = tempo;
|
||||||
currentTimeRef.current = currentTime;
|
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 [computerRoots, setComputerRoots] = React.useState(null);
|
||||||
const [computerTree, setComputerTree] = React.useState({});
|
const [computerTree, setComputerTree] = React.useState({});
|
||||||
const [computerPath, setComputerPath] = React.useState(null);
|
const [computerPath, setComputerPath] = React.useState(null);
|
||||||
@@ -9370,6 +9571,7 @@ const MediaExplorerPanel = ({ height }) => {
|
|||||||
const openMyComputer = async () => {
|
const openMyComputer = async () => {
|
||||||
setFolder('computer');
|
setFolder('computer');
|
||||||
const useClientRoot = async (rootHandle) => {
|
const useClientRoot = async (rootHandle) => {
|
||||||
|
setComputerTree({}); // Dọn sạch cache cây thư mục cũ
|
||||||
setComputerMode('client');
|
setComputerMode('client');
|
||||||
setClientRoot(rootHandle);
|
setClientRoot(rootHandle);
|
||||||
const rootEntry = { name: rootHandle.name, path: 'root', is_dir: true, handle: 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
|
// Cố gắng tìm handle trong cây đã load để mở trực tiếp
|
||||||
const node = computerTree[fav.path];
|
const node = computerTree[fav.path];
|
||||||
if (node && node.handle) {
|
if (node && node.handle) {
|
||||||
|
setComputerTree({}); // Reset cache
|
||||||
browseComputerDir({ ...entry, handle: node.handle });
|
browseComputerDir({ ...entry, handle: node.handle });
|
||||||
} else if (fav.path && fav.path.startsWith('client:')) {
|
} else if (fav.path && fav.path.startsWith('client:')) {
|
||||||
(async () => {
|
(async () => {
|
||||||
let favHandle = null;
|
let favHandle = null;
|
||||||
try { favHandle = await loadClientRootHandle(fav.path); } catch (e) {}
|
try { favHandle = await loadClientRootHandle(fav.path); } catch (e) {}
|
||||||
if (favHandle) {
|
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);
|
setClientRoot(favHandle);
|
||||||
setComputerMode('client');
|
setComputerMode('client');
|
||||||
const rootEntry = { name: favHandle.name, path: 'root', is_dir: true, handle: favHandle };
|
const rootEntry = { name: favHandle.name, path: 'root', is_dir: true, handle: favHandle };
|
||||||
@@ -9537,6 +9754,9 @@ const MediaExplorerPanel = ({ height }) => {
|
|||||||
setComputerPath('root');
|
setComputerPath('root');
|
||||||
setComputerFiles([]);
|
setComputerFiles([]);
|
||||||
await browseComputerDir(rootEntry);
|
await browseComputerDir(rootEntry);
|
||||||
|
} else {
|
||||||
|
window.showToast && window.showToast('Không có quyền truy cập thư mục này', 'warning');
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
window.showToast && window.showToast('Không thể khôi phục quyền truy cập thư mục này', 'error');
|
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.scale(2, 2);
|
||||||
ctx.clearRect(0, 0, w, h);
|
ctx.clearRect(0, 0, w, h);
|
||||||
ctx.fillStyle = '#181818'; ctx.fillRect(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 f = selectedRef.current;
|
||||||
const curPeaks = peaksRef.current;
|
const curPeaks = peaksRef.current;
|
||||||
const curAudioBuffer = audioBufferRef.current;
|
const curAudioBuffer = audioBufferRef.current;
|
||||||
@@ -9819,32 +10039,35 @@ const MediaExplorerPanel = ({ height }) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const dur = fileDuration(f);
|
const dur = fileDuration(f);
|
||||||
|
|
||||||
|
// Scale layout parameters using zoom
|
||||||
|
const pxPerBeat = 42 * zoom;
|
||||||
|
const pxPerSec = pxPerBeat * (curTempo || 120) / 60;
|
||||||
|
|
||||||
if (isMidiFile(f)) {
|
if (isMidiFile(f)) {
|
||||||
ctx.strokeStyle = '#333';
|
ctx.strokeStyle = '#333';
|
||||||
for (let y = 0; y < h - 14; y += 10) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke(); }
|
for (let y = 0; y < h - 14; y += 10) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke(); }
|
||||||
ctx.strokeStyle = '#444';
|
ctx.strokeStyle = '#444';
|
||||||
// Content scrolls when wider than frame: playhead stays at frame center
|
|
||||||
const bpmV = curTempo || 120;
|
const isRealMidi = curMidiNotes && curMidiNotes.length && curMidiTotalBeats > 0;
|
||||||
const pxPerBeat = 42;
|
const totalBeats = isRealMidi ? curMidiTotalBeats : Math.max(curMidiTotal * (curTempo || 120) / 60, 4);
|
||||||
const totalBeats = (curMidiNotes && curMidiNotes.length && curMidiTotalBeats > 0)
|
|
||||||
? curMidiTotalBeats
|
|
||||||
: Math.max(curMidiTotal * bpmV / 60, 4);
|
|
||||||
const beats = Math.max(totalBeats, 4);
|
const beats = Math.max(totalBeats, 4);
|
||||||
const contentW = Math.max(w, beats * pxPerBeat);
|
const contentW = Math.max(w, beats * pxPerBeat);
|
||||||
const pxPerSec = pxPerBeat * bpmV / 60;
|
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
if (playing && contentW > w && dur > 0) {
|
if (playing && contentW > w && dur > 0) {
|
||||||
offset = Math.max(0, Math.min(contentW - w, t * pxPerSec - w / 2));
|
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) {
|
for (let b = 0; b <= beats; b += 4) {
|
||||||
const x = b * pxPerBeat - offset;
|
const x = b * pxPerBeat - offset;
|
||||||
if (x < -10 || x > w + 10) continue;
|
if (x < -10 || x > w + 10) continue;
|
||||||
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h - 14); ctx.stroke();
|
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h - 14); ctx.stroke();
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.fillStyle = '#9ca3af';
|
ctx.fillStyle = '#9ca3af';
|
||||||
if (curMidiNotes && curMidiNotes.length) {
|
if (curMidiNotes && curMidiNotes.length) {
|
||||||
// Real piano-roll: rows = pitches (48..84), columns = beats
|
|
||||||
const pitchMin = 48, pitchMax = 84;
|
const pitchMin = 48, pitchMax = 84;
|
||||||
const pitchRange = Math.max(1, pitchMax - pitchMin);
|
const pitchRange = Math.max(1, pitchMax - pitchMin);
|
||||||
curMidiNotes.forEach(n => {
|
curMidiNotes.forEach(n => {
|
||||||
@@ -9862,8 +10085,23 @@ const MediaExplorerPanel = ({ height }) => {
|
|||||||
ctx.fillRect(nx, ny, Math.max(8, (i % 5 + 1) * 10), 3);
|
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);
|
const midiPlayheadX = contentW > w ? Math.max(0, Math.min(w, t * pxPerSec - offset)) : Math.min(w, t * pxPerSec);
|
||||||
if (playing && dur > 0) {
|
if (playing && dur > 0) {
|
||||||
ctx.fillStyle = '#ef4444';
|
ctx.fillStyle = '#ef4444';
|
||||||
@@ -9872,10 +10110,6 @@ const MediaExplorerPanel = ({ height }) => {
|
|||||||
} else {
|
} else {
|
||||||
const pk = curPeaks && curPeaks.length > 0 ? curPeaks : null;
|
const pk = curPeaks && curPeaks.length > 0 ? curPeaks : null;
|
||||||
if (pk) {
|
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);
|
const contentW = Math.max(1, dur * pxPerSec);
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
if (playing && contentW > w && dur > 0) {
|
if (playing && contentW > w && dur > 0) {
|
||||||
@@ -9890,8 +10124,23 @@ const MediaExplorerPanel = ({ height }) => {
|
|||||||
const ph = Math.max(2, pk[i] * (h / 2 - 4));
|
const ph = Math.max(2, pk[i] * (h / 2 - 4));
|
||||||
ctx.fillRect(x, mid - ph, barW, ph * 2);
|
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);
|
const audioPlayheadX = contentW > w ? Math.max(0, Math.min(w, t * pxPerSec - offset)) : (t * pxPerSec);
|
||||||
if (playing && dur > 0) {
|
if (playing && dur > 0) {
|
||||||
ctx.fillStyle = '#ef4444';
|
ctx.fillStyle = '#ef4444';
|
||||||
@@ -9902,25 +10151,22 @@ const MediaExplorerPanel = ({ height }) => {
|
|||||||
ctx.fillText('Waveform unavailable', 10, h / 2);
|
ctx.fillText('Waveform unavailable', 10, h / 2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ruler
|
// ruler
|
||||||
ctx.fillStyle = '#111'; ctx.fillRect(0, h - 14, w, 14);
|
ctx.fillStyle = '#111'; ctx.fillRect(0, h - 14, w, 14);
|
||||||
ctx.fillStyle = '#888'; ctx.font = '9px JetBrains Mono, monospace';
|
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 isRealMidi = isMidiFile(f) && curMidiNotes && curMidiNotes.length && curMidiTotalBeats > 0;
|
||||||
const rulerTotalBeats = isRealMidi ? Math.max(curMidiTotalBeats, 4) : Math.max(4, Math.ceil(dur * rulerBpm / 60) || 16);
|
const rulerTotalBeats = isRealMidi ? Math.max(curMidiTotalBeats, 4) : Math.max(4, Math.ceil(dur * (curTempo || 120) / 60) || 16);
|
||||||
// Tỉ lệ theo tempo (không giãn đầy khung)
|
const rulerContentW = Math.max(1, rulerTotalBeats * pxPerBeat);
|
||||||
const rulerContentW = Math.max(1, rulerTotalBeats * rulerPxPerBeat);
|
const rulerOffset = (playing && rulerContentW > w && dur > 0) ? Math.max(0, Math.min(rulerContentW - w, t * pxPerSec - w / 2)) : 0;
|
||||||
const rulerPxPerSec = rulerPxPerBeat * rulerBpm / 60;
|
|
||||||
const rulerOffset = (playing && rulerContentW > w && dur > 0) ? Math.max(0, Math.min(rulerContentW - w, t * rulerPxPerSec - w / 2)) : 0;
|
|
||||||
for (let b = 0; b <= rulerTotalBeats; b += 4) {
|
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;
|
if (x < -20 || x > w + 20) continue;
|
||||||
ctx.fillText(String(Math.floor(b / 4)), x + 2, h - 3);
|
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); }, []);
|
React.useEffect(() => () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); }, []);
|
||||||
|
|
||||||
const handleSelect = (f) => {
|
const handleSelect = (f) => {
|
||||||
@@ -9933,6 +10179,9 @@ const MediaExplorerPanel = ({ height }) => {
|
|||||||
setAudioBuffer(null);
|
setAudioBuffer(null);
|
||||||
setAudioDuration(0);
|
setAudioDuration(0);
|
||||||
setMidiNotes(null);
|
setMidiNotes(null);
|
||||||
|
setSelStart(null);
|
||||||
|
setSelEnd(null);
|
||||||
|
setPreviewCtxMenu(null);
|
||||||
stopMediaPlayback();
|
stopMediaPlayback();
|
||||||
if (f.kind === 'other') return;
|
if (f.kind === 'other') return;
|
||||||
if (autoPlay) { playSelected(f, token); }
|
if (autoPlay) { playSelected(f, token); }
|
||||||
@@ -10030,25 +10279,17 @@ const MediaExplorerPanel = ({ height }) => {
|
|||||||
<div className="space-y-0.5 font-sans">
|
<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> <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 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}>
|
{/* FAVORITED */}
|
||||||
<i className="fa-solid fa-computer text-[11px] text-slate-600"></i> My Computer
|
<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'}`}
|
||||||
</div>
|
onClick={() => { openFavorited(); setFavoritedExpanded(!favoritedExpanded); }}>
|
||||||
{folder === 'computer' && computerPath !== 'favorited' && computerRoots && (
|
<i className="fa-solid fa-star text-amber-500 text-[11px] font-bold"></i> Favorited
|
||||||
<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
|
|
||||||
</div>
|
</div>
|
||||||
{favoritedExpanded && (
|
{favoritedExpanded && (
|
||||||
<div className="pl-3 space-y-0.5">
|
<div className="pl-3 space-y-0.5">
|
||||||
{favorites.map((fav, fi) => (
|
{favorites.map((fav, fi) => (
|
||||||
<div key={fav.path + 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"
|
className="flex items-center gap-1 px-1 py-0.5 cursor-pointer rounded-sm hover:bg-amber-100 text-slate-800"
|
||||||
style={{ paddingLeft: 24 }}
|
style={{ paddingLeft: 20 }}
|
||||||
onClick={() => openFavorite(fav)}
|
onClick={() => openFavorite(fav)}
|
||||||
onContextMenu={e => { e.preventDefault(); e.stopPropagation(); setFavContext(Object.assign({}, fav, { x: e.clientX, y: e.clientY })); }}>
|
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>
|
<i className="fa-solid fa-folder text-[#d9a752] shrink-0"></i>
|
||||||
@@ -10062,6 +10303,16 @@ const MediaExplorerPanel = ({ height }) => {
|
|||||||
</div>
|
</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')}>
|
<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
|
<i className="fa-solid fa-folder-open text-[#d9a752]"></i> Media Library
|
||||||
</div>
|
</div>
|
||||||
@@ -10251,7 +10502,65 @@ const MediaExplorerPanel = ({ height }) => {
|
|||||||
{/* CANVAS + METADATA */}
|
{/* CANVAS + METADATA */}
|
||||||
<div className="flex items-stretch gap-2 my-1 flex-1 min-h-0">
|
<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">
|
<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>
|
||||||
<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">
|
<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 ? (
|
{selected ? (
|
||||||
@@ -12176,13 +12485,43 @@ const App = () => {
|
|||||||
};
|
};
|
||||||
const handleSubTabPaste = tabId => {
|
const handleSubTabPaste = tabId => {
|
||||||
const st = subTabsRef.current.find(s => s.id === tabId);
|
const st = subTabsRef.current.find(s => s.id === tabId);
|
||||||
if (!st || !st.buffer) return;
|
if (!st) return;
|
||||||
if (!clipboardRef.current || !clipboardRef.current.buffer) {
|
|
||||||
showToast('Clipboard trống.', 'warning');
|
// 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;
|
return;
|
||||||
}
|
}
|
||||||
const ctx = getAudioContext();
|
const ctx = getAudioContext();
|
||||||
const clipBuf = clipboardRef.current.buffer;
|
const clipBuf = clip.buffer;
|
||||||
const sr = st.buffer.sampleRate;
|
const sr = st.buffer.sampleRate;
|
||||||
const data = st.buffer.getChannelData(0);
|
const data = st.buffer.getChannelData(0);
|
||||||
const insertTime = st.currentTime || 0;
|
const insertTime = st.currentTime || 0;
|
||||||
@@ -13730,10 +14069,74 @@ const App = () => {
|
|||||||
contextMenuDelete();
|
contextMenuDelete();
|
||||||
};
|
};
|
||||||
const doPaste = (targetTrackId, pasteTime) => {
|
const doPaste = (targetTrackId, pasteTime) => {
|
||||||
if (!clipboardRef.current || !clipboardRef.current.buffer) {
|
const clip = clipboardRef.current || window.globalStudioClipboard;
|
||||||
|
if (!clip) {
|
||||||
showToast('Clipboard trống.', 'warning');
|
showToast('Clipboard trống.', 'warning');
|
||||||
return null;
|
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 {
|
const {
|
||||||
buffer: clipBuffer,
|
buffer: clipBuffer,
|
||||||
name,
|
name,
|
||||||
@@ -13743,7 +14146,7 @@ const App = () => {
|
|||||||
sampleRate,
|
sampleRate,
|
||||||
channels,
|
channels,
|
||||||
speed
|
speed
|
||||||
} = clipboardRef.current;
|
} = clip;
|
||||||
const ctx = getAudioContext();
|
const ctx = getAudioContext();
|
||||||
const targetTrack = activeTracks.find(t => t.id === targetTrackId);
|
const targetTrack = activeTracks.find(t => t.id === targetTrackId);
|
||||||
const newClip = {
|
const newClip = {
|
||||||
@@ -22664,7 +23067,8 @@ const App = () => {
|
|||||||
})))), /*#__PURE__*/React.createElement("div", {
|
})))), /*#__PURE__*/React.createElement("div", {
|
||||||
className: "flex-1 min-h-0 overflow-hidden bg-[#262626]"
|
className: "flex-1 min-h-0 overflow-hidden bg-[#262626]"
|
||||||
}, /*#__PURE__*/React.createElement(MediaExplorerPanel, {
|
}, /*#__PURE__*/React.createElement(MediaExplorerPanel, {
|
||||||
height: mediaExplorerPanelHeight
|
height: mediaExplorerPanelHeight,
|
||||||
|
clipboardRef: clipboardRef
|
||||||
}))));
|
}))));
|
||||||
})(), /*#__PURE__*/React.createElement("div", {
|
})(), /*#__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"
|
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