diff --git a/app/static/js/app.jsx b/app/static/js/app.jsx index 94213d8..23a9ad0 100644 --- a/app/static/js/app.jsx +++ b/app/static/js/app.jsx @@ -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.round(drawWidth * dpr); - canvas.height = Math.round(height * dpr); + 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'; @@ -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,9 +698,7 @@ 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); } @@ -726,25 +711,22 @@ 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.round(drawWidth * dpr); - canvas.height = Math.round(height * dpr); + 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'; @@ -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(); @@ -3263,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) { @@ -8376,7 +8357,8 @@ const App = () => { viewportWidth: viewportWidth, onPlayheadSet: handlePlayheadSet, snapValue: snapValue, - onRulerMouseDown: handleRulerMouseDown + onRulerMouseDown: handleRulerMouseDown, + scrollLeft: scrollLeft }))), /*#__PURE__*/React.createElement("div", { className: "flex-1 flex flex-col divide-y divide-[#141414] relative bg-[#111111] min-h-full" }, tracks.map((track, idx) => { @@ -8386,7 +8368,7 @@ const App = () => { style: { height: `${track.height || 96}px` }, - className: `shrink-0 relative overflow-hidden border-b border-[#141414] hover:bg-zinc-850/5 transition-colors ${isSelected ? 'bg-zinc-800/10' : ''}`, + className: `shrink-0 relative border-b border-[#141414] hover:bg-zinc-850/5 transition-colors ${isSelected ? 'bg-zinc-800/10' : ''}`, onDragOver: e => e.preventDefault(), onDrop: e => { e.preventDefault(); @@ -8397,6 +8379,7 @@ const App = () => { track: track, zoom: zoom, timelineWidth: timelineWidth, + viewportWidth: viewportWidth, onSelectRange: handleSelectRange, onPlayheadSet: handlePlayheadSet, isSelected: isSelected, @@ -8428,7 +8411,8 @@ const App = () => { onSetLocalSelectionStart: setLocalSelectionStart, onSetLocalSelectionEnd: setLocalSelectionEnd, localSelLeft: localSelectionStart !== null && localSelectionEnd !== null ? Math.min(localSelectionStart, localSelectionEnd) : null, - localSelRight: localSelectionStart !== null && localSelectionEnd !== null ? Math.max(localSelectionStart, localSelectionEnd) : null + localSelRight: localSelectionStart !== null && localSelectionEnd !== null ? Math.max(localSelectionStart, localSelectionEnd) : null, + scrollLeft: scrollLeft }), selectionMode === 'local' && localSelectionTrackId === track.id && localSelectionStart !== null && localSelectionEnd !== null && Math.abs(localSelectionEnd - localSelectionStart) > 0 && /*#__PURE__*/React.createElement("div", { className: "absolute top-0 bottom-0 border-l border-r border-amber-400 bg-amber-500/20 z-20 pointer-events-none", style: { diff --git a/app/static/js/app.precompiled.js b/app/static/js/app.precompiled.js index 565f06c..23a9ad0 100644 --- a/app/static/js/app.precompiled.js +++ b/app/static/js/app.precompiled.js @@ -216,8 +216,8 @@ const WaveformLane = ({ // Grid lines based on Snap value ctx.strokeStyle = 'rgba(255, 255, 255, 0.03)'; ctx.lineWidth = 1; - const tStart = scrollLeftVal / zoom; - const tEnd = (scrollLeftVal + drawWidth) / zoom; + const tStart = scrollLeft / zoom; + const tEnd = (scrollLeft + drawWidth) / zoom; let gridSpacing = 1.0; if (snapValue && snapValue !== 'free') { const beatDuration = 60 / parseFloat(bpm || 120); @@ -702,7 +702,7 @@ const WaveformLane = ({ const time = Math.max(0, x / zoom); if (onContextMenu) onContextMenu(e, track.id, time); } - }))); + })); }; const TempoTrackLane = ({ bpm, @@ -733,8 +733,8 @@ const TempoTrackLane = ({ ctx.fillRect(0, 0, drawWidth, height); const beatDuration = 60 / bpm; const barDuration = beatDuration * 4; - const tStart = scrollLeftVal / zoom; - const tEnd = (scrollLeftVal + drawWidth) / zoom; + const tStart = scrollLeft / zoom; + const tEnd = (scrollLeft + drawWidth) / zoom; const firstBeat = Math.floor(tStart / beatDuration) * beatDuration; for (let t = firstBeat; t <= tEnd; t += beatDuration) { const beatNum = Math.floor(t / beatDuration) + 1; @@ -816,7 +816,7 @@ const TempoTrackLane = ({ onPlayheadSet(time, e.shiftKey); } } - }))); + })); }; // ── Sub-Tab Waveform Component (LOOP_EDITOR_2.md §1.2 & SUB_EDITOR.md) ── @@ -8357,7 +8357,8 @@ const App = () => { viewportWidth: viewportWidth, onPlayheadSet: handlePlayheadSet, snapValue: snapValue, - onRulerMouseDown: handleRulerMouseDown + onRulerMouseDown: handleRulerMouseDown, + scrollLeft: scrollLeft }))), /*#__PURE__*/React.createElement("div", { className: "flex-1 flex flex-col divide-y divide-[#141414] relative bg-[#111111] min-h-full" }, tracks.map((track, idx) => { @@ -8367,7 +8368,7 @@ const App = () => { style: { height: `${track.height || 96}px` }, - className: `shrink-0 relative overflow-hidden border-b border-[#141414] hover:bg-zinc-850/5 transition-colors ${isSelected ? 'bg-zinc-800/10' : ''}`, + className: `shrink-0 relative border-b border-[#141414] hover:bg-zinc-850/5 transition-colors ${isSelected ? 'bg-zinc-800/10' : ''}`, onDragOver: e => e.preventDefault(), onDrop: e => { e.preventDefault(); @@ -8378,6 +8379,7 @@ const App = () => { track: track, zoom: zoom, timelineWidth: timelineWidth, + viewportWidth: viewportWidth, onSelectRange: handleSelectRange, onPlayheadSet: handlePlayheadSet, isSelected: isSelected, @@ -8409,7 +8411,8 @@ const App = () => { onSetLocalSelectionStart: setLocalSelectionStart, onSetLocalSelectionEnd: setLocalSelectionEnd, localSelLeft: localSelectionStart !== null && localSelectionEnd !== null ? Math.min(localSelectionStart, localSelectionEnd) : null, - localSelRight: localSelectionStart !== null && localSelectionEnd !== null ? Math.max(localSelectionStart, localSelectionEnd) : null + localSelRight: localSelectionStart !== null && localSelectionEnd !== null ? Math.max(localSelectionStart, localSelectionEnd) : null, + scrollLeft: scrollLeft }), selectionMode === 'local' && localSelectionTrackId === track.id && localSelectionStart !== null && localSelectionEnd !== null && Math.abs(localSelectionEnd - localSelectionStart) > 0 && /*#__PURE__*/React.createElement("div", { className: "absolute top-0 bottom-0 border-l border-r border-amber-400 bg-amber-500/20 z-20 pointer-events-none", style: { diff --git a/app/storage/sonicforge.db b/app/storage/sonicforge.db index 79493fe..42eb543 100644 Binary files a/app/storage/sonicforge.db and b/app/storage/sonicforge.db differ