fix: sửa lỗi tách bảng điều khiển TCP và timeline của track
This commit is contained in:
+241
-230
@@ -1671,32 +1671,32 @@
|
||||
localDragStartTimeRef.current = time;
|
||||
};
|
||||
|
||||
// Document-level mousemove/mouseup for local selection drag
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (e) => {
|
||||
if (!localDragInProgressRef.current) return;
|
||||
const wrapper = timelineWrapperRef.current;
|
||||
if (!wrapper) return;
|
||||
const rect = wrapper.getBoundingClientRect();
|
||||
const scrollLeft = wrapper.scrollLeft;
|
||||
const mouseX = e.clientX - rect.left + scrollLeft;
|
||||
const time = Math.max(0, Math.min(maxDuration, mouseX / zoom));
|
||||
setLocalSelectionEnd(time);
|
||||
};
|
||||
const handleMouseUp = () => {
|
||||
if (localDragInProgressRef.current) {
|
||||
localDragInProgressRef.current = false;
|
||||
localDragTrackRef.current = null;
|
||||
localDragStartTimeRef.current = 0;
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [zoom, maxDuration]);
|
||||
// Document-level mousemove/mouseup for local selection drag
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (e) => {
|
||||
if (!localDragInProgressRef.current) return;
|
||||
const wrapper = timelineWrapperRef.current;
|
||||
if (!wrapper) return;
|
||||
const rect = wrapper.getBoundingClientRect();
|
||||
const scrollLeft = wrapper.scrollLeft;
|
||||
const mouseX = e.clientX - rect.left + scrollLeft;
|
||||
const time = Math.max(0, Math.min(maxDuration, (mouseX - 300) / zoom));
|
||||
setLocalSelectionEnd(time);
|
||||
};
|
||||
const handleMouseUp = () => {
|
||||
if (localDragInProgressRef.current) {
|
||||
localDragInProgressRef.current = false;
|
||||
localDragTrackRef.current = null;
|
||||
localDragStartTimeRef.current = 0;
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [zoom, maxDuration]);
|
||||
|
||||
const draggedClipRef = useRef(null);
|
||||
draggedClipRef.current = draggedClip;
|
||||
@@ -1742,7 +1742,7 @@
|
||||
const rect = wrapper.getBoundingClientRect();
|
||||
const scrollLeft = wrapper.scrollLeft;
|
||||
const mouseX = e.clientX - rect.left + scrollLeft;
|
||||
const time = mouseX / zoom;
|
||||
const time = (mouseX - 300) / zoom;
|
||||
const rawStart = Math.max(0, time - drag.clickOffset);
|
||||
const newStart = snapTime(rawStart, snapValueRef.current, bpmRef.current);
|
||||
|
||||
@@ -2766,79 +2766,118 @@
|
||||
)}
|
||||
|
||||
{/* ── Workspace ── */}
|
||||
<div className="flex-1 flex overflow-y-auto select-none daw-bg relative" style={{ display: activeTab !== 'main' ? 'none' : '' }}>
|
||||
{/* TCP Left Column */}
|
||||
<div className="w-[300px] flex flex-col daw-panel border-r border-zinc-900 z-10 select-none shrink-0">
|
||||
<div className="h-8 border-b border-zinc-900 bg-[#242424] flex items-center px-2 justify-between sticky top-0 z-30 select-none">
|
||||
<span className="text-[10px] font-bold text-zinc-500 uppercase shrink-0 mr-2">Kênh</span>
|
||||
|
||||
{/* Timeline Toolbar */}
|
||||
<div className="flex items-center gap-0.5 bg-zinc-900 border border-zinc-800 rounded px-1 py-0.5 shadow-sm">
|
||||
<button
|
||||
onClick={() => { setActiveTool('select'); showToast('Công cụ chọn (Select Tool) đã kích hoạt.', 'info'); }}
|
||||
className={`p-1 rounded transition text-xs flex items-center justify-center ${activeTool === 'select' ? 'bg-cyan-950/80 text-cyan-400 border border-cyan-800/50 font-bold' : 'text-zinc-400 hover:bg-zinc-800 border border-transparent'}`}
|
||||
title="Select Tool: Chọn khoảng, Đặt playhead (Giữ Alt kéo để di chuyển nhanh clip)"
|
||||
>
|
||||
<i data-lucide="mouse-pointer" className="w-3 h-3"></i>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setActiveTool('grab'); showToast('Công cụ di chuyển (Hand Tool) đã kích hoạt.', 'info'); }}
|
||||
className={`p-1 rounded transition text-xs flex items-center justify-center ${activeTool === 'grab' ? 'bg-cyan-950/80 text-cyan-400 border border-cyan-700/50 font-bold' : 'text-zinc-400 hover:bg-zinc-800 border border-transparent'}`}
|
||||
title="Grab Tool: Click kéo trực tiếp để di chuyển clip"
|
||||
>
|
||||
<i data-lucide="hand" className="w-3 h-3"></i>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setActiveTool('razor'); showToast('Công cụ chia đoạn (Razor Tool) đã kích hoạt.', 'info'); }}
|
||||
className={`p-1 rounded transition text-xs flex items-center justify-center ${activeTool === 'razor' ? 'bg-cyan-950/80 text-cyan-400 border border-cyan-700/50 font-bold' : 'text-zinc-400 hover:bg-zinc-800 border border-transparent'}`}
|
||||
title="Razor Tool: Click trên clip để chia nhỏ tại điểm click"
|
||||
>
|
||||
{/* Biểu tượng lưỡi dao cạo - dùng custom SVG hoặc scissors */}
|
||||
<svg className="w-3 h-3 text-orange-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M6 3h12a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z"/>
|
||||
<path d="M4 9h16l-3 9H7z"/>
|
||||
<circle cx="12" cy="6" r="1"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div
|
||||
ref={timelineWrapperRef}
|
||||
className="flex-1 overflow-x-auto overflow-y-auto select-none daw-bg relative"
|
||||
style={{ display: activeTab !== 'main' ? 'none' : '' }}
|
||||
>
|
||||
<div style={{ width: `calc(300px + ${timelineWidth}px)` }} className="relative flex flex-col min-h-full">
|
||||
|
||||
{/* [ZONE A] Header Row */}
|
||||
<div className="sticky top-0 z-40 flex h-8 border-b border-zinc-900 bg-[#242424] shrink-0">
|
||||
{/* Left Side: Channels & Tools Header */}
|
||||
<div className="w-[300px] sticky left-0 z-50 bg-[#242424] border-r border-zinc-900 flex items-center px-2 justify-between select-none shrink-0">
|
||||
<span className="text-[10px] font-bold text-zinc-500 uppercase shrink-0 mr-2">Kênh</span>
|
||||
|
||||
{/* Separator */}
|
||||
{/* Timeline Toolbar */}
|
||||
<div className="flex items-center gap-0.5 bg-zinc-900 border border-zinc-800 rounded px-1 py-0.5 shadow-sm">
|
||||
<button
|
||||
onClick={() => { setActiveTool('select'); showToast('Công cụ chọn (Select Tool) đã kích hoạt.', 'info'); }}
|
||||
className={`p-1 rounded transition text-xs flex items-center justify-center ${activeTool === 'select' ? 'bg-cyan-950/80 text-cyan-400 border border-cyan-800/50 font-bold' : 'text-zinc-400 hover:bg-zinc-800 border border-transparent'}`}
|
||||
title="Select Tool: Chọn khoảng, Đặt playhead (Giữ Alt kéo để di chuyển nhanh clip)"
|
||||
>
|
||||
<i data-lucide="mouse-pointer" className="w-3 h-3"></i>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setActiveTool('grab'); showToast('Công cụ di chuyển (Hand Tool) đã kích hoạt.', 'info'); }}
|
||||
className={`p-1 rounded transition text-xs flex items-center justify-center ${activeTool === 'grab' ? 'bg-cyan-950/80 text-cyan-400 border border-cyan-700/50 font-bold' : 'text-zinc-400 hover:bg-zinc-800 border border-transparent'}`}
|
||||
title="Grab Tool: Click kéo trực tiếp để di chuyển clip"
|
||||
>
|
||||
<i data-lucide="hand" className="w-3 h-3"></i>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setActiveTool('razor'); showToast('Công cụ chia đoạn (Razor Tool) đã kích hoạt.', 'info'); }}
|
||||
className={`p-1 rounded transition text-xs flex items-center justify-center ${activeTool === 'razor' ? 'bg-cyan-950/80 text-cyan-400 border border-cyan-700/50 font-bold' : 'text-zinc-400 hover:bg-zinc-800 border border-transparent'}`}
|
||||
title="Razor Tool: Click trên clip để chia nhỏ tại điểm click"
|
||||
>
|
||||
<svg className="w-3 h-3 text-orange-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M6 3h12a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z"/>
|
||||
<path d="M4 9h16l-3 9H7z"/>
|
||||
<circle cx="12" cy="6" r="1"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="w-[1px] h-3 bg-zinc-800 mx-0.5"></div>
|
||||
|
||||
{/* Quick Actions (Glue, Cut, Copy, Paste) */}
|
||||
<button
|
||||
onClick={handleGlueTracks}
|
||||
className="p-0.5 rounded text-zinc-400 hover:bg-zinc-800 hover:text-purple-400 transition"
|
||||
title="Glue: Gộp track hiện tại với track liền dưới"
|
||||
>
|
||||
<i data-lucide="link" className="w-3 h-3"></i>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCutTrack}
|
||||
className="p-0.5 rounded text-zinc-400 hover:bg-zinc-800 hover:text-red-400 transition"
|
||||
title="Cut Clip (Ctrl+X)"
|
||||
>
|
||||
<i data-lucide="scissors" className="w-3 h-3"></i>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCopyTrack}
|
||||
className="p-0.5 rounded text-zinc-400 hover:bg-zinc-800 hover:text-blue-400 transition"
|
||||
title="Copy Clip (Ctrl+C)"
|
||||
>
|
||||
<i data-lucide="copy" className="w-3 h-3"></i>
|
||||
</button>
|
||||
<button
|
||||
onClick={handlePasteTrack}
|
||||
className="p-0.5 rounded text-zinc-400 hover:bg-zinc-800 hover:text-emerald-400 transition"
|
||||
title="Paste Clip (Ctrl+V)"
|
||||
>
|
||||
<i data-lucide="clipboard" className="w-3 h-3"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Snap Section */}
|
||||
<div className="w-[1px] h-3 bg-zinc-800 mx-0.5"></div>
|
||||
|
||||
{/* Quick Actions (Glue, Cut, Copy, Paste) */}
|
||||
<button
|
||||
onClick={handleGlueTracks}
|
||||
className="p-0.5 rounded text-zinc-400 hover:bg-zinc-800 hover:text-purple-400 transition"
|
||||
title="Glue: Gộp track hiện tại với track liền dưới"
|
||||
>
|
||||
<i data-lucide="link" className="w-3 h-3"></i>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCutTrack}
|
||||
className="p-0.5 rounded text-zinc-400 hover:bg-zinc-800 hover:text-red-400 transition"
|
||||
title="Cut Clip (Ctrl+X)"
|
||||
>
|
||||
<i data-lucide="scissors" className="w-3 h-3"></i>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCopyTrack}
|
||||
className="p-0.5 rounded text-zinc-400 hover:bg-zinc-800 hover:text-blue-400 transition"
|
||||
title="Copy Clip (Ctrl+C)"
|
||||
>
|
||||
<i data-lucide="copy" className="w-3 h-3"></i>
|
||||
</button>
|
||||
<button
|
||||
onClick={handlePasteTrack}
|
||||
className="p-0.5 rounded text-zinc-400 hover:bg-zinc-800 hover:text-emerald-400 transition"
|
||||
title="Paste Clip (Ctrl+V)"
|
||||
>
|
||||
<i data-lucide="clipboard" className="w-3 h-3"></i>
|
||||
</button>
|
||||
<div className="flex items-center gap-1 pl-0.5 select-none">
|
||||
<span className="text-[9px] text-zinc-500 font-bold uppercase">Snap</span>
|
||||
<select
|
||||
value={snapValue}
|
||||
onChange={(e) => setSnapValue(e.target.value)}
|
||||
className="bg-zinc-850 text-zinc-300 text-[10px] px-1 py-0.5 rounded border border-zinc-800 focus:outline-none focus:border-cyan-550 font-mono"
|
||||
>
|
||||
<option value="free">Free</option>
|
||||
<option value="1">1</option>
|
||||
<option value="1/2">1/2</option>
|
||||
<option value="1/4">1/4</option>
|
||||
<option value="1/8">1/8</option>
|
||||
<option value="1/16">1/16</option>
|
||||
<option value="1/32">1/32</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{/* Right Side: Time Ruler */}
|
||||
<div ref={rulerRef} className="flex-1 relative h-full flex items-center select-none cursor-ew-resize overflow-hidden" onMouseDown={handleRulerMouseDown}>
|
||||
{Array.from({ length: Math.ceil(maxDuration) }).map((_, i) => {
|
||||
const sec = i;
|
||||
const x = sec * zoom;
|
||||
return (
|
||||
<div key={i} className="absolute h-full border-l border-zinc-800 pl-1 pt-1 text-[9px] font-mono text-zinc-500 pointer-events-none" style={{ left: `${x}px` }}>
|
||||
{formatTime(sec)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col divide-y divide-[#141414]">
|
||||
{/* Tempo Track TCP - LOOP_EDITOR_2.md §6 */}
|
||||
<div className="h-[40px] p-2 flex flex-col justify-between bg-[#1a1a2e] border-l-4 border-purple-500">
|
||||
{/* [ZONE A] Tempo Track Row */}
|
||||
<div className="sticky top-8 z-30 flex h-[40px] border-b border-purple-500/30 bg-[#1a1a2e] shrink-0">
|
||||
{/* Left Side: Tempo TCP */}
|
||||
<div className="w-[300px] sticky left-0 z-50 bg-[#1a1a2e] border-r border-zinc-900 p-2 flex flex-col justify-between border-l-4 border-purple-500 shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] font-bold text-purple-400 font-mono">TM</span>
|
||||
@@ -2858,124 +2897,93 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Right Side: Tempo Lane */}
|
||||
<div className="flex-1 relative h-full overflow-hidden bg-[#1a1a2e]">
|
||||
<TempoTrackLane bpm={parseInt(bpm) || 120} zoom={zoom} timelineWidth={timelineWidth}
|
||||
onPlayheadSet={setCurrentTime} snapValue={snapValue} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* [ZONE B] Dynamic Track List Workspace */}
|
||||
<div className="flex-1 flex flex-col divide-y divide-[#141414] relative bg-[#111111] min-h-full">
|
||||
{tracks.map((track, idx) => {
|
||||
const isSelected = selectedTrackId === track.id;
|
||||
return (
|
||||
<div
|
||||
key={track.id}
|
||||
onClick={() => setSelectedTrackId(track.id)}
|
||||
className={`h-[96px] p-2.5 flex flex-col justify-between transition-all relative cursor-pointer ${
|
||||
isSelected
|
||||
? 'bg-zinc-800/80 border-l-4 border-cyan-500 pl-1.5 ring-1 ring-cyan-500/30'
|
||||
: 'hover:bg-zinc-800/30'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] font-bold text-zinc-500 font-mono">{(idx+1).toString().padStart(2, '0')}</span>
|
||||
<div className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: track.color }} />
|
||||
<span className="text-xs font-semibold text-zinc-300 truncate max-w-[120px]" title={track.name}>
|
||||
{track.name}
|
||||
</span>
|
||||
<div key={track.id} className={`h-[96px] flex hover:bg-zinc-850/5 transition-colors ${isSelected ? 'bg-zinc-800/10' : ''}`}>
|
||||
{/* Left Column: TCP */}
|
||||
<div
|
||||
onClick={() => setSelectedTrackId(track.id)}
|
||||
className={`w-[300px] sticky left-0 z-10 p-2.5 flex flex-col justify-between bg-[#1e1e1e] border-r border-zinc-900 shrink-0 cursor-pointer border-l-4 overflow-hidden ${isSelected ? 'border-cyan-500 bg-[#252525]' : 'border-transparent hover:bg-zinc-800/20'}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] font-bold text-zinc-500 font-mono">{(idx+1).toString().padStart(2, '0')}</span>
|
||||
<div className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: track.color }} />
|
||||
<span className="text-xs font-semibold text-zinc-300 truncate max-w-[120px]" title={track.name}>
|
||||
{track.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); toggleTrackMute(track.id); }}
|
||||
className={`px-1.5 py-0.5 text-[10px] rounded font-mono font-bold border transition ${
|
||||
track.muted
|
||||
? 'bg-red-950 text-red-400 border-red-700'
|
||||
: 'bg-zinc-800 text-zinc-400 border-transparent hover:text-zinc-200'
|
||||
}`}
|
||||
>M</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); toggleTrackSoloEvaluate(track.id); }}
|
||||
className={`px-1.5 py-0.5 text-[10px] rounded font-mono font-bold border transition ${
|
||||
soloedTrackId === track.id || track.solo
|
||||
? 'bg-amber-950 text-amber-400 border-amber-600'
|
||||
: 'bg-zinc-800 text-zinc-400 border-transparent hover:text-zinc-200'
|
||||
}`}
|
||||
title="Solo nghe thử"
|
||||
>S</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex items-center gap-1 text-[9px] text-zinc-400" onClick={e => e.stopPropagation()}>
|
||||
<span className="font-semibold uppercase text-[8px] text-zinc-500">Mô phỏng:</span>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); toggleTrackMute(track.id); }}
|
||||
className={`px-1.5 py-0.5 text-[10px] rounded font-mono font-bold border transition ${
|
||||
track.muted
|
||||
? 'bg-red-950 text-red-400 border-red-700'
|
||||
: 'bg-zinc-800 text-zinc-400 border-transparent hover:text-zinc-200'
|
||||
}`}
|
||||
>M</button>
|
||||
onClick={() => generateSynthToTrack(track.id, 'kick')}
|
||||
className="px-1 bg-zinc-800 hover:bg-zinc-700 rounded border border-zinc-700"
|
||||
>Kick Drum</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); toggleTrackSoloEvaluate(track.id); }}
|
||||
className={`px-1.5 py-0.5 text-[10px] rounded font-mono font-bold border transition ${
|
||||
soloedTrackId === track.id || track.solo
|
||||
? 'bg-amber-950 text-amber-400 border-amber-600'
|
||||
: 'bg-zinc-800 text-zinc-400 border-transparent hover:text-zinc-200'
|
||||
}`}
|
||||
title="Solo nghe thử"
|
||||
>S</button>
|
||||
onClick={() => generateSynthToTrack(track.id, 'synth')}
|
||||
className="px-1 bg-zinc-800 hover:bg-zinc-700 rounded border border-zinc-700"
|
||||
>Arpeggiator</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<VolumeKnob value={track.volume} onChange={(v) => updateTrackVolume(track.id, v)} />
|
||||
<span className="text-[10px] font-mono text-zinc-500">Gain: {Math.round(track.volume * 100)}%</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<input
|
||||
type="file"
|
||||
id={`upload-${track.id}`}
|
||||
accept="audio/*"
|
||||
className="hidden"
|
||||
onChange={(e) => loadFileOnTrack(track.id, e.target.files[0])}
|
||||
/>
|
||||
<label
|
||||
htmlFor={`upload-${track.id}`}
|
||||
className="px-2 py-1 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded text-[10px] flex items-center gap-1 cursor-pointer transition border border-zinc-700"
|
||||
>
|
||||
<i data-lucide="upload" className="w-3 h-3"></i> Tải file
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 text-[9px] text-zinc-400" onClick={e => e.stopPropagation()}>
|
||||
<span className="font-semibold uppercase text-[8px] text-zinc-500">Mô phỏng:</span>
|
||||
<button
|
||||
onClick={() => generateSynthToTrack(track.id, 'kick')}
|
||||
className="px-1 bg-zinc-800 hover:bg-zinc-700 rounded border border-zinc-700"
|
||||
>Kick Drum</button>
|
||||
<button
|
||||
onClick={() => generateSynthToTrack(track.id, 'synth')}
|
||||
className="px-1 bg-zinc-800 hover:bg-zinc-700 rounded border border-zinc-700"
|
||||
>Arpeggiator</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<VolumeKnob value={track.volume} onChange={(v) => updateTrackVolume(track.id, v)} />
|
||||
<span className="text-[10px] font-mono text-zinc-500">Gain: {Math.round(track.volume * 100)}%</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<input
|
||||
type="file"
|
||||
id={`upload-${track.id}`}
|
||||
accept="audio/*"
|
||||
className="hidden"
|
||||
onChange={(e) => loadFileOnTrack(track.id, e.target.files[0])}
|
||||
/>
|
||||
<label
|
||||
htmlFor={`upload-${track.id}`}
|
||||
className="px-2 py-1 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded text-[10px] flex items-center gap-1 cursor-pointer transition border border-zinc-700"
|
||||
>
|
||||
<i data-lucide="upload" className="w-3 h-3"></i> Tải file
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timeline Right Canvas */}
|
||||
<div
|
||||
ref={timelineWrapperRef}
|
||||
className="flex-1 overflow-x-auto relative bg-[#111111]"
|
||||
>
|
||||
<div style={{ width: `${timelineWidth}px` }} className="relative flex flex-col h-full">
|
||||
|
||||
{/* Ruler - LOOP_MAKER.md VÙNG A: Global Selection hitbox */}
|
||||
<div ref={rulerRef}
|
||||
className="h-8 border-b border-zinc-900 bg-[#242424] sticky top-0 z-30 flex items-center select-none shrink-0 cursor-ew-resize"
|
||||
onMouseDown={handleRulerMouseDown}
|
||||
>
|
||||
{Array.from({ length: Math.ceil(maxDuration) }).map((_, i) => {
|
||||
const sec = i;
|
||||
const x = sec * zoom;
|
||||
return (
|
||||
<div key={i} className="absolute h-full border-l border-zinc-800 pl-1 pt-1 text-[9px] font-mono text-zinc-500 pointer-events-none" style={{ left: `${x}px` }}>
|
||||
{formatTime(sec)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Stacked Lanes */}
|
||||
<div className="flex-1 flex flex-col relative divide-y divide-[#141414]">
|
||||
{/* Tempo Track Lane - LOOP_EDITOR_2.md §6 */}
|
||||
<div className="h-[40px] w-full relative flex-shrink-0 border-b border-purple-500/30">
|
||||
<TempoTrackLane bpm={parseInt(bpm) || 120} zoom={zoom} timelineWidth={timelineWidth}
|
||||
onPlayheadSet={setCurrentTime} snapValue={snapValue} />
|
||||
</div>
|
||||
|
||||
{tracks.map((track) => {
|
||||
const isSelected = selectedTrackId === track.id;
|
||||
return (
|
||||
<div key={track.id} className="h-[96px] w-full relative flex-shrink-0"
|
||||
{/* Right Column: Waveform Lane */}
|
||||
<div
|
||||
className="flex-1 relative overflow-hidden h-full"
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
@@ -3003,19 +3011,22 @@
|
||||
{track.buffer && (
|
||||
<div className="absolute right-2 top-2 flex items-center gap-1 z-10 opacity-70 hover:opacity-100 transition">
|
||||
<button onClick={() => handleSplitTrack(track.id)}
|
||||
className="px-1.5 py-0.5 bg-zinc-900/90 text-zinc-300 rounded text-[9px] flex items-center gap-1 border border-zinc-700/50"
|
||||
className="px-1.5 py-0.5 bg-zinc-900/95 text-zinc-300 rounded text-[9px] flex items-center gap-1 border border-zinc-700/50"
|
||||
title="Cắt đoạn tại Playhead">
|
||||
<i data-lucide="scissors" className="w-2.5 h-2.5 text-cyan-400"></i> Cắt
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Drop zone to create a new track during drag */}
|
||||
{/* Bottom Drop Zone to create new track */}
|
||||
<div className="h-[48px] flex border-t border-dashed border-zinc-800">
|
||||
<div className="w-[300px] sticky left-0 z-10 bg-[#1e1e1e]/50 border-r border-zinc-900 shrink-0"></div>
|
||||
<div
|
||||
className="h-[48px] w-full border-t border-dashed border-zinc-800 flex items-center justify-center text-xs text-zinc-500 hover:bg-zinc-900/20 cursor-pointer select-none shrink-0"
|
||||
className="flex-1 flex items-center justify-center text-xs text-zinc-500 hover:bg-zinc-900/20 cursor-pointer select-none"
|
||||
onMouseEnter={() => {
|
||||
if (draggedClipRef.current) {
|
||||
const newId = addNewTrack();
|
||||
@@ -3028,32 +3039,32 @@
|
||||
<i data-lucide="plus" className="w-3.5 h-3.5"></i> Kéo clip xuống đây hoặc Click để tạo Track mới
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Selection Overlay - only for Global mode (LOOP_EDITOR.md §1.2: local draws on canvas per-track) */}
|
||||
{selectionMode !== 'local' && selLeft !== null && selRight !== null && selRight > selLeft && (
|
||||
<div className="absolute top-8 bottom-0 border-l border-r border-amber-500 bg-amber-500/10 z-20 selection-interactive-box cursor-grab active:cursor-grabbing"
|
||||
style={{ left: `${selLeft * zoom}px`, width: `${(selRight - selLeft) * zoom}px` }}
|
||||
onMouseDown={handleSelectionBodyDragStart}
|
||||
title="Kéo để di chuyển vùng chọn"
|
||||
>
|
||||
<div className="absolute -left-1.5 top-0 bottom-0 w-3 bg-amber-500 hover:bg-amber-400 cursor-ew-resize flex items-center justify-center z-30 transition-colors"
|
||||
onMouseDown={(e) => handleHandleDragStart(e, 'left')}
|
||||
title="Kéo giãn mốc bắt đầu">
|
||||
<div className="w-[1.5px] h-4 bg-zinc-950/70 rounded"></div>
|
||||
</div>
|
||||
<div className="absolute -right-1.5 top-0 bottom-0 w-3 bg-amber-500 hover:bg-amber-400 cursor-ew-resize flex items-center justify-center z-30 transition-colors"
|
||||
onMouseDown={(e) => handleHandleDragStart(e, 'right')}
|
||||
title="Kéo giãn mốc kết thúc">
|
||||
<div className="w-[1.5px] h-4 bg-zinc-950/70 rounded"></div>
|
||||
</div>
|
||||
{/* Selection Overlay - only for Global mode (LOOP_EDITOR.md §1.2: local draws on canvas per-track) */}
|
||||
{selectionMode !== 'local' && selLeft !== null && selRight !== null && selRight > selLeft && (
|
||||
<div className="absolute top-0 bottom-0 border-l border-r border-amber-500 bg-amber-500/10 z-20 selection-interactive-box cursor-grab active:cursor-grabbing"
|
||||
style={{ left: `${selLeft * zoom + 300}px`, width: `${(selRight - selLeft) * zoom}px` }}
|
||||
onMouseDown={handleSelectionBodyDragStart}
|
||||
title="Kéo để di chuyển vùng chọn"
|
||||
>
|
||||
<div className="absolute -left-1.5 top-0 bottom-0 w-3 bg-amber-500 hover:bg-amber-400 cursor-ew-resize flex items-center justify-center z-30 transition-colors"
|
||||
onMouseDown={(e) => handleHandleDragStart(e, 'left')}
|
||||
title="Kéo giãn mốc bắt đầu">
|
||||
<div className="w-[1.5px] h-4 bg-zinc-950/70 rounded"></div>
|
||||
</div>
|
||||
<div className="absolute -right-1.5 top-0 bottom-0 w-3 bg-amber-500 hover:bg-amber-400 cursor-ew-resize flex items-center justify-center z-30 transition-colors"
|
||||
onMouseDown={(e) => handleHandleDragStart(e, 'right')}
|
||||
title="Kéo giãn mốc kết thúc">
|
||||
<div className="w-[1.5px] h-4 bg-zinc-950/70 rounded"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Playhead */}
|
||||
<div className="absolute top-0 bottom-0 w-[2px] bg-[#ef4444] z-20 pointer-events-none"
|
||||
style={{ left: `${playheadLeftPos}px` }}>
|
||||
<div className="w-3 h-3 bg-[#ef4444] rotate-45 transform -translate-x-1/2 -translate-y-1/2 absolute top-0"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Playhead */}
|
||||
<div className="absolute top-0 bottom-0 w-[2px] bg-[#ef4444] z-20 pointer-events-none"
|
||||
style={{ left: `${playheadLeftPos + 300}px` }}>
|
||||
<div className="w-3 h-3 bg-[#ef4444] rotate-45 transform -translate-x-1/2 -translate-y-1/2 absolute top-0"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user