bug: lỗi scrollbar ngang vẫn tồn tại
This commit is contained in:
@@ -2918,7 +2918,7 @@
|
||||
{/* 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'}`}
|
||||
className={`w-[300px] sticky left-0 z-30 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">
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
<div
|
||||
className="flex-1 flex overflow-hidden select-none daw-bg relative"
|
||||
style={{ display: activeTab !== 'main' ? 'none' : '' }}
|
||||
>
|
||||
{/* ══ LEFT COLUMN: TCP PANEL ══ */}
|
||||
<div
|
||||
ref={tcpContainerRef}
|
||||
className="w-[300px] shrink-0 relative z-20 bg-[#262626] overflow-hidden flex flex-col border-r border-zinc-900"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
{/* [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>
|
||||
<span className="text-xs font-semibold text-zinc-300">Tempo Track</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
type="number"
|
||||
value={bpm}
|
||||
onChange={(e) => setBpm(e.target.value)}
|
||||
onBlur={() => localStorage.setItem('studio_bpm', bpm)}
|
||||
className="w-12 bg-zinc-800 border border-zinc-700 rounded text-[10px] text-zinc-300 text-center font-mono focus:outline-none focus:border-purple-500"
|
||||
min="40"
|
||||
max="300"
|
||||
/>
|
||||
<span className="text-[9px] text-zinc-500">BPM</span>
|
||||
</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} 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 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>
|
||||
|
||||
{/* Right Column: Waveform Lane */}
|
||||
<div
|
||||
className="flex-1 relative overflow-hidden h-full"
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
if (e.dataTransfer.files[0]) {
|
||||
loadFileOnTrack(track.id, e.dataTransfer.files[0]);
|
||||
}
|
||||
}}
|
||||
onMouseEnter={() => setHoveredTrackId(track.id)}
|
||||
>
|
||||
<WaveformLane track={track} zoom={zoom} timelineWidth={timelineWidth}
|
||||
onSelectRange={handleSelectRange} onPlayheadSet={setCurrentTime}
|
||||
isSelected={isSelected} onSelectTrack={setSelectedTrackId} markers={track.markers}
|
||||
onTrackLaneMouseDown={handleTrackLaneMouseDown}
|
||||
onContextMenu={handleContextMenu}
|
||||
onClipDragStart={handleClipDragStart}
|
||||
activeTool={activeTool}
|
||||
onSplitTrackAtTime={handleSplitTrackAtTime}
|
||||
snapValue={snapValue}
|
||||
bpm={bpm}
|
||||
selectionMode={selectionMode}
|
||||
localSelectionTrackId={localSelectionTrackId}
|
||||
localSelLeft={localSelectionStart !== null && localSelectionEnd !== null ? Math.min(localSelectionStart, localSelectionEnd) : null}
|
||||
localSelRight={localSelectionStart !== null && localSelectionEnd !== null ? Math.max(localSelectionStart, localSelectionEnd) : null} />
|
||||
|
||||
{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/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>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 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="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();
|
||||
setHoveredTrackId(newId);
|
||||
}
|
||||
}}
|
||||
onClick={addNewTrack}
|
||||
>
|
||||
<span className="flex items-center gap-1 text-zinc-400">
|
||||
<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-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>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
{/* ── Footer ── */}
|
||||
Reference in New Issue
Block a user