From d14a11342a946d342c3c27d6cb2fc1113dc35c7e Mon Sep 17 00:00:00 2001 From: 3dtours Date: Sat, 18 Jul 2026 20:49:50 +0700 Subject: [PATCH] =?UTF-8?q?fix:=20s=E1=BB=ADa=20l=E1=BB=97i=20zoom=20in=20?= =?UTF-8?q?full=20m=C3=A0n=20h=C3=ACnh?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 10_SNAP_TOOL.md | 165 ++++++++ 9_TRACK_UI.md | 191 +++++++++ app/templates/index.html | 819 +++++++++++++++++++++++++++++---------- 3 files changed, 970 insertions(+), 205 deletions(-) create mode 100644 10_SNAP_TOOL.md create mode 100644 9_TRACK_UI.md diff --git a/10_SNAP_TOOL.md b/10_SNAP_TOOL.md new file mode 100644 index 0000000..2610b66 --- /dev/null +++ b/10_SNAP_TOOL.md @@ -0,0 +1,165 @@ + + +# Technical Specification: Grid Snapping System (Grid Snapping Specification) + +This document defines the graphical user interface design and coordinate/signal processing algorithms required to build a synchronized grid snapping feature across both the Web Frontend and the Dockerized Python Desktop Backend. + +--- + +## 1. Toolbar UI Design Upgrade + +The toolbar layout has been expanded to double its physical vertical height. This increase in interactive space allows for larger navigation buttons and the integration of a dedicated Snap controller. + +```text ++───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────+ +| [Pro Toolbar - Height: 64px] | +| +──────────+ +──────────+ +──────────+ +──────────+ +───────────────────────────────────────────────────────────────+ | +| | Cut | | Copy | | Paste | | Snap: | | Transport Monitor | | +| | [Ctrl+X]| | [Ctrl+C]| | [Ctrl+V]| | [1/4 ▼] | | [Tempo: 120 BPM] [Time Signature: 4/4] [Bar:Beat 1.3.00] | | +| +──────────+ +──────────+ +──────────+ +──────────+ +───────────────────────────────────────────────────────────────+ | ++───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────+ + +``` + +### 1.1. Snap Dropdown Configuration + +* **Placement:** Located immediately following the *Paste* button on the primary toolbar row. +* **UI Syntax:** `Snap: ` +* **Dropdown Option Matrix:** +* `free`: Disables snapping; allows unrestricted pixel-by-pixel dragging. +* `1`: Snaps to the beginning of each complete measure (Whole Bar / 1/1). +* `1/2`: Divides the bar into 2 subdivisions (Half Note). +* `1/4`: Divides the bar into 4 subdivisions (Quarter Note / 1 Beat). +* `1/8`: Divides the bar into 8 subdivisions (Eighth Note). +* `1/16`: Divides the bar into 16 subdivisions (Sixteenth Note). +* `1/32`: Divides the bar into 32 subdivisions (Thirty-second Note). + + + +--- + +## 2. DSP Grid Math: Grid Subdivision & Time Interval Calculations + +To calculate the absolute temporal duration between individual grid lanes, the time metrics must be derived dynamically from the project's master tempo. + +Let: + +* $B$ be the project tempo (Beats Per Minute, e.g., $120\text{ BPM}$). +* $T_{\text{beat}}$ be the duration of a single beat (seconds). +* $T_{\text{bar}}$ be the duration of a full measure/bar (seconds)—assuming a standard $4/4$ time signature (4 beats per bar). + +The foundational constants are established as follows: + + +$$T_{\text{beat}} = \frac{60}{B} \quad (\text{seconds})$$ + +$$T_{\text{bar}} = 4 \times T_{\text{beat}} = \frac{240}{B} \quad (\text{seconds})$$ + +*Example:* At a tempo of $120\text{ BPM}$, a single $1\text{ Bar}$ measure spans exactly $2.0\text{ seconds}$. + +### 2.1. Determining Grid Time Interval Modifiers ($\Delta t$) + +Based on the user's active choice inside the snap dropdown selection ($S \in \{\text{free}, 1, 1/2, 1/4, 1/8, 1/16, 1/32\}$), the exact grid time step $\Delta t$ (seconds) is mapped as follows: + +$$\Delta t = \begin{cases} 0 & S = \text{free} \\ T_{\text{bar}} & S = 1 \\ \frac{T_{\text{bar}}}{2} & S = 1/2 \\ \frac{T_{\text{bar}}}{4} = T_{\text{beat}} & S = 1/4 \\ \frac{T_{\text{bar}}}{8} & S = 1/8 \\ \frac{T_{\text{bar}}}{16} & S = 1/16 \\ \frac{T_{\text{bar}}}{32} & S = 1/32 \end{cases}$$ + +--- + +## 3. Snapping Coordinate Calculation + +When a user executes a drag-and-drop event on an audio clip, or updates a timeline marker position, the system continuously converts raw cursor values into aligned coordinates. + +```text + Grid Line 1 (k * dt) Grid Line 2 ((k+1) * dt) + │ │ + ├───────────────○─────────────┤ + ▲ + │ [ Cursor dragging action ] + Raw Time (t_raw) + │ + ▼ [ Apply Snap round() function ] + ────────────────┼─────────────► + Snapped Time (t_snapped) + +``` + +### 3.1. Pixel to Snap-Time Translation Workflow + +1. Intercept the actual client-side horizontal cursor position $X_{\text{raw}}$ (pixels). +2. Convert it into a raw timeline duration metric $t_{\text{raw}}$ (seconds) utilizing the current scaling zoom factor $Z$ (pixels/second): + +$$t_{\text{raw}} = \frac{X_{\text{raw}}}{Z}$$ + + +3. Apply the rounding constraint formula to lock the raw timestamp to the absolute nearest grid marker: + +$$t_{\text{snapped}} = \begin{cases} t_{\text{raw}} & S = \text{free} \\ \text{round}\left( \frac{t_{\text{raw}}}{\Delta t} \right) \times \Delta t & S \neq \text{free} \end{cases}$$ + + +4. Map the snapped timeline index $t_{\text{snapped}}$ back to the layout canvas system coordinates to paint the element at its snapped visual boundary: + +$$X_{\text{snapped}} = t_{\text{snapped}} \times Z$$ + + + +--- + +## 4. Python Porting Blueprint + +When translating this architectural logic into a desktop Python core environment using frameworks like PyQt6, the snapping evaluations are tied directly into the tracking loop inside the `mouseMoveEvent` handler. + +```python +# [PYTHON PORTING BLUEPRINT] - Integrating the Snap algorithm into Python UI layer +import numpy as np + +class AudioSnapEngine: + def __init__(self, bpm: float = 120.0): + self.bpm = bpm + + def calculate_grid_step(self, snap_option: str) -> float: + """ + Calculates the grid's target duration step (seconds) based on Tempo and Snap selection. + """ + if snap_option == "free": + return 0.0 + + # 1 Bar in a standard 4/4 signature equals 240 / BPM seconds + t_bar = 240.0 / self.bpm + + fraction_map = { + "1": 1.0, + "1/2": 2.0, + "1/4": 4.0, + "1/8": 8.0, + "1/16": 16.0, + "1/32": 32.0 + } + + division = fraction_map.get(snap_option, 4.0) + return float(t_bar / division) + + def snap_time(self, raw_time_seconds: float, snap_option: str) -> float: + """ + Hard-clamps a raw timestamp to the nearest grid milestone. Prevents negative index overflows. + """ + dt = self.calculate_grid_step(snap_option) + if dt == 0.0: + return max(0.0, raw_time_seconds) + + # Find the nearest integer index k of the target grid lane: raw_time / dt + k = round(raw_time_seconds / dt) + snapped_time = k * dt + + return max(0.0, snapped_time) + +``` + +--- + +## 5. Visual Grid Alignment + +To maintain an intuitive environment for multi-channel editing, whenever a snapping constraint value other than `free` is engaged: + +* The rendering engine overlays thin, low-contrast vertical grid lines (`rgba(255, 255, 255, 0.05)`) over the background profile of every active Waveform Lane. +* These marker lines are projected onto every timeline axis point that satisfies a whole multiple increment of $\Delta t$. +* Displaying these alignment indicators ensures that users can visually anticipate bounding snapping positions before releasing their mouse track buttons. \ No newline at end of file diff --git a/9_TRACK_UI.md b/9_TRACK_UI.md new file mode 100644 index 0000000..18a1154 --- /dev/null +++ b/9_TRACK_UI.md @@ -0,0 +1,191 @@ + + +# Technical Specification: Multi-Channel Layout Synchronization & Scroll Management (Unified DAW Layout & Sync Scroll) + +This document analyzes and defines the structural hierarchy of the graphical user interface based on the real-world interface analysis. This specification serves to guide Frontend interface programming and porting to a Python Desktop application running inside a Docker container. + +--- + +## 1. Structural Wireframe + +Based on the visual analysis, the layout composition is split into vertically static and dynamic zones: + +```text ++───────────────────────────────────────────────────────────────────────────────+ +| [ZONE A - STATIC] HEADER ZONE (Sticky - Permanently fixed when scrolling down)| +| +────────────────────+──────────────────────────────────────────────────────+ | +| | Channels & Tools | Time Ruler Scale | | +| |--------------------|------------------------------------------------------| | +| | Tempo Track Header | Tempo Grid Lane (120 BPM) | | +| +────────────────────+──────────────────────────────────────────────────────+ | ++───────────────────────────────────────────────────────────────────────────────+ +| [ZONE B - DYNAMIC] TRACKS SCROLL WORKSPACE (Synchronized vertical scroll) | +| +────────────────────+──────────────────────────────────────────────────────+ | +| | TCP - Track 01 | Waveform Lane - Track 01 | | +| | TCP - Track 02 | Waveform Lane - Track 02 | | +| | TCP - Track 03 | Waveform Lane - Track 03 | | +| | ... | ... | | +| +────────────────────+──────────────────────────────────────────────────────+ | ++───────────────────────────────────────────────────────────────────────────────+ ▲ + │ [Vertical Scrollbar] + │ (Single unified scroll) + ▼ + +``` + +--- + +## 2. Layout Specifications + +### 2.1. Fixed Header Zone (Green Border Area - Sticky Header) + +* **Visual Scope:** Encompasses the toolbar, the time ruler scale, and the Tempo Track Lane (indicated by the green bounding border in `image_fbbd4e.png`). +* **Graphical Sticky Behavior:** +* When a user adds dozens of tracks and scrolls downward, this entire zone must remain anchored to the top of the screen and is not permitted to slide out of view. +* This ensures that users can continuously track the Ruler Seconds and the master project tempo (Tempo BPM) while editing tracks located deeper down the timeline. + + + +### 2.2. Absolute Horizontal Row Alignment (Red Border Area - Row Alignment) + +* **Interaction Scope:** The exact matching pair consisting of the left Track Control Panel (TCP) and the right Waveform Lane of the same track (e.g., Track 4 inside the red border of `image_fbbd4e.png`). +* **Row Alignment Rules:** +* The corresponding TCP and Waveform Lane must have identical heights ($H = 96\text{ px}$). +* These two elements must be wrapped within a single parent row container (`Flex Row` or `Grid Row`) to guarantee that during vertical scrolling, both move simultaneously along the exact same vertical axis coordinate ($Y$). +* Row misalignment must be strictly avoided (e.g., situations where the Track 4 TCP sits higher or lower than the Track 4 Waveform lane). + + + +### 2.3. Single Vertical Scrollbar Mandate + +* **Issue to Avoid:** Separating the TCP into an independent scrollable column and the Timeline into another independent scrollable column. Doing so leads to scroll-position desynchronization errors when a user drags the scrollbar. +* **Design Standard:** +* Only a single unified Vertical Scrollbar is permitted to appear on the absolute far right of the application window (as directed by the two red arrows in `image_fbbd4e.png`). +* This vertical scrollbar moves the entire dynamic wrapper (**Tracks Scroll Workspace**), scrolling both TCPs and Waveform Lanes up or down in sync. + + + +--- + +## 3. Implementation Guide + +### 3.1. Web Frontend Integration (HTML / Tailwind CSS) + +To group everything into one scrollbar while keeping the Tempo Track anchored at the top, use `position: sticky` and wrap the dynamic track list inside a single container: + +```html + +
+ + +
+ +
+
CHANNELS
+
...Ruler Numbers...
+
+ +
+
+ Tempo Track + 120 BPM +
+
...Tempo Grid Lines...
+
+
+ + +
+
+ + +
+ +
+ ...Controls (Mute, Solo, Volume, File Name)... +
+ +
+ ...Waveform Canvas... +
+
+ + + +
+
+
+ +``` + +### 3.2. Desktop App Integration (Python PyQt6) + +When engineering this user interface using the Qt framework in Python, utilize a `QScrollArea` to encapsulate a `QWidget` managed by a layout of rows to control the single scrollbar behavior: + +```python +from PyQt6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QScrollArea, QLabel +from PyQt6.QtCore import Qt + +class MasterDAWWidget(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. Initialize Fixed Header (Toolbar, Ruler, Tempo) + self.header_widget = QWidget() + self.header_widget.setFixedHeight(76) # 32px Ruler + 44px Tempo + self.setup_header_ui() + self.main_layout.addWidget(self.header_widget) + + # 2. Initialize Scroll Area for dynamic track rows + self.scroll_area = QScrollArea() + self.scroll_area.setWidgetResizable(True) + # Force a single vertical scrollbar on the far right + self.scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOn) + self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + + # Widget container hosting the track list inside the Scroll Area + self.tracks_container = QWidget() + self.tracks_layout = QVBoxLayout(self.tracks_container) + self.tracks_layout.setContentsMargins(0, 0, 0, 0) + self.tracks_layout.setSpacing(0) + self.tracks_layout.setAlignment(Qt.AlignmentFlag.AlignTop) + + self.scroll_area.setWidget(self.tracks_container) + self.main_layout.addWidget(self.scroll_area) + + def add_track_row(self, track_id, track_name): + """ + Appends a new track row. Uses QHBoxLayout to lock the TCP and Waveform Lane + into absolute horizontal sync within the row. + """ + row_widget = QWidget() + row_widget.setFixedHeight(96) # Rigid constraint for the entire row + row_layout = QHBoxLayout(row_widget) + row_layout.setContentsMargins(0, 0, 0, 0) + row_layout.setSpacing(0) + + # Left: Track Control Panel (TCP) + tcp_widget = QWidget() + tcp_widget.setFixedWidth(300) + # Setup TCP UI components... + row_layout.addWidget(tcp_widget) + + # Right: Waveform Lane + waveform_widget = QWidget() + # Setup Waveform Canvas Painter... + row_layout.addWidget(waveform_widget) + + self.tracks_layout.addWidget(row_widget) + +``` + +--- + +## 4. Layout Architecture Advantages + +* **Fluid User Experience:** Eliminates row-stuttering or scrolling layout shifts between the control panels and audio visuals when a user scrolls through long track stacks rapidly. +* **Flawless Python Porting Compatibility:** By wrapping the TCP and the Waveform Canvas inside a common row (`QHBoxLayout` in Qt or `Flex Row` in Web), the core widget tree hierarchy remains incredibly lean. This design removes the need to write custom coordinate bridging code to bind two separate scroll engines together. +* **Clean Interface Aesthetics:** Safely protects the pixel rendering mapping ratios of the fixed time grids at the top, precisely matching the professional DAW interface conventions observed \ No newline at end of file diff --git a/app/templates/index.html b/app/templates/index.html index 2d51646..ed4686b 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -175,6 +175,10 @@ onTrackLaneMouseDown, onContextMenu, onClipDragStart, + activeTool, + onSplitTrackAtTime, + snapValue, + bpm, }) => { const canvasRef = useRef(null); @@ -193,13 +197,34 @@ ctx.fillStyle = isSelected ? '#2a2a2a' : (track.id % 2 === 0 ? '#181818' : '#1d1d1d'); ctx.fillRect(0, 0, width, height); - // Grid lines - ctx.strokeStyle = 'rgba(255, 255, 255, 0.02)'; + // Grid lines based on Snap value + ctx.strokeStyle = 'rgba(255, 255, 255, 0.03)'; ctx.lineWidth = 1; - const stepSeconds = zoom > 120 ? 0.5 : 1; const totalSec = width / zoom; + + let gridSpacing = 1.0; // default 1 second + if (snapValue && snapValue !== 'free') { + const beatDuration = 60 / parseFloat(bpm || 120); + let divisor = 1; + if (snapValue === '1') divisor = 1; + else if (snapValue === '1/2') divisor = 0.5; + else if (snapValue === '1/4') divisor = 0.25; + else if (snapValue === '1/8') divisor = 0.125; + else if (snapValue === '1/16') divisor = 0.0625; + else if (snapValue === '1/32') divisor = 0.03125; + + gridSpacing = beatDuration * divisor; + } else { + gridSpacing = 60 / parseFloat(bpm || 120); // default to 1 beat + } - for (let s = 0; s <= totalSec; s += stepSeconds) { + // Guard: if lines are too close, scale grid spacing by multiples of 2 + let drawSpacing = gridSpacing; + while (drawSpacing * zoom < 10) { + drawSpacing *= 2; + } + + for (let s = 0; s <= totalSec; s += drawSpacing) { const x = s * zoom; ctx.beginPath(); ctx.moveTo(x, 0); @@ -208,56 +233,87 @@ } // Draw waveform lane - if (track.buffer) { - const data = track.buffer.getChannelData(0); - const sampleRate = track.buffer.sampleRate; - const totalSamples = data.length; - const duration = totalSamples / sampleRate; + const clips = track.clips && track.clips.length > 0 ? track.clips : (track.buffer ? [{ + id: 'default', + buffer: track.buffer, + startTime: track.startTime || 0, + name: track.name + }] : []); - const xStart = (track.startTime || 0) * zoom; - const wClip = duration * zoom; - const xEnd = xStart + wClip; + if (clips.length > 0) { + clips.forEach(clip => { + const data = clip.buffer.getChannelData(0); + const sampleRate = clip.buffer.sampleRate; + const totalSamples = data.length; + const duration = totalSamples / sampleRate; - // Draw markers - if (markers && markers.length > 0) { - markers.forEach(m => { - const mx = m.time * zoom; - ctx.fillStyle = '#fbbf24'; - ctx.fillRect(mx - 1, 0, 2, height); - ctx.fillStyle = 'rgba(251, 191, 36, 0.1)'; - ctx.fillRect(mx - 1, 0, 2, height); - }); - } + const xStart = (clip.startTime || 0) * zoom; + const wClip = duration * zoom; + const xEnd = xStart + wClip; - // Peak waveform drawing only within clip bounds - ctx.strokeStyle = isSelected ? '#06b6d4' : '#6ee7b7'; - ctx.lineWidth = 1; - - const drawXStart = Math.max(0, Math.floor(xStart)); - const drawXEnd = Math.min(width, Math.ceil(xEnd)); - const samplesPerPixel = sampleRate / zoom; - - for (let px = drawXStart; px < drawXEnd; px++) { - const timeInClip = (px - xStart) / zoom; - const sampleIdx = Math.floor(timeInClip * sampleRate); - const chunkSize = Math.max(1, Math.floor(samplesPerPixel)); - const chunkStart = Math.max(0, sampleIdx - Math.floor(chunkSize / 2)); - const chunkEnd = Math.min(totalSamples, chunkStart + chunkSize); - - let maxVal = 0; - for (let i = chunkStart; i < chunkEnd; i++) { - const abs = Math.abs(data[i]); - if (abs > maxVal) maxVal = abs; - } - - const mid = height / 2; - const peakHeight = maxVal * (height * 0.4); + // 1. Draw Clip Layer Background & Border + ctx.fillStyle = track.color ? track.color + '22' : 'rgba(6, 182, 212, 0.12)'; + ctx.strokeStyle = track.color || '#06b6d4'; + ctx.lineWidth = 1.5; + + const clipTop = 8; + const clipHeight = height - 16; ctx.beginPath(); - ctx.moveTo(px, mid - peakHeight); - ctx.lineTo(px, mid + peakHeight); + if (ctx.roundRect) { + ctx.roundRect(xStart, clipTop, wClip, clipHeight, 4); + } else { + ctx.rect(xStart, clipTop, wClip, clipHeight); + } + ctx.fill(); ctx.stroke(); - } + + // 2. Draw Clip Label + ctx.fillStyle = '#e4e4e7'; + ctx.font = 'bold 9px sans-serif'; + ctx.fillText(clip.name || 'Clip', xStart + 8, clipTop + 14); + + // Draw markers + if (markers && markers.length > 0) { + markers.forEach(m => { + const mx = m.time * zoom; + ctx.fillStyle = '#fbbf24'; + ctx.fillRect(mx - 1, 0, 2, height); + ctx.fillStyle = 'rgba(251, 191, 36, 0.1)'; + ctx.fillRect(mx - 1, 0, 2, height); + }); + } + + // 3. Peak waveform drawing only within clip bounds + ctx.strokeStyle = isSelected ? '#22d3ee' : '#a7f3d0'; + ctx.lineWidth = 1; + + const drawXStart = Math.max(0, Math.floor(xStart)); + const drawXEnd = Math.min(width, Math.ceil(xEnd)); + const samplesPerPixel = sampleRate / zoom; + + for (let px = drawXStart; px < drawXEnd; px++) { + const timeInClip = (px - xStart) / zoom; + const sampleIdx = Math.floor(timeInClip * sampleRate); + const chunkSize = Math.max(1, Math.floor(samplesPerPixel)); + const chunkStart = Math.max(0, sampleIdx - Math.floor(chunkSize / 2)); + const chunkEnd = Math.min(totalSamples, chunkStart + chunkSize); + + let maxVal = 0; + for (let i = chunkStart; i < chunkEnd; i++) { + const abs = Math.abs(data[i]); + if (abs > maxVal) maxVal = abs; + } + + const mid = height / 2; + const peakHeight = maxVal * (clipHeight * 0.45); + + ctx.beginPath(); + ctx.moveTo(px, mid - peakHeight); + ctx.lineTo(px, mid + peakHeight); + ctx.stroke(); + } + }); } else { ctx.fillStyle = '#444'; ctx.font = '12px Inter, sans-serif'; @@ -277,12 +333,39 @@ ctx.strokeRect(hlLeft, 0, hlWidth, height); } - }, [track, zoom, timelineWidth, isSelected, markers, selectionMode, localSelectionTrackId, localSelLeft, localSelRight]); + }, [track, zoom, timelineWidth, isSelected, markers, selectionMode, localSelectionTrackId, localSelLeft, localSelRight, snapValue, bpm]); return ( { + if (!canvasRef.current) return; + const rect = canvasRef.current.getBoundingClientRect(); + const wrapper = canvasRef.current?.parentElement?.parentElement; + const scrollLeft = wrapper ? wrapper.scrollLeft : 0; + const x = e.clientX - rect.left + scrollLeft; + const time = x / zoom; + + const clips = track.clips && track.clips.length > 0 ? track.clips : (track.buffer ? [{ + id: 'default', + buffer: track.buffer, + startTime: track.startTime || 0, + name: track.name + }] : []); + + const hoveredClip = clips.find(c => time >= c.startTime && time < c.startTime + c.buffer.duration); + const isOverClip = !!hoveredClip; + + if (activeTool === 'grab') { + canvasRef.current.style.cursor = isOverClip ? 'grab' : 'default'; + } else if (activeTool === 'razor') { + canvasRef.current.style.cursor = isOverClip ? 'cell' : 'not-allowed'; + } else { + // select tool + canvasRef.current.style.cursor = (isOverClip && e.altKey) ? 'grab' : 'crosshair'; + } + }} onMouseDown={(e) => { // Ignore right-click for local selection drag (context menu handles it) if (e.button === 2) return; @@ -293,13 +376,45 @@ const time = Math.max(0, x / zoom); onSelectTrack(track.id); - // Check for Alt+Click drag clip - const isOverClip = track.buffer && time >= (track.startTime || 0) && time < (track.startTime || 0) + track.buffer.duration; - if (isOverClip && e.altKey) { + const clips = track.clips && track.clips.length > 0 ? track.clips : (track.buffer ? [{ + id: 'default', + buffer: track.buffer, + startTime: track.startTime || 0, + name: track.name + }] : []); + + const clickedClip = clips.find(c => time >= c.startTime && time < c.startTime + c.buffer.duration); + + if (activeTool === 'razor') { + if (clickedClip) { + e.preventDefault(); + e.stopPropagation(); + if (onSplitTrackAtTime) { + onSplitTrackAtTime(track.id, clickedClip.id, time); + } + } + return; + } + + if (activeTool === 'grab') { + if (clickedClip) { + e.preventDefault(); + e.stopPropagation(); + if (onClipDragStart) { + onClipDragStart(track.id, clickedClip.id, time - clickedClip.startTime); + } + } else { + onPlayheadSet(time); + } + return; + } + + // Check for click drag clip (no Alt key required if select tool is in fallback alt-mode) + if (clickedClip && e.altKey) { e.preventDefault(); e.stopPropagation(); if (onClipDragStart) { - onClipDragStart(track.id, time - (track.startTime || 0)); + onClipDragStart(track.id, clickedClip.id, time - clickedClip.startTime); } return; } @@ -325,7 +440,7 @@ ); }; - const TempoTrackLane = ({ bpm, zoom, timelineWidth, onPlayheadSet }) => { + const TempoTrackLane = ({ bpm, zoom, timelineWidth, onPlayheadSet, snapValue }) => { const canvasRef = useRef(null); useEffect(() => { @@ -375,12 +490,39 @@ } } + // Draw snap sub-ticks at the bottom + if (snapValue && snapValue !== 'free') { + ctx.strokeStyle = 'rgba(255, 255, 255, 0.15)'; + ctx.lineWidth = 0.8; + let divisor = 1; + if (snapValue === '1') divisor = 1; + else if (snapValue === '1/2') divisor = 0.5; + else if (snapValue === '1/4') divisor = 0.25; + else if (snapValue === '1/8') divisor = 0.125; + else if (snapValue === '1/16') divisor = 0.0625; + else if (snapValue === '1/32') divisor = 0.03125; + + const snapInterval = beatDuration * divisor; + if (snapInterval * zoom >= 4) { + for (let t = 0; t <= totalSec; t += snapInterval) { + const onBeat = Math.abs((t / beatDuration) - Math.round(t / beatDuration)) < 0.001; + if (!onBeat) { + const x = t * zoom; + ctx.beginPath(); + ctx.moveTo(x, height - 6); + ctx.lineTo(x, height); + ctx.stroke(); + } + } + } + } + ctx.fillStyle = 'rgba(255, 255, 255, 0.35)'; ctx.font = 'bold 10px Inter, sans-serif'; ctx.textAlign = 'right'; ctx.fillText(`${bpm} BPM`, width - 6, 12); - }, [bpm, zoom, timelineWidth]); + }, [bpm, zoom, timelineWidth, snapValue]); return ( { + if (snapVal === 'free') return time; + const beatDuration = 60 / parseFloat(bpmVal || 120); + let divisor = 1; + if (snapVal === '1') divisor = 1; + else if (snapVal === '1/2') divisor = 0.5; + else if (snapVal === '1/4') divisor = 0.25; + else if (snapVal === '1/8') divisor = 0.125; + else if (snapVal === '1/16') divisor = 0.0625; + else if (snapVal === '1/32') divisor = 0.03125; + + const gridSpacing = beatDuration * divisor; + return Math.round(time / gridSpacing) * gridSpacing; + }; + + const snapValueRef = useRef(snapValue); + snapValueRef.current = snapValue; + const bpmRef = useRef(bpm); + bpmRef.current = bpm; + + useEffect(() => { + setTimeout(() => { + if (window.lucide) { + window.lucide.createIcons(); + } + }, 50); + }, [activeTool]); // BMP for Tempo Track - LOOP_EDITOR_2.md §6 - const [bpm, setBpm] = useState(localStorage.getItem('studio_bpm') || '120'); const [selectedTrackId, setSelectedTrackId] = useState('1'); const [currentTime, setCurrentTime] = useState(0); const [isPlaying, setIsPlaying] = useState(false); @@ -546,6 +718,12 @@ // buffer is captured via reference copy for undo; we store a clone for redo buffer: track.buffer, startTime: track.startTime || 0, + clips: track.clips ? track.clips.map(c => ({ + id: c.id, + buffer: c.buffer, + startTime: c.startTime, + name: c.name + })) : null }; }; @@ -892,81 +1070,51 @@ const ctx = getAudioContext(); const targetTrack = tracks.find(t => t.id === targetTrackId); - if (targetTrack && targetTrack.buffer) { - const targetSr = targetTrack.buffer.sampleRate; - const clipSr = clipBuffer.sampleRate; - // Resample clipboard to target sample rate if needed - let pasteData; - let pasteSamples; - if (clipSr === targetSr) { - const newClipBuffer = ctx.createBuffer(1, clipBuffer.length, clipSr); - newClipBuffer.copyToChannel(clipBuffer.getChannelData(0), 0); - pasteData = newClipBuffer.getChannelData(0); - pasteSamples = clipBuffer.length; - } else { - pasteSamples = Math.round(clipBuffer.length * targetSr / clipSr); - const resampled = ctx.createBuffer(1, pasteSamples, targetSr); - const out = resampled.getChannelData(0); - const src = clipBuffer.getChannelData(0); - const srcLen = clipBuffer.length; - for (let i = 0; i < pasteSamples; i++) { - const pos = (i / pasteSamples) * srcLen; - const idx = Math.floor(pos); - const frac = pos - idx; - const s0 = src[Math.min(idx, srcLen - 1)]; - const s1 = src[Math.min(idx + 1, srcLen - 1)]; - out[i] = s0 + (s1 - s0) * frac; - } - pasteData = out; - } + const newClip = { + id: 'clip_' + Date.now() + '_' + Math.floor(Math.random() * 1000), + startTime: pasteTime, + buffer: clipBuffer, + name: name || 'Pasted Clip' + }; - const targetStart = targetTrack.startTime || 0; - const relativePasteTime = pasteTime - targetStart; - - let newStartTime = targetStart; - let combined; - const origLen = targetTrack.buffer.length; - const origData = targetTrack.buffer.getChannelData(0); - - if (relativePasteTime >= 0) { - const insertSample = Math.floor(relativePasteTime * targetSr); - const newLen = Math.max(origLen, insertSample + pasteSamples); - combined = ctx.createBuffer(1, newLen, targetSr); - const combinedData = combined.getChannelData(0); - for (let i = 0; i < origLen; i++) combinedData[i] = origData[i]; - for (let i = 0; i < pasteSamples; i++) combinedData[insertSample + i] = pasteData[i]; - } else { - newStartTime = pasteTime; - const prependSamples = Math.floor((targetStart - pasteTime) * targetSr); - const newLen = Math.max(prependSamples + origLen, pasteSamples); - combined = ctx.createBuffer(1, newLen, targetSr); - const combinedData = combined.getChannelData(0); - for (let i = 0; i < origLen; i++) combinedData[prependSamples + i] = origData[i]; - for (let i = 0; i < pasteSamples; i++) combinedData[i] = pasteData[i]; - } - - setTracks(p => p.map(t => t.id === targetTrackId ? { ...t, buffer: combined, startTime: newStartTime } : t)); - setCurrentTime(pasteTime); - showToast('Đã dán vào track.', 'success'); - return targetTrackId; - } if (targetTrack) { - // Track exists but no buffer — use clipboard sample rate - const newClipBuffer = ctx.createBuffer(1, clipBuffer.length, clipBuffer.sampleRate); - newClipBuffer.copyToChannel(clipBuffer.getChannelData(0), 0); - setTracks(p => p.map(t => t.id === targetTrackId ? { ...t, buffer: newClipBuffer, startTime: pasteTime, name: `Pasted_${name || t.name}`, volume: volume || t.volume, color: color || t.color } : t)); + setTracks(p => p.map(t => { + if (t.id === targetTrackId) { + const existingClips = t.clips && t.clips.length > 0 ? t.clips : (t.buffer ? [{ + id: 'default_' + t.id, + buffer: t.buffer, + startTime: t.startTime || 0, + name: t.name + }] : []); + + const updatedClips = [...existingClips, newClip]; + + return { + ...t, + clips: updatedClips, + buffer: updatedClips[0].buffer, + startTime: updatedClips[0].startTime, + name: name || t.name, + volume: volume || t.volume, + color: color || t.color + }; + } + return t; + })); setCurrentTime(pasteTime); - showToast('Đã dán vào track.', 'success'); + showToast('Đã dán clip vào track.', 'success'); return targetTrackId; } + // No matching track — create a new one const colors = ['#0f766e', '#1d4ed8', '#701a75', '#a21caf', '#b45309']; const newId = 'track_pasted_' + Date.now(); - const newClipBuffer = ctx.createBuffer(1, clipBuffer.length, clipBuffer.sampleRate); - newClipBuffer.copyToChannel(clipBuffer.getChannelData(0), 0); setTracks(prev => [...prev, { - id: newId, name: `Pasted_${name || 'track'}`, buffer: newClipBuffer, + id: newId, + name: `Pasted_${name || 'track'}`, + buffer: clipBuffer, startTime: pasteTime, + clips: [newClip], volume: volume || 0.8, muted: false, solo: false, color: color || colors[prev.length % colors.length], markers: [], serverFileId: null, @@ -1157,7 +1305,7 @@ maxDurationRef.current = maxDuration; const minZoom = useMemo(() => { - return Math.max(20, viewportWidth / maxDuration); + return viewportWidth / maxDuration; }, [viewportWidth, maxDuration]); const timelineWidth = useMemo(() => { @@ -1339,36 +1487,45 @@ const hasSolo = tracks.some(t => t.solo) || soloedTrackId !== null; tracks.forEach(track => { - if (!track.buffer) return; - const isPlayable = hasSolo ? (track.id === soloedTrackId || track.solo) : !track.muted; if (!isPlayable) return; - const source = context.createBufferSource(); - source.buffer = track.buffer; + const clips = track.clips && track.clips.length > 0 ? track.clips : (track.buffer ? [{ + id: 'default', + buffer: track.buffer, + startTime: track.startTime || 0, + name: track.name + }] : []); - const gainNode = context.createGain(); - gainNode.gain.setValueAtTime(track.volume, context.currentTime); + clips.forEach(clip => { + if (!clip.buffer) return; - source.connect(gainNode); - gainNode.connect(context.destination); + const source = context.createBufferSource(); + source.buffer = clip.buffer; - const trackStart = track.startTime || 0; - const trackDuration = track.buffer.duration; - const trackEnd = trackStart + trackDuration; + const gainNode = context.createGain(); + gainNode.gain.setValueAtTime(track.volume, context.currentTime); - if (offsetTime < trackStart) { - const delay = trackStart - offsetTime; - source.start(context.currentTime + delay, 0); - activeSourcesRef.current.push(source); - } else if (offsetTime < trackEnd) { - const playOffset = offsetTime - trackStart; - source.start(context.currentTime, playOffset); - activeSourcesRef.current.push(source); - } + source.connect(gainNode); + gainNode.connect(context.destination); + + const clipStart = clip.startTime || 0; + const clipDuration = clip.buffer.duration; + const clipEnd = clipStart + clipDuration; + + if (offsetTime < clipStart) { + const delay = clipStart - offsetTime; + source.start(context.currentTime + delay, 0); + activeSourcesRef.current.push(source); + } else if (offsetTime < clipEnd) { + const playOffset = offsetTime - clipStart; + source.start(context.currentTime, playOffset); + activeSourcesRef.current.push(source); + } + }); }); }; @@ -1376,30 +1533,41 @@ const startLocalTrackPlayback = (trackId, offsetTime) => { const context = getAudioContext(); const track = tracks.find(t => t.id === trackId); - if (!track || !track.buffer) return; + if (!track) return; - const source = context.createBufferSource(); - source.buffer = track.buffer; + const clips = track.clips && track.clips.length > 0 ? track.clips : (track.buffer ? [{ + id: 'default', + buffer: track.buffer, + startTime: track.startTime || 0, + name: track.name + }] : []); - const gainNode = context.createGain(); - gainNode.gain.setValueAtTime(track.volume, context.currentTime); + clips.forEach(clip => { + if (!clip.buffer) return; - source.connect(gainNode); - gainNode.connect(context.destination); + const source = context.createBufferSource(); + source.buffer = clip.buffer; - const trackStart = track.startTime || 0; - const trackDuration = track.buffer.duration; - const trackEnd = trackStart + trackDuration; + const gainNode = context.createGain(); + gainNode.gain.setValueAtTime(track.volume, context.currentTime); - if (offsetTime < trackStart) { - const delay = trackStart - offsetTime; - source.start(context.currentTime + delay, 0); - activeSourcesRef.current.push(source); - } else if (offsetTime < trackEnd) { - const playOffset = offsetTime - trackStart; - source.start(context.currentTime, playOffset); - activeSourcesRef.current.push(source); - } + source.connect(gainNode); + gainNode.connect(context.destination); + + const clipStart = clip.startTime || 0; + const clipDuration = clip.buffer.duration; + const clipEnd = clipStart + clipDuration; + + if (offsetTime < clipStart) { + const delay = clipStart - offsetTime; + source.start(context.currentTime + delay, 0); + activeSourcesRef.current.push(source); + } else if (offsetTime < clipEnd) { + const playOffset = offsetTime - clipStart; + source.start(context.currentTime, playOffset); + activeSourcesRef.current.push(source); + } + }); }; const handlePlayPause = () => { @@ -1534,20 +1702,31 @@ draggedClipRef.current = draggedClip; const hoveredTrackIdRef = useRef(null); hoveredTrackIdRef.current = hoveredTrackId; + const captureTrackSnapshotRef = useRef(null); + captureTrackSnapshotRef.current = captureTrackSnapshot; - const handleClipDragStart = (trackId, clickOffset) => { + const handleClipDragStart = (trackId, clipId, clickOffset) => { const track = tracks.find(t => t.id === trackId); - if (!track || !track.buffer) return; + if (!track) return; + + const clips = track.clips && track.clips.length > 0 ? track.clips : (track.buffer ? [{ + id: 'default', + buffer: track.buffer, + startTime: track.startTime || 0, + name: track.name + }] : []); + + const clip = clips.find(c => c.id === clipId); + if (!clip) return; const beforeSnap = captureTrackSnapshot(trackId); setDraggedClip({ trackId: trackId, + clipId: clipId, clickOffset: clickOffset, - buffer: track.buffer, - name: track.name, - volume: track.volume, - color: track.color, + buffer: clip.buffer, + name: clip.name, beforeSnap: beforeSnap }); }; @@ -1564,22 +1743,50 @@ const scrollLeft = wrapper.scrollLeft; const mouseX = e.clientX - rect.left + scrollLeft; const time = mouseX / zoom; - const newStart = Math.max(0, time - drag.clickOffset); + const rawStart = Math.max(0, time - drag.clickOffset); + const newStart = snapTime(rawStart, snapValueRef.current, bpmRef.current); const targetTrackId = hoveredTrackIdRef.current || drag.trackId; setTracks(prev => prev.map(t => { + // Clear the clip from its previous track if it moved to a new track if (t.id === drag.trackId && drag.trackId !== targetTrackId) { - return { ...t, buffer: null, startTime: 0 }; + const updatedClips = (t.clips || []).filter(c => c.id !== drag.clipId); + return { + ...t, + clips: updatedClips, + buffer: updatedClips.length > 0 ? updatedClips[0].buffer : null, + startTime: updatedClips.length > 0 ? updatedClips[0].startTime : 0, + name: updatedClips.length > 0 ? updatedClips[0].name : `Track ${t.id}` + }; } + // Update/set clip on target track if (t.id === targetTrackId) { + const existingClips = t.clips && t.clips.length > 0 ? t.clips : (t.buffer ? [{ + id: 'default_' + t.id, + buffer: t.buffer, + startTime: t.startTime || 0, + name: t.name + }] : []); + + const hasClip = existingClips.some(c => c.id === drag.clipId); + let updatedClips; + if (hasClip) { + updatedClips = existingClips.map(c => c.id === drag.clipId ? { ...c, startTime: newStart } : c); + } else { + updatedClips = [...existingClips, { + id: drag.clipId, + buffer: drag.buffer, + startTime: newStart, + name: drag.name + }]; + } return { ...t, - buffer: drag.buffer, - startTime: newStart, - name: drag.name, - volume: drag.volume, - color: drag.color + clips: updatedClips, + buffer: updatedClips[0].buffer, + startTime: updatedClips[0].startTime, + name: updatedClips[0].name }; } return t; @@ -1594,7 +1801,7 @@ const drag = draggedClipRef.current; if (!drag) return; - const afterSnap = captureTrackSnapshot(drag.trackId); + const afterSnap = captureTrackSnapshotRef.current(drag.trackId); pushAction('MOVE_CLIP', drag.trackId, drag.beforeSnap, afterSnap); setDraggedClip(null); @@ -1863,6 +2070,7 @@ }]); showToast(`Đã thêm Track ${newId}.`, 'info'); setTimeout(() => lucide.createIcons(), 200); + return newId; }; // ── Server-side Export ── @@ -2226,22 +2434,35 @@ }; // ── Split Track at Playhead ── - const handleSplitTrack = (trackId) => { + const handleSplitTrackAtTime = (trackId, clipId, time) => { const track = tracks.find(t => t.id === trackId); - if (!track || !track.buffer) return; + if (!track) return; - const trackStart = track.startTime || 0; - const relCurrentTime = Math.max(0, currentTime - trackStart); - const cutTime = findZeroCrossing(track.buffer, relCurrentTime); - const sr = track.buffer.sampleRate; - const cutSample = Math.floor(cutTime * sr); - const originalData = track.buffer.getChannelData(0); + const clips = track.clips && track.clips.length > 0 ? track.clips : (track.buffer ? [{ + id: 'default', + buffer: track.buffer, + startTime: track.startTime || 0, + name: track.name + }] : []); + + const targetClipId = clipId || (clips.find(c => time >= c.startTime && time < c.startTime + c.buffer.duration)?.id); + if (!targetClipId) return; + + const clip = clips.find(c => c.id === targetClipId); + if (!clip || !clip.buffer) return; + + const relTime = Math.max(0, time - clip.startTime); + const sr = clip.buffer.sampleRate; + const cutSample = Math.floor(relTime * sr); + const originalData = clip.buffer.getChannelData(0); if (cutSample <= 0 || cutSample >= originalData.length) { - showToast("Vị trí Playhead nằm ngoài dải biên tập.", "warning"); + showToast("Vị trí cắt nằm ngoài dải âm thanh của clip.", "warning"); return; } + const beforeSnap = captureTrackSnapshot(trackId); + const ctx = getAudioContext(); const b1 = ctx.createBuffer(1, cutSample, sr); b1.copyToChannel(originalData.subarray(0, cutSample), 0); @@ -2249,24 +2470,130 @@ const b2 = ctx.createBuffer(1, originalData.length - cutSample, sr); b2.copyToChannel(originalData.subarray(cutSample), 0); - setTracks(prev => { - const idx = prev.findIndex(t => t.id === trackId); - const updated = [...prev]; - updated[idx] = { ...track, name: `${track.name} (Part 1)`, buffer: b1 }; - - const newTrack = { - ...track, - id: 'track_split_' + Date.now(), - name: `${track.name} (Part 2)`, - buffer: b2, - startTime: trackStart + (cutSample / sr), - markers: [], - serverFileId: null, - }; - updated.splice(idx + 1, 0, newTrack); - return updated; + const clip1 = { + id: 'clip_' + Date.now() + '_p1', + name: `${clip.name.replace(' (Part 1)', '').replace(' (Part 2)', '')} (Part 1)`, + buffer: b1, + startTime: clip.startTime + }; + + const clip2 = { + id: 'clip_' + Date.now() + '_p2', + name: `${clip.name.replace(' (Part 1)', '').replace(' (Part 2)', '')} (Part 2)`, + buffer: b2, + startTime: clip.startTime + (cutSample / sr) + }; + + setTracks(prev => prev.map(t => { + if (t.id === trackId) { + const remainingClips = clips.filter(c => c.id !== targetClipId); + const updatedClips = [...remainingClips, clip1, clip2]; + return { + ...t, + clips: updatedClips, + buffer: updatedClips[0]?.buffer || null, + startTime: updatedClips[0]?.startTime || 0, + name: updatedClips[0]?.name || t.name + }; + } + return t; + })); + + setTimeout(() => { + const afterSnap = captureTrackSnapshot(trackId); + pushAction('SPLIT_CLIP', trackId, beforeSnap, afterSnap); + }, 50); + + showToast(`Đã chia nhỏ clip tại ${formatTime(time)}.`, "info"); + }; + + const handleSplitTrack = (trackId) => { + handleSplitTrackAtTime(trackId, null, currentTime); + }; + + // ── Glue (Merge) Clips on Selected Track ── + const handleGlueTracks = () => { + const track = tracks.find(t => t.id === selectedTrackId); + if (!track) { + showToast('Vui lòng chọn một track để thực hiện gộp (glue).', 'warning'); + return; + } + + const clips = track.clips && track.clips.length > 0 ? track.clips : (track.buffer ? [{ + id: 'default', + buffer: track.buffer, + startTime: track.startTime || 0, + name: track.name + }] : []); + + if (clips.length < 2) { + showToast('Cần ít nhất 2 clip trên track này để gộp (glue).', 'warning'); + return; + } + + const beforeSnap = captureTrackSnapshot(track.id); + + const ctx = getAudioContext(); + const sr = clips[0].buffer.sampleRate; + + let minStart = Infinity; + let maxEnd = -Infinity; + clips.forEach(c => { + const start = c.startTime || 0; + const end = start + c.buffer.duration; + minStart = Math.min(minStart, start); + maxEnd = Math.max(maxEnd, end); }); - showToast("Đã chia nhỏ track tại vị trí Playhead.", "info"); + + const newDur = maxEnd - minStart; + const newBuffer = ctx.createBuffer(1, Math.ceil(newDur * sr), sr); + const newData = newBuffer.getChannelData(0); + + clips.forEach(c => { + const data = c.buffer.getChannelData(0); + const offset = Math.floor(((c.startTime || 0) - minStart) * sr); + for (let i = 0; i < data.length; i++) { + if (offset + i < newData.length) { + newData[offset + i] += data[i]; + } + } + }); + + let maxPeak = 0; + for (let i = 0; i < newData.length; i++) { + const abs = Math.abs(newData[i]); + if (abs > maxPeak) maxPeak = abs; + } + if (maxPeak > 1.0) { + for (let i = 0; i < newData.length; i++) newData[i] /= maxPeak; + } + + const mergedClip = { + id: 'clip_merged_' + Date.now(), + name: `${track.name.replace(' (Part 1)', '').replace(' (Part 2)', '')} (Glued)`, + buffer: newBuffer, + startTime: minStart + }; + + setTracks(prev => prev.map(t => { + if (t.id === track.id) { + return { + ...t, + clips: [mergedClip], + buffer: newBuffer, + startTime: minStart, + name: mergedClip.name + }; + } + return t; + })); + + setTimeout(() => { + const afterSnap = captureTrackSnapshot(track.id); + pushAction('GLUE', track.id, beforeSnap, afterSnap); + }, 50); + + showToast(`Đã gộp ${clips.length} clips thành công.`, 'success'); }; // ── Save AI config to localStorage ── @@ -2442,9 +2769,71 @@
{/* TCP Left Column */}
-
- Danh Sách Kênh - Chọn Click +
+ Kênh + + {/* Timeline Toolbar */} +
+ + + + + {/* Separator */} +
+ + {/* Quick Actions (Glue, Cut, Copy, Paste) */} + + + + +
@@ -2580,7 +2969,7 @@ {/* Tempo Track Lane - LOOP_EDITOR_2.md §6 */}
+ onPlayheadSet={setCurrentTime} snapValue={snapValue} />
{tracks.map((track) => { @@ -2602,6 +2991,10 @@ 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} @@ -2620,6 +3013,22 @@ ); })} + {/* Drop zone to create a new track during drag */} +
{ + if (draggedClipRef.current) { + const newId = addNewTrack(); + setHoveredTrackId(newId); + } + }} + onClick={addNewTrack} + > + + Kéo clip xuống đây hoặc Click để tạo Track mới + +
+ {/* 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 && (