feat: merge tempo bar + timebar into beat-based TimelineRuler
Replace old seconds-based ruler (0.00s, 1.00s...) and TempoTrackLane with unified 48px canvas ruler showing bar:beat grid lines, snap sub-ticks, and BPM label. WaveformLane grid aligns with ruler positions — items snap to same beat grid visually.
This commit is contained in:
+127
-60
@@ -1190,6 +1190,115 @@ const WaveformLane = ({
|
|||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
const TimelineRuler = ({
|
||||||
|
bpm, zoom, timelineWidth, viewportWidth,
|
||||||
|
onPlayheadSet, snapValue, onRulerMouseDown, scrollLeft, canvasRedrawCount
|
||||||
|
}) => {
|
||||||
|
const canvasRef = useRef(null);
|
||||||
|
const RULER_HEIGHT = 48;
|
||||||
|
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 scrollLeftVal = scrollLeft || 0;
|
||||||
|
const height = RULER_HEIGHT;
|
||||||
|
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.width = `${drawWidth}px`;
|
||||||
|
canvas.style.height = `${height}px`;
|
||||||
|
ctx.fillStyle = '#242424';
|
||||||
|
ctx.fillRect(0, 0, drawWidth, height);
|
||||||
|
ctx.strokeStyle = 'rgba(255,255,255,0.08)';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, height - 0.5);
|
||||||
|
ctx.lineTo(drawWidth, height - 0.5);
|
||||||
|
ctx.stroke();
|
||||||
|
const beatDuration = 60 / bpm;
|
||||||
|
const barDuration = beatDuration * 4;
|
||||||
|
const PADDING_LEFT = barDuration;
|
||||||
|
const tStart = scrollLeftVal / zoom - PADDING_LEFT;
|
||||||
|
const tEnd = (scrollLeftVal + drawWidth) / zoom + PADDING_LEFT;
|
||||||
|
const firstBeat = Math.floor(tStart / beatDuration) * beatDuration;
|
||||||
|
for (let t = firstBeat; t <= tEnd; t += beatDuration) {
|
||||||
|
const beatNum = Math.floor(t / beatDuration) + 1;
|
||||||
|
const isBar = beatNum % 4 === 1;
|
||||||
|
const localX = (t - tStart) * zoom;
|
||||||
|
if (localX < -200 || localX > drawWidth + 200) continue;
|
||||||
|
if (isBar) {
|
||||||
|
ctx.strokeStyle = 'rgba(255, 255, 255, 0.4)';
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(localX, 0);
|
||||||
|
ctx.lineTo(localX, height);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.fillStyle = 'rgba(255, 255, 255, 0.85)';
|
||||||
|
ctx.font = 'bold 10px Inter, sans-serif';
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
ctx.fillText(`${Math.ceil(beatNum / 4)}`, localX + 4, 13);
|
||||||
|
} else {
|
||||||
|
ctx.strokeStyle = 'rgba(255, 255, 255, 0.12)';
|
||||||
|
ctx.lineWidth = 0.8;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(localX, 0);
|
||||||
|
ctx.lineTo(localX, height);
|
||||||
|
ctx.stroke();
|
||||||
|
if (zoom >= 40) {
|
||||||
|
const bar = Math.ceil(beatNum / 4);
|
||||||
|
const beat = ((beatNum - 1) % 4) + 1;
|
||||||
|
ctx.fillStyle = 'rgba(255, 255, 255, 0.4)';
|
||||||
|
ctx.font = '7px Inter, sans-serif';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText(`${bar}.${beat}`, localX, height - 4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (snapValue && snapValue !== 'free') {
|
||||||
|
ctx.strokeStyle = 'rgba(255, 255, 255, 0.2)';
|
||||||
|
ctx.lineWidth = 0.8;
|
||||||
|
let divisor = 1;
|
||||||
|
if (snapValue === '4') divisor = 4; else 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) {
|
||||||
|
const firstSnap = Math.floor(tStart / snapInterval) * snapInterval;
|
||||||
|
for (let t = firstSnap; t <= tEnd; t += snapInterval) {
|
||||||
|
const onBeat = Math.abs(t / beatDuration - Math.round(t / beatDuration)) < 0.001;
|
||||||
|
if (!onBeat) {
|
||||||
|
const localX = (t - tStart) * zoom;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(localX, height - 6);
|
||||||
|
ctx.lineTo(localX, height);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.fillStyle = 'rgba(255, 255, 255, 0.4)';
|
||||||
|
ctx.font = 'bold 10px Inter, sans-serif';
|
||||||
|
ctx.textAlign = 'right';
|
||||||
|
ctx.fillText(`${bpm} BPM`, drawWidth - 6, 13);
|
||||||
|
}, [bpm, zoom, timelineWidth, viewportWidth, snapValue, scrollLeft, canvasRedrawCount]);
|
||||||
|
return React.createElement(React.Fragment, null, React.createElement("div", {
|
||||||
|
key: "virtual-spacer-ruler",
|
||||||
|
style: { width: `${timelineWidth}px`, height: '1px', pointerEvents: 'none' }
|
||||||
|
}), React.createElement("canvas", {
|
||||||
|
ref: canvasRef,
|
||||||
|
style: { position: 'sticky', left: 0, imageRendering: 'pixelated' },
|
||||||
|
className: "cursor-crosshair",
|
||||||
|
onMouseDown: e => {
|
||||||
|
const rect = canvasRef.current.getBoundingClientRect();
|
||||||
|
const x = e.clientX - rect.left + (scrollLeft || 0);
|
||||||
|
const time = Math.max(0, x / zoom);
|
||||||
|
if (e.shiftKey) { e.preventDefault(); e.stopPropagation(); }
|
||||||
|
if (onRulerMouseDown) onRulerMouseDown(e);
|
||||||
|
else onPlayheadSet(time, e.shiftKey);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
};
|
||||||
const TempoTrackLane = ({
|
const TempoTrackLane = ({
|
||||||
bpm,
|
bpm,
|
||||||
zoom,
|
zoom,
|
||||||
@@ -14843,42 +14952,7 @@ const App = () => {
|
|||||||
width: `${timelineWidth}px`
|
width: `${timelineWidth}px`
|
||||||
},
|
},
|
||||||
className: "relative flex flex-col min-h-full"
|
className: "relative flex flex-col min-h-full"
|
||||||
}, /*#__PURE__*/React.createElement("div", {
|
}, /*#__PURE__*/React.createElement(TimelineRuler, {
|
||||||
className: "sticky top-0 z-45 flex h-10 border-b border-zinc-900 bg-[#242424] shrink-0"
|
|
||||||
}, /*#__PURE__*/React.createElement("div", {
|
|
||||||
ref: rulerRef,
|
|
||||||
className: "flex-1 relative h-full flex items-center select-none cursor-ew-resize overflow-hidden",
|
|
||||||
onMouseDown: handleRulerMouseDown
|
|
||||||
}, Array.from({
|
|
||||||
length: Math.ceil(maxDuration)
|
|
||||||
}).map((_, i) => {
|
|
||||||
const sec = i;
|
|
||||||
const x = sec * zoom;
|
|
||||||
return /*#__PURE__*/React.createElement("div", {
|
|
||||||
key: i,
|
|
||||||
className: "absolute h-full border-l border-zinc-700 pl-1 pt-1 text-[14px] font-mono text-zinc-300 pointer-events-none",
|
|
||||||
style: {
|
|
||||||
left: `${x}px`
|
|
||||||
}
|
|
||||||
}, formatTimeSimple(sec));
|
|
||||||
}))), selectionMode === 'global' && selLeft !== null && selRight !== null && selRight > selLeft && /*#__PURE__*/React.createElement("div", {
|
|
||||||
className: "absolute inset-0 pointer-events-none z-20",
|
|
||||||
style: {
|
|
||||||
left: `${selLeft * zoom}px`,
|
|
||||||
width: `${(selRight - selLeft) * zoom}px`,
|
|
||||||
top: '40px'
|
|
||||||
}
|
|
||||||
}, /*#__PURE__*/React.createElement("div", {
|
|
||||||
className: "w-full h-full bg-amber-500/10",
|
|
||||||
style: {
|
|
||||||
borderLeft: '1px solid #f59e0b',
|
|
||||||
borderRight: '1px solid #f59e0b'
|
|
||||||
}
|
|
||||||
})), /*#__PURE__*/React.createElement("div", {
|
|
||||||
className: "sticky top-10 z-35 flex h-[40px] border-b border-purple-500/30 bg-[#1a1a2e] shrink-0"
|
|
||||||
}, /*#__PURE__*/React.createElement("div", {
|
|
||||||
className: "flex-1 relative h-full overflow-hidden bg-[#1a1a2e]"
|
|
||||||
}, /*#__PURE__*/React.createElement(TempoTrackLane, {
|
|
||||||
bpm: parseInt(bpm) || 120,
|
bpm: parseInt(bpm) || 120,
|
||||||
zoom: zoom,
|
zoom: zoom,
|
||||||
timelineWidth: timelineWidth,
|
timelineWidth: timelineWidth,
|
||||||
@@ -14888,7 +14962,20 @@ const App = () => {
|
|||||||
onRulerMouseDown: handleRulerMouseDown,
|
onRulerMouseDown: handleRulerMouseDown,
|
||||||
scrollLeft: scrollLeft,
|
scrollLeft: scrollLeft,
|
||||||
canvasRedrawCount: canvasRedrawCount
|
canvasRedrawCount: canvasRedrawCount
|
||||||
}))), /*#__PURE__*/React.createElement("div", {
|
}), selectionMode === 'global' && selLeft !== null && selRight !== null && selRight > selLeft && /*#__PURE__*/React.createElement("div", {
|
||||||
|
className: "absolute inset-0 pointer-events-none z-20",
|
||||||
|
style: {
|
||||||
|
left: `${selLeft * zoom}px`,
|
||||||
|
width: `${(selRight - selLeft) * zoom}px`,
|
||||||
|
top: '48px'
|
||||||
|
}
|
||||||
|
}, /*#__PURE__*/React.createElement("div", {
|
||||||
|
className: "w-full h-full bg-amber-500/10",
|
||||||
|
style: {
|
||||||
|
borderLeft: '1px solid #f59e0b',
|
||||||
|
borderRight: '1px solid #f59e0b'
|
||||||
|
}
|
||||||
|
})), /*#__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"
|
||||||
}, activeTracks.map((track, idx) => {
|
}, activeTracks.map((track, idx) => {
|
||||||
const isSelected = selectedTrackId === track.id;
|
const isSelected = selectedTrackId === track.id;
|
||||||
@@ -15373,37 +15460,17 @@ const App = () => {
|
|||||||
width: `${subTabTimelineWidth}px`
|
width: `${subTabTimelineWidth}px`
|
||||||
},
|
},
|
||||||
className: "relative flex flex-col min-h-full"
|
className: "relative flex flex-col min-h-full"
|
||||||
}, /*#__PURE__*/React.createElement("div", {
|
}, /*#__PURE__*/React.createElement(TimelineRuler, {
|
||||||
className: "sticky top-0 z-45 flex h-10 border-b border-zinc-900 bg-[#242424] shrink-0"
|
|
||||||
}, /*#__PURE__*/React.createElement("div", {
|
|
||||||
className: "flex-1 relative h-full flex items-center select-none cursor-ew-resize overflow-hidden",
|
|
||||||
onMouseDown: handleRulerMouseDown
|
|
||||||
}, Array.from({
|
|
||||||
length: Math.ceil(st.buffer ? st.buffer.duration : 0)
|
|
||||||
}).map((_, i) => {
|
|
||||||
const sec = i;
|
|
||||||
const x = sec * zoom;
|
|
||||||
return /*#__PURE__*/React.createElement("div", {
|
|
||||||
key: i,
|
|
||||||
className: "absolute h-full border-l border-zinc-700 pl-1 pt-1 text-[14px] font-mono text-zinc-300 pointer-events-none",
|
|
||||||
style: {
|
|
||||||
left: `${x}px`
|
|
||||||
}
|
|
||||||
}, formatTimeSimple(sec));
|
|
||||||
}))), /*#__PURE__*/React.createElement("div", {
|
|
||||||
className: "sticky top-10 z-35 flex h-[40px] border-b border-purple-500/30 bg-[#1a1a2e] shrink-0"
|
|
||||||
}, /*#__PURE__*/React.createElement("div", {
|
|
||||||
className: "flex-1 relative h-full overflow-hidden bg-[#1a1a2e]"
|
|
||||||
}, /*#__PURE__*/React.createElement(TempoTrackLane, {
|
|
||||||
bpm: parseInt(bpm) || 120,
|
bpm: parseInt(bpm) || 120,
|
||||||
zoom: zoom,
|
zoom: zoom,
|
||||||
timelineWidth: subTabTimelineWidth,
|
timelineWidth: subTabTimelineWidth,
|
||||||
viewportWidth: viewportWidth,
|
viewportWidth: viewportWidth,
|
||||||
onPlayheadSet: setCurrentTime,
|
onPlayheadSet: setCurrentTime,
|
||||||
snapValue: snapValue,
|
snapValue: snapValue,
|
||||||
|
onRulerMouseDown: handleRulerMouseDown,
|
||||||
scrollLeft: scrollLeft,
|
scrollLeft: scrollLeft,
|
||||||
canvasRedrawCount: canvasRedrawCount
|
canvasRedrawCount: canvasRedrawCount
|
||||||
}))), /*#__PURE__*/React.createElement("div", {
|
}), /*#__PURE__*/React.createElement("div", {
|
||||||
className: "flex-1 flex flex-col relative bg-[#111111] min-h-full"
|
className: "flex-1 flex flex-col relative bg-[#111111] min-h-full"
|
||||||
}, vTrack && /*#__PURE__*/React.createElement("div", {
|
}, vTrack && /*#__PURE__*/React.createElement("div", {
|
||||||
style: {
|
style: {
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -73,3 +73,9 @@
|
|||||||
- **Tóm tắt thay đổi:** (1) Xoá auto-follow phải (gây jump), chỉ giữ auto-follow trái 60px — scroll phải nhờ `autoScrollTimeline` mượt. (2) Thêm `PADDING_RIGHT` vào `tEnd` ở TempoTrackLane và WaveformLane — bar numbers + grid hiển thị đến sát rìa phải. (3) `if (localX < 0 || localX > drawWidth) continue;` — skip phần tử ngoài canvas. (4) Buffer 12 bars cho `maxDuration`. (5) Thêm `snapTime` vào section/MIDI item drag. (6) Thêm snap option `'4'` (1 bar). (7) Đồng bộ `PADDING_LEFT = barDuration` ở cả 2 lane.
|
- **Tóm tắt thay đổi:** (1) Xoá auto-follow phải (gây jump), chỉ giữ auto-follow trái 60px — scroll phải nhờ `autoScrollTimeline` mượt. (2) Thêm `PADDING_RIGHT` vào `tEnd` ở TempoTrackLane và WaveformLane — bar numbers + grid hiển thị đến sát rìa phải. (3) `if (localX < 0 || localX > drawWidth) continue;` — skip phần tử ngoài canvas. (4) Buffer 12 bars cho `maxDuration`. (5) Thêm `snapTime` vào section/MIDI item drag. (6) Thêm snap option `'4'` (1 bar). (7) Đồng bộ `PADDING_LEFT = barDuration` ở cả 2 lane.
|
||||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`
|
- **Các file ảnh hưởng:** `app/static/js/app.jsx`
|
||||||
- **Ghi chú/Test (nếu có):** `npx babel app/static/js/app.jsx` — compile pass.
|
- **Ghi chú/Test (nếu có):** `npx babel app/static/js/app.jsx` — compile pass.
|
||||||
|
|
||||||
|
### [2026-07-25 10:08] Task: Merge tempo bar + timebar, beat-based ruler, snap grid
|
||||||
|
- **Tóm tắt thay đổi:** Thay thế seconds-based ruler (0.00s, 1.00s...) + TempoTrackLane bằng TimelineRuler (48px canvas) hiển thị bar:beat grid lines + snap sub-ticks + BPM label. Grid WaveformLane khớp ruler, snapTime() đã dùng grid spacing beat — snap items đúng vị trí ruler.
|
||||||
|
- **Các file ảnh hưởng:** `app/static/js/app.jsx`
|
||||||
|
- **Ghi chú/Test (nếu có):** `npm run build` — build passes.
|
||||||
|
---
|
||||||
|
|||||||
Reference in New Issue
Block a user