fix: sửa lỗi tách bảng điều khiển TCP và timeline của track

This commit is contained in:
2026-07-18 21:17:08 +07:00
parent d14a11342a
commit f52e9b7bef
3 changed files with 383 additions and 230 deletions
+142
View File
@@ -0,0 +1,142 @@
# Bug Fix Specification: Resolving Critical Row Desynchronization
This document analyzes the root cause and provides a permanent structural solution to eliminate the vertical row desynchronization and internal horizontal scrolling artifacts occurring between the left Track Control Panel (TCP) and the right waveform lanes, based on the real-world visual analysis
---
## 1. Visual Symptom Analysis
The layout engine is suffering from two critical alignment failures indicated by the red arrows:
```text
[ LEFT COLUMN - TCP PANEL ] [ RIGHT COLUMN - TIMELINE GRID ]
┌──────────────────────────────┐ ┌──────────────────────────────────────────────┐
│ ... Track 05, 06 (Aligned) │ ══════════ │ Waveform 05, 06 (Aligned) │
├──────────────────────────────┤ ├──────────────────────────────────────────────┤
│ 07 Track 3 (Channel Header) │ [MISALIGNED]│ [EMPTY BLACK DEAD SPACE] (Lower red arrow) │
│ [Junk horizontal scrollbar] │ ◄────────── │ ◄── Caused by Waveform 07 dropping height to 0│
│ (Upper red arrow) │ ├──────────────────────────────────────────────┤
├──────────────────────────────┤ │ Waveform 07 (Pushed down to Track 08's row) │
│ 08 Track 3 │ ══════════ │ ... │
└──────────────────────────────┘ └──────────────────────────────────────────────┘
```
### 1.1. Defect Index 1: Spurious Internal Horizontal Scrollbar (Upper Red Arrow)
* **Symptom:** A small gray horizontal scrollbar emerges directly beneath Track 07 within the left TCP column.
* **Root Cause:** The container wrapper for the left TCP column enforces a rigid bounding layout (`fixed width` or missing an explicit `overflow-x: hidden` safety attribute). When inner structural components (such as long text labels, Mute/Solo clusters, or upload file actions) expand horizontally, the browser generates a local scrollbar. This automatically inflates the effective physical height of the left Track 07 by roughly $12\text{ px} \rightarrow 16\text{ px}$.
### 1.2. Defect Index 2: Vertical Row Desynchronization & Dead Black Space (Lower Red Arrow)
* **Symptom:** On the right column (Timeline), a massive horizontal empty black gap disrupts the grid layout where Waveform 07 ought to sit. Consequently, all matching waveforms for Track 07 and Track 08 are offset downward, falling entirely out of phase with their corresponding control headers on the left.
* **Root Cause:** The system evaluates the target height ($H$) of the left TCP container independently from the right Waveform Lane. When the left Track 07 column expands due to the rendering of the junk scrollbar, the right canvas lane does not dynamically adapt. This triggers a cumulative pixel error along the vertical axis ($Y$), producing progressive, severe desynchronization downstream (the lower the tracks sit, the worse the alignment drifts).
---
## 2. Structural Correction Blueprint
To prevent this layout defect from recurring—especially when porting the interface to desktop Python using PyQt/PySide—the system must completely decouple from independent height calculations and embrace a **Unified Row Layout** model.
### 2.1. Standardized HTML / Tailwind CSS Architecture Blueprint
Instead of splitting the page tree layout into two isolated columns (`Col1: [TCP1, TCP2, TCP3]` and `Col2: [Wave1, Wave2, Wave3]`), the application must encapsulate each matching TCP and Waveform pair within a shared, unified row wrapper:
```html
<!-- Wrap the entire track stack inside a single vertical scroll container -->
<div class="flex-1 overflow-y-auto bg-[#111111]">
<!-- UNIFIED TRACK ROW (Enforces strict shared-row geometry) -->
<div class="flex h-[96px] w-full min-w-max border-b border-[#141414]">
<!-- Left Side: TCP (Fixed width; absolute containment of horizontal overflows) -->
<div class="w-[300px] shrink-0 bg-[#262626] p-2.5 overflow-hidden flex flex-col justify-between">
<!-- TCP Control Content Elements Go Here -->
</div>
<!-- Right Side: Waveform Lane (Flexibly fills remaining browser canvas viewport) -->
<div class="flex-1 relative overflow-hidden">
<!-- Waveform Canvas Engine -->
</div>
</div>
<!-- Add additional track rows duplicating the exact structural envelope above... -->
</div>
```
### Architectural Advantages:
* Because both the control deck and the waveform graphic share an identical row container wrapper (`flex row` or `grid row`), any arbitrary height fluctuation on the TCP side (due to text zoom-in behaviors or overflow glitches) will instantly force the right waveform canvas view to mirror the $100\%$ row scale change.
* Only one master vertical scrollbar exists on the outer perimeter window to slide all rows simultaneously.
---
## 3. Prevention Guidelines for Python Porting (PyQt6 / PySide6)
If you attempt to design this DAW interface inside a containerized Python Docker application by leveraging two separate `QScrollArea` nodes for the TCP track column and the timeline canvas, you will inevitably trigger this row alignment defect due to timing delays or scroll tracking errors (`scrollEvent` mismatch).
### 3.1. Secure Layout Architecture Using Python QWidget
Implement a nested widget strategy to securely bind the horizontal axes together at all times:
```python
from PyQt6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QScrollArea
from PyQt6.QtCore import Qt
class ProDAWArrangeWindow(QWidget):
def __init__(self):
super().__init__()
self.main_layout = QVBoxLayout(self)
self.main_layout.setContentsMargins(0, 0, 0, 0)
self.main_layout.setSpacing(0)
# 1. Instantiate a single, unified QScrollArea for the absolute Workspace
self.workspace_scroll = QScrollArea()
self.workspace_scroll.setWidgetResizable(True)
self.workspace_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOn)
self.workspace_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
# 2. Outer container hosting the multi-channel rows
self.container_widget = QWidget()
self.container_layout = QVBoxLayout(self.container_widget)
self.container_layout.setContentsMargins(0, 0, 0, 0)
self.container_layout.setSpacing(0)
self.container_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
self.workspace_scroll.setWidget(self.container_widget)
self.main_layout.addWidget(self.workspace_scroll)
def add_track(self, track_id: str):
"""
Appends a unified track row utilizing QHBoxLayout with a rigid physical height constraint.
"""
track_row = QWidget()
track_row.setFixedHeight(96) # Lock physical pixel height constraints for the row
row_layout = QHBoxLayout(track_row)
row_layout.setContentsMargins(0, 0, 0, 0)
row_layout.setSpacing(0)
# Left Panel: Track Control Panel (Enforces a strict rigid width constraint)
tcp_widget = QWidget()
tcp_widget.setFixedWidth(300)
# tcp_widget.setup_ui(...)
# Right Panel: Waveform Canvas Viewport
waveform_widget = QWidget()
# waveform_widget.setup_canvas(...)
# Combine both widgets into the layout block to guarantee row lock
row_layout.addWidget(tcp_widget)
row_layout.addWidget(waveform_widget)
self.container_layout.addWidget(track_row)
```
### 3.2. Concrete Advantages for Python Docker Environments
* **Zero Alignment Variance:** Row alignment is entirely guaranteed at the OS-level layout engine, bypassing desynchronization issues caused by asynchronous rendering cycles or UI latency.
* **Streamlined UI Pipelines:** The environment tracking mechanisms hook into a single scrollbar, reducing memory usage and optimizing the drawing threads for the Docker X11 Server or WebRTC stream pipelines when projecting graphics down to the client.
View File
+72 -61
View File
@@ -1680,7 +1680,7 @@
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));
const time = Math.max(0, Math.min(maxDuration, (mouseX - 300) / zoom));
setLocalSelectionEnd(time);
};
const handleMouseUp = () => {
@@ -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,10 +2766,17 @@
)}
{/* ── 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">
<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>
{/* Timeline Toolbar */}
@@ -2793,7 +2800,6 @@
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"/>
@@ -2834,11 +2840,44 @@
<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>
<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,18 +2897,23 @@
</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
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'
}`}
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">
@@ -2936,46 +2980,10 @@
</div>
</div>
</div>
);
})}
</div>
</div>
{/* Timeline Right Canvas */}
{/* Right Column: Waveform Lane */}
<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"
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> Ct
</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,11 +3039,12 @@
<i data-lucide="plus" className="w-3.5 h-3.5"></i> Kéo clip xung đây hoc Click đ to Track mi
</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` }}
<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"
>
@@ -3051,13 +3063,12 @@
{/* Playhead */}
<div className="absolute top-0 bottom-0 w-[2px] bg-[#ef4444] z-20 pointer-events-none"
style={{ left: `${playheadLeftPos}px` }}>
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>
</div>
{/* ── Footer ── */}
<footer className="h-44 bg-[#1c1c1c] border-t border-zinc-900 p-4 grid grid-cols-1 md:grid-cols-12 gap-4 text-xs shrink-0 select-none" style={{ display: activeTab !== 'main' ? 'none' : '' }}>