fix: zoom in

This commit is contained in:
2026-07-21 11:36:41 +07:00
parent 2d9744c13a
commit 184a63b331
3 changed files with 52 additions and 65 deletions
+40 -56
View File
@@ -191,29 +191,23 @@ const WaveformLane = ({
onSplitTrackAtTime, onSplitTrackAtTime,
onEditClipInSubTab, onEditClipInSubTab,
snapValue, snapValue,
bpm bpm,
scrollLeft
}) => { }) => {
const canvasRef = useRef(null); const canvasRef = useRef(null);
const drawWidth = Math.min(timelineWidth, viewportWidth);
useEffect(() => { useEffect(() => {
const canvas = canvasRef.current; const canvas = canvasRef.current;
if (!canvas) return; if (!canvas) return;
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
const dpr = window.devicePixelRatio || 1; const dpr = window.devicePixelRatio || 1;
const wrapper = canvas.parentElement ? canvas.parentElement.parentElement : null; const scrollLeftVal = scrollLeft || 0;
const vWidth = viewportWidth || 1200;
// 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 height = canvas.parentElement ? canvas.parentElement.clientHeight : 96; const height = canvas.parentElement ? canvas.parentElement.clientHeight : 96;
canvas.width = Math.round(drawWidth * dpr); canvas.width = Math.min(Math.round(drawWidth * dpr), 32768);
canvas.height = Math.round(height * dpr); canvas.height = Math.min(Math.round(height * dpr), 32768);
ctx.scale(dpr, dpr); ctx.scale(dpr, dpr);
ctx.imageSmoothingEnabled = false; 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.width = `${drawWidth}px`;
canvas.style.height = `${height}px`; canvas.style.height = `${height}px`;
ctx.fillStyle = isSelected ? '#2a2a2a' : track.id % 2 === 0 ? '#181818' : '#1d1d1d'; ctx.fillStyle = isSelected ? '#2a2a2a' : track.id % 2 === 0 ? '#181818' : '#1d1d1d';
@@ -456,19 +450,18 @@ const WaveformLane = ({
ctx.lineWidth = 1; ctx.lineWidth = 1;
ctx.strokeRect(hlLeftLocal, 0, hlWidth, height); ctx.strokeRect(hlLeftLocal, 0, hlWidth, height);
} }
}, [track, zoom, timelineWidth, viewportWidth, isSelected, markers, selectionMode, localSelectionTrackId, localSelLeft, localSelRight, snapValue, bpm]); }, [track, zoom, timelineWidth, viewportWidth, isSelected, markers, selectionMode, localSelectionTrackId, localSelLeft, localSelRight, snapValue, bpm, scrollLeft]);
return /*#__PURE__*/React.createElement("div", { return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("div", {
key: "virtual-spacer",
style: { style: {
width: `${timelineWidth}px`, width: `${timelineWidth}px`,
height: '100%', height: '1px',
position: 'relative', pointerEvents: 'none'
overflow: 'hidden'
} }
}, /*#__PURE__*/React.createElement("canvas", { }), /*#__PURE__*/React.createElement("canvas", {
ref: canvasRef, ref: canvasRef,
style: { style: {
position: 'absolute', position: 'sticky',
top: 0,
left: 0, left: 0,
imageRendering: 'pixelated' imageRendering: 'pixelated'
}, },
@@ -476,9 +469,7 @@ const WaveformLane = ({
onMouseMove: e => { onMouseMove: e => {
if (!canvasRef.current) return; if (!canvasRef.current) return;
const rect = canvasRef.current.getBoundingClientRect(); const rect = canvasRef.current.getBoundingClientRect();
const wrapper = canvasRef.current?.parentElement?.parentElement; const x = e.clientX - rect.left + (scrollLeft || 0);
const scrollLeft = wrapper ? wrapper.scrollLeft : 0;
const x = e.clientX - rect.left + scrollLeft;
const time = x / zoom; const time = x / zoom;
const clips = track.clips && track.clips.length > 0 ? track.clips : track.buffer ? [{ const clips = track.clips && track.clips.length > 0 ? track.clips : track.buffer ? [{
id: 'default', id: 'default',
@@ -547,9 +538,7 @@ const WaveformLane = ({
// Ignore right-click for local selection drag (context menu handles it) // Ignore right-click for local selection drag (context menu handles it)
if (e.button === 2) return; if (e.button === 2) return;
const rect = canvasRef.current.getBoundingClientRect(); const rect = canvasRef.current.getBoundingClientRect();
const wrapper = canvasRef.current?.parentElement?.parentElement; const x = e.clientX - rect.left + (scrollLeft || 0);
const scrollLeft = wrapper ? wrapper.scrollLeft : 0;
const x = e.clientX - rect.left + scrollLeft;
const time = Math.max(0, x / zoom); const time = Math.max(0, x / zoom);
onSelectTrack(track.id); onSelectTrack(track.id);
const clips = track.clips && track.clips.length > 0 ? track.clips : track.buffer ? [{ const clips = track.clips && track.clips.length > 0 ? track.clips : track.buffer ? [{
@@ -686,9 +675,7 @@ const WaveformLane = ({
}, },
onDoubleClick: e => { onDoubleClick: e => {
const rect = canvasRef.current.getBoundingClientRect(); const rect = canvasRef.current.getBoundingClientRect();
const wrapper = canvasRef.current?.parentElement?.parentElement; const x = e.clientX - rect.left + (scrollLeft || 0);
const scrollLeft = wrapper ? wrapper.scrollLeft : 0;
const x = e.clientX - rect.left + scrollLeft;
const time = Math.max(0, x / zoom); const time = Math.max(0, x / zoom);
const clips = track.clips && track.clips.length > 0 ? track.clips : track.buffer ? [{ const clips = track.clips && track.clips.length > 0 ? track.clips : track.buffer ? [{
id: 'default', id: 'default',
@@ -711,9 +698,7 @@ const WaveformLane = ({
e.stopPropagation(); e.stopPropagation();
onSelectTrack(track.id); onSelectTrack(track.id);
const rect = canvasRef.current.getBoundingClientRect(); const rect = canvasRef.current.getBoundingClientRect();
const wrapper = canvasRef.current?.parentElement?.parentElement; const x = e.clientX - rect.left + (scrollLeft || 0);
const scrollLeft = wrapper ? wrapper.scrollLeft : 0;
const x = e.clientX - rect.left + scrollLeft;
const time = Math.max(0, x / zoom); const time = Math.max(0, x / zoom);
if (onContextMenu) onContextMenu(e, track.id, time); if (onContextMenu) onContextMenu(e, track.id, time);
} }
@@ -726,25 +711,22 @@ const TempoTrackLane = ({
viewportWidth, viewportWidth,
onPlayheadSet, onPlayheadSet,
snapValue, snapValue,
onRulerMouseDown onRulerMouseDown,
scrollLeft
}) => { }) => {
const canvasRef = useRef(null); const canvasRef = useRef(null);
const drawWidth = Math.min(timelineWidth, viewportWidth);
useEffect(() => { useEffect(() => {
const canvas = canvasRef.current; const canvas = canvasRef.current;
if (!canvas) return; if (!canvas) return;
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
const dpr = window.devicePixelRatio || 1; const dpr = window.devicePixelRatio || 1;
const wrapper = canvas.parentElement ? canvas.parentElement.parentElement : null; const scrollLeftVal = scrollLeft || 0;
const scrollLeft = wrapper ? wrapper.scrollLeft : 0;
const vWidth = viewportWidth || (wrapper ? wrapper.clientWidth : 1200);
const drawWidth = Math.min(timelineWidth, Math.max(vWidth, 1200));
const height = canvas.parentElement ? canvas.parentElement.clientHeight : 40; const height = canvas.parentElement ? canvas.parentElement.clientHeight : 40;
canvas.width = Math.round(drawWidth * dpr); canvas.width = Math.min(Math.round(drawWidth * dpr), 32768);
canvas.height = Math.round(height * dpr); canvas.height = Math.min(Math.round(height * dpr), 32768);
ctx.scale(dpr, dpr); ctx.scale(dpr, dpr);
ctx.imageSmoothingEnabled = false; ctx.imageSmoothingEnabled = false;
canvas.style.position = 'absolute';
canvas.style.left = `${scrollLeft}px`;
canvas.style.width = `${drawWidth}px`; canvas.style.width = `${drawWidth}px`;
canvas.style.height = `${height}px`; canvas.style.height = `${height}px`;
ctx.fillStyle = '#1a1a2e'; ctx.fillStyle = '#1a1a2e';
@@ -804,28 +786,25 @@ const TempoTrackLane = ({
ctx.font = 'bold 10px Inter, sans-serif'; ctx.font = 'bold 10px Inter, sans-serif';
ctx.textAlign = 'right'; ctx.textAlign = 'right';
ctx.fillText(`${bpm} BPM`, drawWidth - 6, 12); ctx.fillText(`${bpm} BPM`, drawWidth - 6, 12);
}, [bpm, zoom, timelineWidth, viewportWidth, snapValue]); }, [bpm, zoom, timelineWidth, viewportWidth, snapValue, scrollLeft]);
return /*#__PURE__*/React.createElement("div", { return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("div", {
key: "virtual-spacer-tempo",
style: { style: {
width: `${timelineWidth}px`, width: `${timelineWidth}px`,
height: '100%', height: '1px',
position: 'relative', pointerEvents: 'none'
overflow: 'hidden'
} }
}, /*#__PURE__*/React.createElement("canvas", { }), /*#__PURE__*/React.createElement("canvas", {
ref: canvasRef, ref: canvasRef,
style: { style: {
position: 'absolute', position: 'sticky',
top: 0,
left: 0, left: 0,
imageRendering: 'pixelated' imageRendering: 'pixelated'
}, },
className: "cursor-crosshair", className: "cursor-crosshair",
onMouseDown: e => { onMouseDown: e => {
const wrapper = canvasRef.current?.parentElement?.parentElement;
const scrollLeft = wrapper ? wrapper.scrollLeft : 0;
const rect = canvasRef.current.getBoundingClientRect(); 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); const time = Math.max(0, x / zoom);
if (e.shiftKey) { if (e.shiftKey) {
e.preventDefault(); e.preventDefault();
@@ -3263,10 +3242,12 @@ const App = () => {
}, [activeTool, activeTab]); }, [activeTool, activeTab]);
const timelineWrapperRef = useRef(null); const timelineWrapperRef = useRef(null);
const tcpContainerRef = useRef(null); const tcpContainerRef = useRef(null);
const [scrollLeft, setScrollLeft] = useState(0);
const handleTimelineScroll = e => { const handleTimelineScroll = e => {
if (tcpContainerRef.current) { if (tcpContainerRef.current) {
tcpContainerRef.current.scrollTop = e.currentTarget.scrollTop; tcpContainerRef.current.scrollTop = e.currentTarget.scrollTop;
} }
setScrollLeft(e.currentTarget.scrollLeft);
}; };
const handleTCPScroll = e => { const handleTCPScroll = e => {
if (timelineWrapperRef.current) { if (timelineWrapperRef.current) {
@@ -8376,7 +8357,8 @@ const App = () => {
viewportWidth: viewportWidth, viewportWidth: viewportWidth,
onPlayheadSet: handlePlayheadSet, onPlayheadSet: handlePlayheadSet,
snapValue: snapValue, snapValue: snapValue,
onRulerMouseDown: handleRulerMouseDown onRulerMouseDown: handleRulerMouseDown,
scrollLeft: scrollLeft
}))), /*#__PURE__*/React.createElement("div", { }))), /*#__PURE__*/React.createElement("div", {
className: "flex-1 flex flex-col divide-y divide-[#141414] relative bg-[#111111] min-h-full" className: "flex-1 flex flex-col divide-y divide-[#141414] relative bg-[#111111] min-h-full"
}, tracks.map((track, idx) => { }, tracks.map((track, idx) => {
@@ -8386,7 +8368,7 @@ const App = () => {
style: { style: {
height: `${track.height || 96}px` 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(), onDragOver: e => e.preventDefault(),
onDrop: e => { onDrop: e => {
e.preventDefault(); e.preventDefault();
@@ -8397,6 +8379,7 @@ const App = () => {
track: track, track: track,
zoom: zoom, zoom: zoom,
timelineWidth: timelineWidth, timelineWidth: timelineWidth,
viewportWidth: viewportWidth,
onSelectRange: handleSelectRange, onSelectRange: handleSelectRange,
onPlayheadSet: handlePlayheadSet, onPlayheadSet: handlePlayheadSet,
isSelected: isSelected, isSelected: isSelected,
@@ -8428,7 +8411,8 @@ const App = () => {
onSetLocalSelectionStart: setLocalSelectionStart, onSetLocalSelectionStart: setLocalSelectionStart,
onSetLocalSelectionEnd: setLocalSelectionEnd, onSetLocalSelectionEnd: setLocalSelectionEnd,
localSelLeft: localSelectionStart !== null && localSelectionEnd !== null ? Math.min(localSelectionStart, localSelectionEnd) : null, 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", { }), 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", className: "absolute top-0 bottom-0 border-l border-r border-amber-400 bg-amber-500/20 z-20 pointer-events-none",
style: { style: {
+12 -9
View File
@@ -216,8 +216,8 @@ const WaveformLane = ({
// Grid lines based on Snap value // Grid lines based on Snap value
ctx.strokeStyle = 'rgba(255, 255, 255, 0.03)'; ctx.strokeStyle = 'rgba(255, 255, 255, 0.03)';
ctx.lineWidth = 1; ctx.lineWidth = 1;
const tStart = scrollLeftVal / zoom; const tStart = scrollLeft / zoom;
const tEnd = (scrollLeftVal + drawWidth) / zoom; const tEnd = (scrollLeft + drawWidth) / zoom;
let gridSpacing = 1.0; let gridSpacing = 1.0;
if (snapValue && snapValue !== 'free') { if (snapValue && snapValue !== 'free') {
const beatDuration = 60 / parseFloat(bpm || 120); const beatDuration = 60 / parseFloat(bpm || 120);
@@ -702,7 +702,7 @@ const WaveformLane = ({
const time = Math.max(0, x / zoom); const time = Math.max(0, x / zoom);
if (onContextMenu) onContextMenu(e, track.id, time); if (onContextMenu) onContextMenu(e, track.id, time);
} }
}))); }));
}; };
const TempoTrackLane = ({ const TempoTrackLane = ({
bpm, bpm,
@@ -733,8 +733,8 @@ const TempoTrackLane = ({
ctx.fillRect(0, 0, drawWidth, height); ctx.fillRect(0, 0, drawWidth, height);
const beatDuration = 60 / bpm; const beatDuration = 60 / bpm;
const barDuration = beatDuration * 4; const barDuration = beatDuration * 4;
const tStart = scrollLeftVal / zoom; const tStart = scrollLeft / zoom;
const tEnd = (scrollLeftVal + drawWidth) / zoom; const tEnd = (scrollLeft + drawWidth) / zoom;
const firstBeat = Math.floor(tStart / beatDuration) * beatDuration; const firstBeat = Math.floor(tStart / beatDuration) * beatDuration;
for (let t = firstBeat; t <= tEnd; t += beatDuration) { for (let t = firstBeat; t <= tEnd; t += beatDuration) {
const beatNum = Math.floor(t / beatDuration) + 1; const beatNum = Math.floor(t / beatDuration) + 1;
@@ -816,7 +816,7 @@ const TempoTrackLane = ({
onPlayheadSet(time, e.shiftKey); onPlayheadSet(time, e.shiftKey);
} }
} }
}))); }));
}; };
// ── Sub-Tab Waveform Component (LOOP_EDITOR_2.md §1.2 & SUB_EDITOR.md) ── // ── Sub-Tab Waveform Component (LOOP_EDITOR_2.md §1.2 & SUB_EDITOR.md) ──
@@ -8357,7 +8357,8 @@ const App = () => {
viewportWidth: viewportWidth, viewportWidth: viewportWidth,
onPlayheadSet: handlePlayheadSet, onPlayheadSet: handlePlayheadSet,
snapValue: snapValue, snapValue: snapValue,
onRulerMouseDown: handleRulerMouseDown onRulerMouseDown: handleRulerMouseDown,
scrollLeft: scrollLeft
}))), /*#__PURE__*/React.createElement("div", { }))), /*#__PURE__*/React.createElement("div", {
className: "flex-1 flex flex-col divide-y divide-[#141414] relative bg-[#111111] min-h-full" className: "flex-1 flex flex-col divide-y divide-[#141414] relative bg-[#111111] min-h-full"
}, tracks.map((track, idx) => { }, tracks.map((track, idx) => {
@@ -8367,7 +8368,7 @@ const App = () => {
style: { style: {
height: `${track.height || 96}px` 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(), onDragOver: e => e.preventDefault(),
onDrop: e => { onDrop: e => {
e.preventDefault(); e.preventDefault();
@@ -8378,6 +8379,7 @@ const App = () => {
track: track, track: track,
zoom: zoom, zoom: zoom,
timelineWidth: timelineWidth, timelineWidth: timelineWidth,
viewportWidth: viewportWidth,
onSelectRange: handleSelectRange, onSelectRange: handleSelectRange,
onPlayheadSet: handlePlayheadSet, onPlayheadSet: handlePlayheadSet,
isSelected: isSelected, isSelected: isSelected,
@@ -8409,7 +8411,8 @@ const App = () => {
onSetLocalSelectionStart: setLocalSelectionStart, onSetLocalSelectionStart: setLocalSelectionStart,
onSetLocalSelectionEnd: setLocalSelectionEnd, onSetLocalSelectionEnd: setLocalSelectionEnd,
localSelLeft: localSelectionStart !== null && localSelectionEnd !== null ? Math.min(localSelectionStart, localSelectionEnd) : null, 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", { }), 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", className: "absolute top-0 bottom-0 border-l border-r border-amber-400 bg-amber-500/20 z-20 pointer-events-none",
style: { style: {
Binary file not shown.