From 2d9744c13a16bdc5a7143f4e1533d952a3b07ed3 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Tue, 21 Jul 2026 11:22:43 +0700 Subject: [PATCH] fix: git commit node modules --- .gitignore | 1 + 24_CURSOR_ZOOM.md | 174 +++++++++++++++++++++++++++++ 25_ZOOM.md | 121 ++++++++++++++++++++ 26_ZOOM_HTML.md | 77 +++++++++++++ 27_ZOOM_TECH.md | 186 +++++++++++++++++++++++++++++++ app/static/js/app.precompiled.js | 115 ++++++++----------- 6 files changed, 605 insertions(+), 69 deletions(-) create mode 100644 24_CURSOR_ZOOM.md create mode 100644 25_ZOOM.md create mode 100644 26_ZOOM_HTML.md create mode 100644 27_ZOOM_TECH.md diff --git a/.gitignore b/.gitignore index de26fe3..2c03612 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,4 @@ app/storage/processed/* .vscode/ *.log celerybeat-schedule +node_modules/ diff --git a/24_CURSOR_ZOOM.md b/24_CURSOR_ZOOM.md new file mode 100644 index 0000000..48c0599 --- /dev/null +++ b/24_CURSOR_ZOOM.md @@ -0,0 +1,174 @@ +# Technical Specification: Playhead-Centering Zoom Algorithm + +This document specifies the playhead drifting phenomenon during zoom operations and provides the architectural solutions, mathematical formulations, and source code prototypes required to lock the playback cursor as a static physical anchor point on the screen throughout timeline magnification updates. + +--- + +## 1. Visual Symptom & Playhead Drifting Analysis + +In standard digital audio workstation (DAW) graphical user interfaces, when an operator executes a mouse wheel zoom gesture (Zoom In/Out), the layout layout engine defaults to treating the leftmost physical pixel coordinate ($0$) of the timeline as the boundary axis for scaling. + +### 1.1. Visual Failure Manifestations: + +* **During Zoom In:** The red playback cursor (Playhead) positioned at a specific timestamp (e.g., $4.00\text{ s}$) is rapidly shifted toward the right perimeter of the viewport until it flies completely out of view. +* **During Zoom Out:** The playhead is abruptly snapped back toward the left perimeter of the screen viewport. +* **Consequence:** The sound engineer is forced to continuously adjust the horizontal scrollbar (`scrollLeft`) to find the playhead location, severely breaking the workflow during detail editing blocks. + +### 1.2. Target Layout State (Playhead-Centering Zoom): + +Throughout mouse-driven zoom updates at any scale: + +* The playback cursor (Playhead) must act as a static physical anchor point locked to its exact pixel position relative to the visible browser window viewport. +* The multi-channel waveform graphics must stretch or compress symmetrically around the vertical axis of the playback cursor. + +--- + +## 2. Mathematical Modeling for Playhead Anchoring + +To guarantee that the on-screen placement of the cursor maps identically before and after a modification to the viewport magnification ratio, we establish a system of equations conserving the pixel coordinates of the playhead. + +### 2.1. Operational Variables Mapping: + +* $t_{\text{playhead}}$ (seconds): The instantaneous runtime clock position of the playhead (e.g., $4.00\text{ s}$). +* $Z_{\text{current}}$ (px/s): The initial timeline horizontal scaling zoom factor before resizing. +* $Z_{\text{new}}$ (px/s): The target timeline horizontal scaling zoom factor after resizing. +* $S_{\text{current}}$ (pixels): The current initial horizontal scroll offset (`scrollLeft`) of the timeline view. +* $S_{\text{new}}$ (pixels): The target adjusted horizontal scroll offset calculated to overwrite the container state. +* $X_{\text{viewport}}$ (pixels): The physical offset tracking the distance from the left edge of the screen viewport container to the playhead rendering path line. + +### 2.2. Coordinate Conservation Formula + +The absolute spatial coordinate of the playhead on the global arrangement timeline maps to: + + +$$X_{\text{absolute}} = t_{\text{playhead}} \times Z$$ + +The actual visible screen viewport placement of the cursor before executing the zoom factor modification evaluates to: + + +$$X_{\text{viewport}} = (t_{\text{playhead}} \times Z_{\text{current}}) - S_{\text{current}}$$ + +To lock the playhead directly to its coordinate position post-zoom ($Z_{\text{new}}$), the variable value $X_{\text{viewport}}$ must remain strictly unchanged: + + +$$X_{\text{viewport}} = (t_{\text{playhead}} \times Z_{\text{new}}) - S_{\text{new}}$$ + +Solving the equation systems to calculate the target adjusted scroll offset parameter $S_{\text{new}}$: + + +$$S_{\text{new}} = (t_{\text{playhead}} \times Z_{\text{new}}) - X_{\text{viewport}}$$ + +Substituting the initial definition statement of $X_{\text{viewport}}$ back into the calculation loop: + + +$$S_{\text{new}} = (t_{\text{playhead}} \times Z_{\text{new}}) - \left( (t_{\text{playhead}} \times Z_{\text{current}}) - S_{\text{current}} \right)$$ + +Compiling the final optimized mathematical reduction model: + + +$$S_{\text{new}} = S_{\text{current}} + t_{\text{playhead}} \times (Z_{\text{new}} - Z_{\text{current}})$$ + +*Physical Property Significance:* The calculated target scrollbar position equals the current scroll offset augmented by the absolute coordinate displacement of the playhead triggered by the variance across magnification scales. + +--- + +## 3. Frontend Client Integration Blueprint (React / HTML5) + +This mathematical alignment routine is tied directly into the primary mouse `wheel` event handler capturing timeline zoom interactions inside the main `index.html` structure: + +```javascript +// Timeline wheel interaction handling segment capturing Playhead-anchored Zoom +const handleTimelineZoom = (e) => { + // Restrict zoom loops exclusively to situations where Ctrl (or Cmd) modifiers are engaged + if (!e.ctrlKey) return; + e.preventDefault(); + + const timelineWrapper = timelineWrapperRef.current; + if (!timelineWrapper) return; + + // 1. Capture absolute layout dimensions before updating state variables + const scrollLeftCurrent = timelineWrapper.scrollLeft; + const zoomCurrent = zoom; // Maps to Z_current + const playheadTime = currentTime; // Maps to t_playhead + + // 2. Evaluate target zoom ratio step updates (Enforces fluid scaling profiles) + const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1; + let zoomNew = zoomCurrent * zoomFactor; + + // Rigidly clamp calculation bounds within safe operating limits + const minZoomLimit = viewportWidth / maxDuration; + const maxZoomLimit = 2000; // Mitigates graphical memory canvas texture crashes + zoomNew = Math.max(minZoomLimit, Math.min(maxZoomLimit, zoomNew)); + + // 3. Apply the conservation formula to calculate S_new scroll offsets + const scrollLeftNew = scrollLeftCurrent + playheadTime * (zoomNew - zoomCurrent); + + // 4. Propagate updated values synchronously down to State queues and the DOM + setZoom(zoomNew); + + // Defer scroll alignment to requestAnimationFrame to execute right as Canvas buffers redraw + requestAnimationFrame(() => { + timelineWrapper.scrollLeft = scrollLeftNew; + }); +}; + +``` + +--- + +## 4. Desktop Application Integration Manual (Python PyQt6 / PySide6) + +When porting this layout algorithm to a containerized Python desktop context, capture the native `wheelEvent` tracking loop of the underlying `QGraphicsView` or `QScrollArea` layout wrapper: + +```python +# [PYTHON PORTING BLUEPRINT] - Lock-step Playhead Zoom tracking over PyQt6 QGraphicsView +from PyQt6.QtWidgets import QGraphicsView, QScrollBar +from PyQt6.QtCore import Qt + +class ProAudioTimelineView(QGraphicsView): + def __init__(self, parent=None): + super().__init__(parent) + self.playhead_time_seconds = 4.0 # Maps to t_playhead parameter + self.zoom_level = 100.0 # Maps to Z_current constant (pixels/second) + + def wheelEvent(self, event): + # Inspect for active hardware keyboard ControlModifier keys + if event.modifiers() & Qt.KeyboardModifier.ControlModifier: + event.accept() + + # 1. Capture absolute workspace metrics before calculating adjustments + h_scrollbar = self.horizontalScrollBar() + scroll_current = h_scrollbar.value() # Maps to S_current + zoom_current = self.zoom_level + t_playhead = self.playhead_time_seconds + + # 2. Evaluate target scaling ratio increments + angle_delta = event.angleDelta().y() + zoom_factor = 1.1 if angle_delta > 0 else 0.9 + zoom_new = max(10.0, min(2000.0, zoom_current * zoom_factor)) + + # 3. Apply the coordinate conservation model to isolate scroll_new offsets + scroll_new = scroll_current + t_playhead * (zoom_new - zoom_current) + + # 4. Overwrite parameters and prompt vector updates on the QPainter surface + self.zoom_level = zoom_new + self.update_timeline_graphics() # Invokes the multi-channel waveform redraw routines + + # Commit updated scroll values immediately to lock playhead layout tracking + h_scrollbar.setValue(int(scroll_new)) + else: + # Drop down to default native vertical/horizontal scroll handling patterns + super().wheelEvent(event) + +``` + +--- + +## 5. UI Operational State Comparison + +Based on the verified structural architecture of the system layout: + +* **Baseline Initial State:** Audio waveform paths render at standard macro scaling bounds (evaluating approximately to a few hundred pixel columns per second of timeline data). The distinct vertical red playback cursor path line tracking the $4.00\text{ s}$ clock milestone renders centered in the visible workspace view. +* **Post Maximum Zoom-In State:** Symmetrical audio waveform data lines stretch horizontally to their maximum viewport scaling boundaries (exposing granular peak structures explicitly). By executing the conservation equations defined in Section 2.2, the horizontal scroll container shifts rightward, keeping the red cursor line locked to its absolute pixel column coordinate on the screen instead of letting it slip past the viewport limits. + +This technical spec document establishes the supreme design token rules for compiling and verifying zooming workflows on the arrangement canvas. \ No newline at end of file diff --git a/25_ZOOM.md b/25_ZOOM.md new file mode 100644 index 0000000..dc4bb40 --- /dev/null +++ b/25_ZOOM.md @@ -0,0 +1,121 @@ +Here is the translation of the document into English Markdown format: + +# Geometric Analysis: Progressive Center Drift During Asymmetrical Zoom & Pre-Roll Gutter Solution + +This document analyzes the mathematical root cause of center drift during zoom operations at asymmetric timeline markers (e.g., zooming at $1\text{ s}$ drifts drastically compared to $5\text{ s}$ on a $10\text{ s}$ total track length). It also provides a structural solution using boundary margins (**Pre-roll/Post-roll Gutter**) to lock the absolute anchor point in all interaction scenarios. + +--- + +## 1. Mathematical Proof: Why Zooming at $1\text{ s}$ Drifts Further Than $5\text{ s}$ + +This visual discrepancy is not caused by random calculation precision errors, but is the mathematical result of boundary clamping (**Scroll Left Clamping**). + +### 1.1. Conservation Equation for Mouse/Playhead Anchor Points + +To preserve the visual location of time marker $t$ at pixel coordinate $X_{\text{viewport}}$ relative to the display before and after changing the zoom scale factor ($Z_{\text{current}} \rightarrow Z_{\text{new}}$), the required horizontal scroll offset $S_{\text{new}}$ (`scrollLeft`) must satisfy: + +$$S_{\text{new}} = (t \times Z_{\text{new}}) - X_{\text{viewport}}$$ + +### 1.2. Scenario Analysis: Zooming Out at $X_{\text{viewport}} = 300\text{ px}$ (Cursor at Screen Center) + +Assume the timeline is zoomed out significantly, reducing the zoom ratio down to $Z_{\text{new}} = 100\text{ px/second}$. + +#### Scenario A: Operator zooms at the central symmetrical coordinate $t = 5.0\text{ s}$ + +Applying the target scroll position calculation: + +$$S_{\text{new}} = (5.0 \times 100) - 300 = 500 - 300 = +200\text{ px}$$ + +* **Result:** Because $+200\text{ px} \ge 0$, the scroll position resides safely within physical boundary limits. The browser sets `scrollLeft = 200` smoothly. The $5.0\text{ s}$ point remains locked at position $300\text{ px}$ on the screen with a spatial drift of $0\text{ px}$. + +#### Scenario B: Operator zooms at an asymmetrical coordinate near the left edge $t = 1.0\text{ s}$ + +Applying the target scroll position calculation: + +$$S_{\text{new}} = (1.0 \times 100) - 300 = 100 - 300 = -200\text{ px}$$ + +* **Critical Issue:** Browsers and operating hardware cannot execute negative scroll values ($scrollLeft < 0$), instantly **clamping the horizontal scroll position at the minimum boundary $S_{\text{clamped}} = 0\text{ px}$**. +* Due to this clamping, the actual on-screen rendering coordinate of the $1.0\text{ s}$ milestone drifts to: + +$$X_{\text{viewport\_actual}} = (1.0 \times 100) - 0 = 100\text{ px}$$ + +* **Visual Discrepancy:** The $1.0\text{ s}$ marker, which should remain stationary at coordinate $300\text{ px}$, is **pulled to the left to coordinate $100\text{ px}$** (resulting in a spatial shift of $200\text{ px}$). + +> **Geometric Principle:** The smaller the zoom anchor timestamp $t$ (the closer it sits to the left boundary), the more likely the required scroll position $S_{\text{new}}$ drops below zero to be clamped at $0$, increasing visual waveform displacement during zoom-out operations. + +--- + +## 2. Professional DAW Solution: Pre-Roll & Post-Roll Gutters + +To permanently eliminate this behavior and give SonicForge Studio a professional zoom experience similar to Reaper or Adobe Audition, apply a **Pre-roll & Post-roll Gutter (Boundary Margins)**. + +```text +|<─────────────────── Actual Timeline Scroll Width ───────────────────>| ++──────────────────────────┬───────────────────────────────────────────+ +| [ Pre-roll Gutter ] │ 0:00.000 (Actual music start time) | +| (Width: W_viewport) │ | +| (scrollLeft can run here)│ [ Waveform and track grid start here... ]| ++──────────────────────────┴───────────────────────────────────────────+ +▲ +│ [ 1.0s anchor point remains 100% stationary here ] +│ Because the scrollbar is allowed to retreat negatively into the gutter! + +``` + +1. **Enabling Visual Negative Scrolling:** Instead of starting the timeline canvas at pixel coordinate $0\text{ px}$ (corresponding to $0.0\text{ s}$), prepend an empty padding region (**Gutter**) equal to the full viewport width $W_{\text{viewport}}$ (e.g., $1200\text{ px}$) before the $0.0\text{ s}$ mark. +2. **Updated Coordinate Mapping Formula:** +The physical pixel coordinate $X$ of timestamp $t$ on the Canvas includes the offset padding: + +$$X_t = (t \times Z) + W_{\text{pre\_roll}}$$ + +3. **Unclamped Scroll Conservation Equation:** +When zooming at any asymmetrical timestamp (including $0.1\text{ s}$ or $0.0\text{ s}$): + +$$S_{\text{new}} = (t \times Z_{\text{new}}) + W_{\text{pre\_roll}} - X_{\text{viewport}}$$ + +* Because $W_{\text{pre\_roll}}$ is added, $S_{\text{new}}$ remains greater than $0$ during standard zoom-out actions, eliminating the clamp at $0$. Your $1.0\text{ s}$ timestamp or playhead stays stationary, the waveform graphics scale symmetrically, and the $0.0\text{ s}$ mark smoothly recedes toward the center of the viewport, exposing a subtle, professional dark gray pre-roll gutter area in front of the track. + +--- + +## 3. Implementing the Boundary Lock Algorithm in Source Code + +Below is the upgraded mouse wheel zoom event handler for `index.html`, incorporating pre-roll margin compensation: + +```javascript +const handleTimelineZoomWithGutter = (e) => { + if (!e.ctrlKey) return; + e.preventDefault(); + + const timelineWrapper = timelineWrapperRef.current; + if (!timelineWrapper) return; + + const rect = timelineWrapper.getBoundingClientRect(); + const mouseXInViewport = e.clientX - rect.left; + + // Pre-roll gutter padding equal to half the viewport width to allow scrolling past 0s + const preRollPadding = rect.width / 2; + + const scrollLeftCurrent = timelineWrapper.scrollLeft; + const zoomCurrent = zoom; + const anchorTime = (scrollLeftCurrent + mouseXInViewport - preRollPadding) / zoomCurrent; + + const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1; + let zoomNew = zoomCurrent * zoomFactor; + + // Apply zoom constraints + zoomNew = Math.max(minZoom, Math.min(2000, zoomNew)); + + // Calculate new scroll offset preserving the anchor point under the cursor + const scrollLeftNew = (anchorTime * zoomNew) + preRollPadding - mouseXInViewport; + + // Update state + setZoom(zoomNew); + + requestAnimationFrame(() => { + timelineWrapper.scrollLeft = scrollLeftNew; + }); +}; + +``` + +This upgrade enables SonicForge Studio to achieve zero-latency, sample-accurate zooming with studio-grade anchor locking! \ No newline at end of file diff --git a/26_ZOOM_HTML.md b/26_ZOOM_HTML.md new file mode 100644 index 0000000..4c92193 --- /dev/null +++ b/26_ZOOM_HTML.md @@ -0,0 +1,77 @@ + +## 💡 Nguyên lý tính toán đúng (Zoom to Mouse Pointer) + +Để điểm dưới con trỏ chuột đứng yên tại đúng vị trí đó sau khi zoom, bạn cần giữ nguyên **tỷ lệ thời gian (time ratio)** tại điểm con trỏ chuột so với chiều rộng hiện tại của vùng hiển thị (Viewport). + +### **Công thức chuyển đổi:** + +Giả sử thanh cuộn (Scrollbar) có vị trí xả hiện tại là `scrollLeft`: + +1. **Tìm điểm thời gian tương đối tại vị trí chuột ($T_{mouse}$):** + +$$T_{mouse} = \text{scrollLeft} + X_{mouse\_in\_canvas}$$ + + +2. **Tính tỷ lệ zoom mới ($S_{new} / S_{old}$):** + +$$\text{ratio} = \frac{\text{scale}_{new}}{\text{scale}_{old}}$$ + + +3. **Cập nhật vị trí cuộn mới (`scrollLeft_{new}`):** + +$$\text{scrollLeft}_{new} = (T_{mouse} \times \text{ratio}) - X_{mouse\_in\_canvas}$$ + + + +--- + +## 🛠️ Code mẫu ngắn gọn (Pure JS / Canvas) + +Dưới đây là đoạn code lắng nghe sự kiện `wheel` (lăn chuột) trên Waveform Canvas/Container để xử lý zoom đúng chuẩn các phần mềm DAW: + +```javascript +const container = document.getElementById('waveform-container'); +let pixelsPerSecond = 100; // Tỉ lệ Zoom ban đầu (mức Zoom) + +container.addEventListener('wheel', (e) => { + // Chỉ thực hiện zoom khi giữ phím Ctrl (hoặc bạn có thể bỏ condition này nếu muốn lăn chuột là zoom) + if (!e.ctrlKey) return; + e.preventDefault(); + + // 1. Lấy vị trí con trỏ chuột so với viền trái của Waveform Container (Viewport) + const rect = container.getBoundingClientRect(); + const mouseX = e.clientX - rect.left; + + // 2. Tính tọa độ thời gian (giây) tại điểm con trỏ chuột đang chỉ vào + const currentScrollLeft = container.scrollLeft; + const timeAtMouse = (currentScrollLeft + mouseX) / pixelsPerSecond; + + // 3. Tính tỉ lệ zoom mới (Phóng to / Thu nhỏ) + const zoomFactor = e.deltaY < 0 ? 1.2 : 0.8; // Lăn lên = phóng to, lăn xuống = thu nhỏ + const newPixelsPerSecond = Math.max(10, Math.min(2000, pixelsPerSecond * zoomFactor)); + + // 4. Cập nhật tỉ lệ zoom mới vào ứng dụng + pixelsPerSecond = newPixelsPerSecond; + + // (Thực hiện render lại Waveform với pixelsPerSecond mới tại đây) + renderWaveform(); + + // 5. CẬP NHẬT SCROLLBAR: Cuộn lại sao cho điểm 'timeAtMouse' vẫn nằm đúng ở 'mouseX' + container.scrollLeft = (timeAtMouse * pixelsPerSecond) - mouseX; +}, { passive: false }); + +``` + +--- + +## 📌 Nhắc nhở thêm nếu dùng thư viện: + +* **Nếu bạn dùng Canvas thuần:** Đảm bảo hàm `renderWaveform()` vẽ lại waveform dựa theo `pixelsPerSecond` mới trước khi cập nhật `container.scrollLeft`. +* **Nếu bạn đang dùng `wavesurfer.js`:** Thư viện này đã hỗ trợ sẵn logic này, bạn chỉ cần dùng method: +```javascript +wavesurfer.zoom(newPxPerSec); + +``` + + +*(Nếu WaveSurfer bản cũ bị trôi, bạn áp dụng lại công thức tính `scrollLeft` ở trên sau khi gọi lệnh `zoom()`)*. diff --git a/27_ZOOM_TECH.md b/27_ZOOM_TECH.md new file mode 100644 index 0000000..620eeff --- /dev/null +++ b/27_ZOOM_TECH.md @@ -0,0 +1,186 @@ + +# Giải pháp Virtual Viewport Rendering cho Waveform Zoom + +Phương pháp này sử dụng kỹ thuật **Virtual Viewport Rendering** (Rendering theo vùng nhìn). + +### Cơ chế hoạt động: + +1. **Thanh cuộn ảo (Virtual Scrollbar):** Duy trì một thẻ `div` ẩn (hoặc gán chiều rộng cho container) bằng chiều rộng lý thuyết của toàn bộ file audio khi zoom. Nhưng **Canvas thực tế thì luôn cố định chiều rộng bằng khung nhìn (Viewport)**. +2. **Xử lý phần ẩn:** Các phần ngoài khung nhìn sẽ **không được vẽ/render lên Canvas**. Dữ liệu âm thanh gốc (`Audio Buffer` / `Array Data`) vẫn nằm nguyên trong bộ nhớ (RAM/JS Array), không bị ảnh hưởng. +3. **Khi Zoom Out:** Tính toán lại khoảng thời gian `[startTime, endTime]` rộng hơn, lấy mảng dữ liệu sample tương ứng trong khoảng đó và vẽ đè lại lên Canvas. + +--- + +## 1. Kiến trúc tổng quan + +```text +[ Toàn bộ Audio Buffer trong Memory: 0s ----------------------> 180s ] + | Khung nhìn | + v (Canvas Fixed) v + [startTime ------------> endTime] + +``` + +--- + +## 2. Mã nguồn triển khai (Pure HTML5 & JS) + +Đoạn code bên dưới minh họa cơ chế zoom chính xác tại vị trí con trỏ chuột mà không sợ quá tải Canvas hay nhảy vị trí: + +```html + + + + + + + + +
+
+ +
+ + + + + + +``` + +--- + +## 3. Các điểm quan trọng giúp giải quyết bài toán + +* **Thẻ Canvas cố định (`position: sticky`):** +Dù zoom $10\times, 100\times$ hay $1000\times$, chiều rộng Canvas không thay đổi (luôn là `800px`). Điều này giúp tránh hoàn toàn việc vượt giới hạn chiều rộng của trình duyệt (`max canvas width limit`). +* **Khôi phục dữ liệu khi Zoom Out:** +Khi zoom out, `pixelsPerSecond` giảm xuống. Hàm `renderVisibleWaveform()` tự động mở rộng khoảng `[startTime, endTime]` (ví dụ từ 2 giây thành 30 giây). Mảng `audioSamples` gốc trong RAM vẫn giữ nguyên, hàm vẽ chỉ cần duyệt mảng rộng hơn và vẽ lại lên Canvas. +* **Tối ưu hiệu năng:** +Hệ thống chỉ tốn tài nguyên GPU/CPU để render đúng số lượng cột sóng xuất hiện trong khung nhìn thay vì vẽ toàn bộ file audio dài. \ No newline at end of file diff --git a/app/static/js/app.precompiled.js b/app/static/js/app.precompiled.js index d287e12..565f06c 100644 --- a/app/static/js/app.precompiled.js +++ b/app/static/js/app.precompiled.js @@ -191,29 +191,23 @@ const WaveformLane = ({ onSplitTrackAtTime, onEditClipInSubTab, snapValue, - bpm + bpm, + scrollLeft }) => { const canvasRef = useRef(null); + const drawWidth = Math.min(timelineWidth, viewportWidth); useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext('2d'); const dpr = window.devicePixelRatio || 1; - const wrapper = canvas.parentElement ? canvas.parentElement.parentElement : null; - - // Viewport Virtualization: canvas pixel width is strictly capped to visible window width (e.g. 1200px) - const scrollLeft = wrapper ? wrapper.scrollLeft : 0; - const vWidth = viewportWidth || (wrapper ? wrapper.clientWidth : 1200); - const drawWidth = Math.min(timelineWidth, Math.max(vWidth, 1200)); + const scrollLeftVal = scrollLeft || 0; + const vWidth = viewportWidth || 1200; const height = canvas.parentElement ? canvas.parentElement.clientHeight : 96; canvas.width = Math.min(Math.round(drawWidth * dpr), 32768); canvas.height = Math.min(Math.round(height * dpr), 32768); ctx.scale(dpr, dpr); ctx.imageSmoothingEnabled = false; - - // Position canvas element at current scrollLeft inside track container - canvas.style.position = 'absolute'; - canvas.style.left = `${scrollLeft}px`; canvas.style.width = `${drawWidth}px`; canvas.style.height = `${height}px`; ctx.fillStyle = isSelected ? '#2a2a2a' : track.id % 2 === 0 ? '#181818' : '#1d1d1d'; @@ -222,8 +216,8 @@ const WaveformLane = ({ // Grid lines based on Snap value ctx.strokeStyle = 'rgba(255, 255, 255, 0.03)'; ctx.lineWidth = 1; - const tStart = scrollLeft / zoom; - const tEnd = (scrollLeft + drawWidth) / zoom; + const tStart = scrollLeftVal / zoom; + const tEnd = (scrollLeftVal + drawWidth) / zoom; let gridSpacing = 1.0; if (snapValue && snapValue !== 'free') { const beatDuration = 60 / parseFloat(bpm || 120); @@ -456,19 +450,18 @@ const WaveformLane = ({ ctx.lineWidth = 1; ctx.strokeRect(hlLeftLocal, 0, hlWidth, height); } - }, [track, zoom, timelineWidth, viewportWidth, isSelected, markers, selectionMode, localSelectionTrackId, localSelLeft, localSelRight, snapValue, bpm]); - return /*#__PURE__*/React.createElement("div", { + }, [track, zoom, timelineWidth, viewportWidth, isSelected, markers, selectionMode, localSelectionTrackId, localSelLeft, localSelRight, snapValue, bpm, scrollLeft]); + return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("div", { + key: "virtual-spacer", style: { width: `${timelineWidth}px`, - height: '100%', - position: 'relative', - overflow: 'hidden' + height: '1px', + pointerEvents: 'none' } - }, /*#__PURE__*/React.createElement("canvas", { + }), /*#__PURE__*/React.createElement("canvas", { ref: canvasRef, style: { - position: 'absolute', - top: 0, + position: 'sticky', left: 0, imageRendering: 'pixelated' }, @@ -476,9 +469,7 @@ const WaveformLane = ({ onMouseMove: e => { 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 x = e.clientX - rect.left + (scrollLeft || 0); const time = x / zoom; const clips = track.clips && track.clips.length > 0 ? track.clips : track.buffer ? [{ id: 'default', @@ -547,9 +538,7 @@ const WaveformLane = ({ // Ignore right-click for local selection drag (context menu handles it) if (e.button === 2) 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 x = e.clientX - rect.left + (scrollLeft || 0); const time = Math.max(0, x / zoom); onSelectTrack(track.id); const clips = track.clips && track.clips.length > 0 ? track.clips : track.buffer ? [{ @@ -686,9 +675,7 @@ const WaveformLane = ({ }, onDoubleClick: e => { 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 x = e.clientX - rect.left + (scrollLeft || 0); const time = Math.max(0, x / zoom); const clips = track.clips && track.clips.length > 0 ? track.clips : track.buffer ? [{ id: 'default', @@ -711,13 +698,11 @@ const WaveformLane = ({ e.stopPropagation(); onSelectTrack(track.id); 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 x = e.clientX - rect.left + (scrollLeft || 0); const time = Math.max(0, x / zoom); if (onContextMenu) onContextMenu(e, track.id, time); } - })); + }))); }; const TempoTrackLane = ({ bpm, @@ -726,33 +711,30 @@ const TempoTrackLane = ({ viewportWidth, onPlayheadSet, snapValue, - onRulerMouseDown + onRulerMouseDown, + scrollLeft }) => { const canvasRef = useRef(null); + const drawWidth = Math.min(timelineWidth, viewportWidth); useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext('2d'); const dpr = window.devicePixelRatio || 1; - const wrapper = canvas.parentElement ? canvas.parentElement.parentElement : null; - const scrollLeft = wrapper ? wrapper.scrollLeft : 0; - const vWidth = viewportWidth || (wrapper ? wrapper.clientWidth : 1200); - const drawWidth = Math.min(timelineWidth, Math.max(vWidth, 1200)); + const scrollLeftVal = scrollLeft || 0; const height = canvas.parentElement ? canvas.parentElement.clientHeight : 40; canvas.width = Math.min(Math.round(drawWidth * dpr), 32768); canvas.height = Math.min(Math.round(height * dpr), 32768); ctx.scale(dpr, dpr); ctx.imageSmoothingEnabled = false; - canvas.style.position = 'absolute'; - canvas.style.left = `${scrollLeft}px`; canvas.style.width = `${drawWidth}px`; canvas.style.height = `${height}px`; ctx.fillStyle = '#1a1a2e'; ctx.fillRect(0, 0, drawWidth, height); const beatDuration = 60 / bpm; const barDuration = beatDuration * 4; - const tStart = scrollLeft / zoom; - const tEnd = (scrollLeft + drawWidth) / zoom; + const tStart = scrollLeftVal / zoom; + const tEnd = (scrollLeftVal + drawWidth) / zoom; const firstBeat = Math.floor(tStart / beatDuration) * beatDuration; for (let t = firstBeat; t <= tEnd; t += beatDuration) { const beatNum = Math.floor(t / beatDuration) + 1; @@ -804,28 +786,25 @@ const TempoTrackLane = ({ ctx.font = 'bold 10px Inter, sans-serif'; ctx.textAlign = 'right'; ctx.fillText(`${bpm} BPM`, drawWidth - 6, 12); - }, [bpm, zoom, timelineWidth, viewportWidth, snapValue]); - return /*#__PURE__*/React.createElement("div", { + }, [bpm, zoom, timelineWidth, viewportWidth, snapValue, scrollLeft]); + return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("div", { + key: "virtual-spacer-tempo", style: { width: `${timelineWidth}px`, - height: '100%', - position: 'relative', - overflow: 'hidden' + height: '1px', + pointerEvents: 'none' } - }, /*#__PURE__*/React.createElement("canvas", { + }), /*#__PURE__*/React.createElement("canvas", { ref: canvasRef, style: { - position: 'absolute', - top: 0, + position: 'sticky', left: 0, imageRendering: 'pixelated' }, className: "cursor-crosshair", onMouseDown: e => { - const wrapper = canvasRef.current?.parentElement?.parentElement; - const scrollLeft = wrapper ? wrapper.scrollLeft : 0; const rect = canvasRef.current.getBoundingClientRect(); - const x = e.clientX - rect.left + scrollLeft; + const x = e.clientX - rect.left + (scrollLeft || 0); const time = Math.max(0, x / zoom); if (e.shiftKey) { e.preventDefault(); @@ -837,7 +816,7 @@ const TempoTrackLane = ({ onPlayheadSet(time, e.shiftKey); } } - })); + }))); }; // ── Sub-Tab Waveform Component (LOOP_EDITOR_2.md §1.2 & SUB_EDITOR.md) ── @@ -892,8 +871,8 @@ const SubTabWaveform = ({ const vWidth = wrapper ? wrapper.clientWidth : 1200; const drawWidth = Math.min(timelineWidth, Math.max(vWidth, 1200)); const h = canvas.parentElement ? canvas.parentElement.clientHeight : 200; - canvas.width = Math.min(Math.round(drawWidth * dpr), 32768); - canvas.height = Math.min(Math.round(h * dpr), 32768); + canvas.width = Math.round(drawWidth * dpr); + canvas.height = Math.round(h * dpr); ctx.scale(dpr, dpr); ctx.imageSmoothingEnabled = false; canvas.style.position = 'absolute'; @@ -2178,8 +2157,8 @@ const GraphEditorCanvas = ({ const vWidth = wrapper ? wrapper.clientWidth : 1200; const drawWidth = Math.min(timelineWidth, Math.max(vWidth, 1200)); const h = canvas.parentElement ? canvas.parentElement.clientHeight : 200; - canvas.width = Math.min(Math.round(drawWidth * dpr), 32768); - canvas.height = Math.min(Math.round(h * dpr), 32768); + canvas.width = Math.round(drawWidth * dpr); + canvas.height = Math.round(h * dpr); ctx.scale(dpr, dpr); ctx.imageSmoothingEnabled = false; canvas.style.position = 'absolute'; @@ -2985,9 +2964,7 @@ const App = () => { const [localSelectionTrackId, setLocalSelectionTrackId] = useState(null); const [localSelectionStart, setLocalSelectionStart] = useState(null); const [localSelectionEnd, setLocalSelectionEnd] = useState(null); - const [mainZoom, setMainZoom] = useState(100); - const [subTabZoom, setSubTabZoom] = useState(100); - let zoom = 100; + const [zoom, setZoom] = useState(100); const [isLoopingSelection, setIsLoopingSelection] = useState(false); const [beginBar, setBeginBar] = useState(1); const [endBar, setEndBar] = useState(1); @@ -3105,7 +3082,6 @@ const App = () => { // ── Tab System (LOOP_EDITOR_2.md §1) ── const [activeTab, setActiveTab] = useState('main'); - zoom = activeTab === 'main' ? mainZoom : subTabZoom; const [subTabSelectedNodeTime, setSubTabSelectedNodeTime] = useState(null); const [subTabNormVal, setSubTabNormVal] = useState(0); const [subTabGainVal, setSubTabGainVal] = useState(100); @@ -3266,10 +3242,12 @@ const App = () => { }, [activeTool, activeTab]); const timelineWrapperRef = useRef(null); const tcpContainerRef = useRef(null); + const [scrollLeft, setScrollLeft] = useState(0); const handleTimelineScroll = e => { if (tcpContainerRef.current) { tcpContainerRef.current.scrollTop = e.currentTarget.scrollTop; } + setScrollLeft(e.currentTarget.scrollLeft); }; const handleTCPScroll = e => { if (timelineWrapperRef.current) { @@ -3821,8 +3799,8 @@ const App = () => { const ctx = canvas.getContext('2d'); const dpr = window.devicePixelRatio || 1; const rect = canvas.getBoundingClientRect(); - canvas.width = Math.min(Math.round(rect.width * dpr), 32768); - canvas.height = Math.min(Math.round(rect.height * dpr), 32768); + canvas.width = rect.width * dpr; + canvas.height = rect.height * dpr; ctx.scale(dpr, dpr); const w = rect.width; const h = rect.height; @@ -4820,7 +4798,7 @@ const App = () => { }, [zoom, maxDuration, viewportWidth]); useEffect(() => { if (zoom < minZoom) { - if (activeTab === 'main') setMainZoom(minZoom); else setSubTabZoom(minZoom); + setZoom(minZoom); } }, [minZoom]); const playheadLeftPos = useMemo(() => currentTime * zoom, [currentTime, zoom]); @@ -4908,7 +4886,7 @@ const App = () => { const mouseXInCanvas = mouseXInViewport + timeline.scrollLeft; const anchorTime = mouseXInCanvas / zoom; const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1; - const updateZoom = prevZoom => { + setZoom(prevZoom => { let newZoom = prevZoom * zoomFactor; if (newZoom < minZoom) newZoom = minZoom; if (newZoom > 50000) newZoom = 50000; @@ -4917,8 +4895,7 @@ const App = () => { timeline.scrollLeft = newMouseXInCanvas - mouseXInViewport; }); return newZoom; - }; - if (activeTab === 'main') setMainZoom(updateZoom(mainZoom)); else setSubTabZoom(updateZoom(subTabZoom)); + }); } else if (e.shiftKey) { e.preventDefault(); timeline.scrollLeft += e.deltaY;