feat: lead-in margin, 0-indexed bars, fix bar number clipping

- Add 1-bar lead-in margin at timeline start (bar 0, time 0s at margin edge)
- Playhead position includes lead-in offset
- Bar numbers now 0-indexed (0, 1, 2...) in ruler and track grid
- Fix bar number clipping: CLIP_BUFFER increased from 200px to
  max(400px, barWidth+200px) so bars past viewport edge render
- All mouse/time handlers account for lead-in margin (ruler, clip
  drag, section drag/resize, context menu, double-click)
- formatBeat returns 0-indexed bar
- Sub-tab ruler has leadInMargin=0 (no lead-in in clip editor)
This commit is contained in:
2026-07-25 10:25:30 +07:00
parent ff44fd4b8d
commit 8372ee1b98
3 changed files with 87 additions and 64 deletions
+63 -46
View File
@@ -55,10 +55,10 @@ const formatTimeSimple = secs => {
return `${secs.toFixed(2)}s`;
};
const formatBeat = (secs, bpmVal) => {
if (isNaN(secs) || secs < 0) return "1.1.1";
if (isNaN(secs) || secs < 0) return "0.1.1";
const beatDuration = 60 / bpmVal;
const barDuration = beatDuration * 4;
const bar = Math.floor(secs / barDuration) + 1;
const bar = Math.floor(secs / barDuration);
const beat = Math.floor((secs % barDuration) / beatDuration) + 1;
const sub = Math.floor((secs % beatDuration) / (beatDuration / 4)) + 1;
return `${bar}.${beat}.${sub}`;
@@ -418,6 +418,7 @@ const WaveformLane = ({
}) => {
const canvasRef = useRef(null);
const drawWidth = Math.min(timelineWidth, viewportWidth);
const leadInMargin = (60.0 / (parseFloat(bpm) || 120)) * 4;
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
@@ -440,9 +441,11 @@ const WaveformLane = ({
ctx.lineWidth = 1;
const beatDuration = (60.0 / (parseFloat(bpm) || 120));
const barDuration = beatDuration * 4;
const leadIn = barDuration;
const CLIP_BUFFER = Math.max(400, barDuration * zoom + 200);
const PADDING_LEFT = barDuration; // 1-bar margin on left
const tStart = scrollLeftVal / zoom - PADDING_LEFT;
const tEnd = (scrollLeftVal + drawWidth) / zoom + PADDING_LEFT;
const tStart = (scrollLeftVal - leadIn * zoom) / zoom - PADDING_LEFT;
const tEnd = (scrollLeftVal + drawWidth - leadIn * zoom) / zoom + PADDING_LEFT;
const firstBeat = Math.floor(tStart / beatDuration) * beatDuration;
let snapDivisor = 1;
if (snapValue && snapValue !== 'free') {
@@ -452,7 +455,7 @@ const WaveformLane = ({
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 (localX < -CLIP_BUFFER || localX > drawWidth + CLIP_BUFFER) continue;
ctx.strokeStyle = isBar ? 'rgba(255, 255, 255, 0.12)' : 'rgba(255, 255, 255, 0.04)';
ctx.lineWidth = isBar ? 1.2 : 0.5;
ctx.beginPath();
@@ -463,7 +466,7 @@ const WaveformLane = ({
ctx.fillStyle = 'rgba(255, 255, 255, 0.15)';
ctx.font = 'bold 7px Inter, sans-serif';
ctx.textAlign = 'left';
ctx.fillText(`${Math.ceil(beatNum / 4)}`, localX + 2, 10);
ctx.fillText(`${Math.floor((beatNum - 1) / 4)}`, localX + 2, 10);
}
}
@@ -832,7 +835,7 @@ const WaveformLane = ({
if (!canvasRef.current) return;
const rect = canvasRef.current.getBoundingClientRect();
const x = e.clientX - rect.left + (scrollLeft || 0);
const time = x / zoom;
const time = Math.max(0, x / zoom - leadInMargin);
const clips = track.clips && track.clips.length > 0 ? track.clips : track.buffer ? [{
id: 'default',
buffer: track.buffer,
@@ -944,7 +947,7 @@ const WaveformLane = ({
if (e.button === 2) return;
const rect = canvasRef.current.getBoundingClientRect();
const x = e.clientX - rect.left + (scrollLeft || 0);
const time = Math.max(0, x / zoom);
const time = Math.max(0, x / zoom - leadInMargin);
onSelectTrack(track.id);
const clips = track.clips && track.clips.length > 0 ? track.clips : track.buffer ? [{
id: 'default',
@@ -1132,7 +1135,7 @@ const WaveformLane = ({
onDoubleClick: e => {
const rect = canvasRef.current.getBoundingClientRect();
const x = e.clientX - rect.left + (scrollLeft || 0);
const time = Math.max(0, x / zoom);
const time = Math.max(0, x / zoom - leadInMargin);
const clips = track.clips && track.clips.length > 0 ? track.clips : track.buffer ? [{
id: 'default',
buffer: track.buffer,
@@ -1179,7 +1182,7 @@ const WaveformLane = ({
onSelectTrack(track.id);
const rect = canvasRef.current.getBoundingClientRect();
const x = e.clientX - rect.left + (scrollLeft || 0);
const time = Math.max(0, x / zoom);
const time = Math.max(0, x / zoom - leadInMargin);
// Detect section under cursor
const secList = track.sections || [];
let hitSectionId = null;
@@ -1192,7 +1195,8 @@ const WaveformLane = ({
};
const TimelineRuler = ({
bpm, zoom, timelineWidth, viewportWidth,
onPlayheadSet, snapValue, onRulerMouseDown, scrollLeft, canvasRedrawCount
onPlayheadSet, snapValue, onRulerMouseDown, scrollLeft, canvasRedrawCount,
leadInMargin: propLeadIn
}) => {
const canvasRef = useRef(null);
const RULER_HEIGHT = 48;
@@ -1220,15 +1224,17 @@ const TimelineRuler = ({
ctx.stroke();
const beatDuration = 60 / bpm;
const barDuration = beatDuration * 4;
const leadIn = propLeadIn !== undefined ? propLeadIn : barDuration;
const CLIP_BUFFER = Math.max(400, barDuration * zoom + 200);
const PADDING_LEFT = barDuration;
const tStart = scrollLeftVal / zoom - PADDING_LEFT;
const tEnd = (scrollLeftVal + drawWidth) / zoom + PADDING_LEFT;
const tStart = (scrollLeftVal - leadIn * zoom) / zoom - PADDING_LEFT;
const tEnd = (scrollLeftVal + drawWidth - leadIn * zoom) / 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 (localX < -CLIP_BUFFER || localX > drawWidth + CLIP_BUFFER) continue;
if (isBar) {
ctx.strokeStyle = 'rgba(255, 255, 255, 0.4)';
ctx.lineWidth = 1.5;
@@ -1239,7 +1245,7 @@ const TimelineRuler = ({
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);
ctx.fillText(`${Math.floor((beatNum - 1) / 4)}`, localX + 4, 13);
} else {
ctx.strokeStyle = 'rgba(255, 255, 255, 0.12)';
ctx.lineWidth = 0.8;
@@ -1248,7 +1254,7 @@ const TimelineRuler = ({
ctx.lineTo(localX, height);
ctx.stroke();
if (zoom >= 40) {
const bar = Math.ceil(beatNum / 4);
const bar = Math.floor((beatNum - 1) / 4);
const beat = ((beatNum - 1) % 4) + 1;
ctx.fillStyle = 'rgba(255, 255, 255, 0.4)';
ctx.font = '7px Inter, sans-serif';
@@ -1292,7 +1298,8 @@ const TimelineRuler = ({
onMouseDown: e => {
const rect = canvasRef.current.getBoundingClientRect();
const x = e.clientX - rect.left + (scrollLeft || 0);
const time = Math.max(0, x / zoom);
const leadIn = propLeadIn !== undefined ? propLeadIn : (60 / bpm) * 4;
const time = Math.max(0, x / zoom - leadIn);
if (e.shiftKey) { e.preventDefault(); e.stopPropagation(); }
if (onRulerMouseDown) onRulerMouseDown(e);
else onPlayheadSet(time, e.shiftKey);
@@ -1348,10 +1355,7 @@ const TempoTrackLane = ({
ctx.fillStyle = 'rgba(255, 255, 255, 0.7)';
ctx.font = 'bold 9px Inter, sans-serif';
ctx.textAlign = 'left';
ctx.fillStyle = 'rgba(255, 255, 255, 0.7)';
ctx.font = 'bold 9px Inter, sans-serif';
ctx.textAlign = 'left';
ctx.fillText(`${Math.ceil(beatNum / 4)}`, localX + 3, 11);
ctx.fillText(`${Math.floor((beatNum - 1) / 4)}`, localX + 3, 11);
} else {
ctx.strokeStyle = 'rgba(255, 255, 255, 0.08)';
ctx.lineWidth = 1;
@@ -1362,7 +1366,7 @@ const TempoTrackLane = ({
}
ctx.fillStyle = 'rgba(255, 255, 255, 0.35)';
ctx.font = '7px Inter, sans-serif';
const bar = Math.ceil(beatNum / 4);
const bar = Math.floor((beatNum - 1) / 4);
const beat = ((beatNum - 1) % 4) + 1;
ctx.fillText(`${bar}:${beat}`, localX + 2, height - 3);
}
@@ -6119,8 +6123,8 @@ const App = () => {
const [localSelectionEnd, setLocalSelectionEnd] = useState(null);
const [zoom, setZoom] = useState(100);
const [isLoopingSelection, setIsLoopingSelection] = useState(false);
const [beginBar, setBeginBar] = useState(1);
const [endBar, setEndBar] = useState(1);
const [beginBar, setBeginBar] = useState(0);
const [endBar, setEndBar] = useState(0);
const [numberBar, setNumberBar] = useState(1);
const [subTabHeight, setSubTabHeight] = useState(96);
const [isExporting, setIsExporting] = useState(false);
@@ -8630,6 +8634,9 @@ const App = () => {
}, []);
// Computed Values
const leadInMargin = useMemo(() => (60.0 / (parseInt(bpm) || 120)) * 4, [bpm]);
const leadInMarginRef = useRef(leadInMargin);
leadInMarginRef.current = leadInMargin;
const maxDuration = useMemo(() => {
const secPerBar = (60.0 / (parseInt(bpm) || 120)) * 4;
const cur = activeTracks;
@@ -8668,14 +8675,14 @@ const App = () => {
return viewportWidth / maxDuration;
}, [viewportWidth, maxDuration]);
const timelineWidth = useMemo(() => {
return Math.max(zoom * maxDuration, viewportWidth);
}, [zoom, maxDuration, viewportWidth]);
return Math.max(zoom * (maxDuration + leadInMargin), viewportWidth);
}, [zoom, maxDuration, viewportWidth, leadInMargin]);
useEffect(() => {
if (zoom < minZoom) {
setZoom(minZoom);
}
}, [minZoom]);
const playheadLeftPos = useMemo(() => currentTime * zoom, [currentTime, zoom]);
const playheadLeftPos = useMemo(() => (currentTime + leadInMargin) * zoom, [currentTime, zoom, leadInMargin]);
const selLeft = useMemo(() => {
if (selectionMode === 'local' && localSelectionStart !== null && localSelectionEnd !== null) {
return Math.min(localSelectionStart, localSelectionEnd);
@@ -10023,7 +10030,7 @@ const App = () => {
const rect = wrapper.getBoundingClientRect();
const scrollLeft = wrapper.scrollLeft;
const mouseX = e.clientX - rect.left + scrollLeft;
const rawTime = mouseX / zoom;
const rawTime = Math.max(0, mouseX / zoom - leadInMargin);
const time = snapValue !== 'free' ? snapTime(rawTime, snapValue, bpm) : rawTime;
clearLocalSelection();
setSelectionMode('global');
@@ -10055,7 +10062,7 @@ const App = () => {
const rect = wrapper.getBoundingClientRect();
const scrollLeft = wrapper.scrollLeft;
const mouseX = e.clientX - rect.left + scrollLeft;
const rawTime = Math.max(0, Math.min(maxDuration, mouseX / zoom));
const rawTime = Math.max(0, Math.min(maxDuration, mouseX / zoom - leadInMarginRef.current));
const time = snapValueRef.current !== 'free' ? snapTime(rawTime, snapValueRef.current, bpmRef.current) : rawTime;
const anchor = rulerAnchorRef.current ?? rulerDragStartRef.current ?? time;
setSelectionStart(Math.min(anchor, time));
@@ -10322,7 +10329,7 @@ const App = () => {
const rect = wrapper.getBoundingClientRect();
const scrollLeft = wrapper.scrollLeft;
const mouseX = e.clientX - rect.left + scrollLeft;
const time = mouseX / zoom;
const time = Math.max(0, mouseX / zoom - leadInMarginRef.current);
const beatSec = (60.0 / (parseInt(bpmRef.current) || 120));
const rawStart = Math.max(beatSec, time - drag.clickOffset);
const secPerBar = beatSec * 4;
@@ -10490,7 +10497,7 @@ const App = () => {
const rect = wrapper.getBoundingClientRect();
const scrollLeft = wrapper.scrollLeft;
const mouseX = e.clientX - rect.left + scrollLeft;
const time = mouseX / zoom;
const time = Math.max(0, mouseX / zoom - leadInMarginRef.current);
const beatSec = (60.0 / (parseInt(bpm) || 120));
const secondsPerBar = beatSec * 4;
const marginBar = maxDurationRef.current - secondsPerBar;
@@ -10566,7 +10573,7 @@ const App = () => {
const rect = wrapper.getBoundingClientRect();
const scrollLeft = wrapper.scrollLeft;
const mouseX = e.clientX - rect.left + scrollLeft;
const time = mouseX / zoom;
const time = Math.max(0, mouseX / zoom - leadInMarginRef.current);
updateActiveTracks(prev => prev.map(t => {
if (t.id !== resize.trackId) return t;
const items = resize.itemType === 'section' ? [...(t.sections || [])] : [...(t.midiItems || [])];
@@ -13964,13 +13971,13 @@ const App = () => {
className: "text-[14px] text-zinc-500 font-bold"
}, "Bars:"), /*#__PURE__*/React.createElement("input", {
type: "number",
min: "1",
min: "0",
value: beginBar,
onChange: e => {
const b = parseInt(e.target.value) || 1;
const b = parseInt(e.target.value) || 0;
setBeginBar(b);
const beatDuration = 60 / parseInt(bpm || 120);
const t = (b - 1) * beatDuration * 4;
const t = b * beatDuration * 4;
clearLocalSelection();
setSelectionMode('global');
setSelectionStart(t);
@@ -13981,13 +13988,13 @@ const App = () => {
className: "text-[14px] text-zinc-500"
}, "-"), /*#__PURE__*/React.createElement("input", {
type: "number",
min: "1",
min: "0",
value: endBar,
onChange: e => {
const b = parseInt(e.target.value) || 1;
const b = parseInt(e.target.value) || 0;
setEndBar(b);
const beatDuration = 60 / parseInt(bpm || 120);
const t = (b - 1) * beatDuration * 4;
const t = b * beatDuration * 4;
setSelectionEnd(t + beatDuration * 4);
setNumberBar(b - beginBar + 1);
},
@@ -14412,13 +14419,13 @@ const App = () => {
className: "block text-[7px] text-zinc-500 font-bold uppercase mb-0.5"
}, "Begin Bar"), /*#__PURE__*/React.createElement("input", {
type: "number",
min: "1",
min: "0",
value: beginBar,
onChange: e => {
const b = parseInt(e.target.value) || 1;
const b = parseInt(e.target.value) || 0;
setBeginBar(b);
const beatDuration = 60 / parseInt(bpm || 120);
const t = (b - 1) * beatDuration * 4;
const t = b * beatDuration * 4;
clearLocalSelection();
setSelectionMode('global');
setSelectionStart(t);
@@ -14429,13 +14436,13 @@ const App = () => {
className: "block text-[7px] text-zinc-500 font-bold uppercase mb-0.5"
}, "End Bar"), /*#__PURE__*/React.createElement("input", {
type: "number",
min: "1",
min: "0",
value: endBar,
onChange: e => {
const b = parseInt(e.target.value) || 1;
const b = parseInt(e.target.value) || 0;
setEndBar(b);
const beatDuration = 60 / parseInt(bpm || 120);
const t = (b - 1) * beatDuration * 4;
const t = b * beatDuration * 4;
setSelectionEnd(t + beatDuration * 4);
setNumberBar(b - beginBar + 1);
},
@@ -15467,9 +15474,19 @@ const App = () => {
viewportWidth: viewportWidth,
onPlayheadSet: setCurrentTime,
snapValue: snapValue,
onRulerMouseDown: handleRulerMouseDown,
onRulerMouseDown: e => {
const wrapper = timelineWrapperRef.current;
if (!wrapper) return;
const rect = wrapper.getBoundingClientRect();
const sl = wrapper.scrollLeft;
const raw = Math.max(0, (e.clientX - rect.left + sl) / zoom);
const t = snapValue !== 'free' ? snapTime(raw, snapValue, bpm) : raw;
if (e.shiftKey) { e.preventDefault(); e.stopPropagation(); }
handlePlayheadSet(t);
},
scrollLeft: scrollLeft,
canvasRedrawCount: canvasRedrawCount
canvasRedrawCount: canvasRedrawCount,
leadInMargin: 0
}), /*#__PURE__*/React.createElement("div", {
className: "flex-1 flex flex-col relative bg-[#111111] min-h-full"
}, vTrack && /*#__PURE__*/React.createElement("div", {
File diff suppressed because one or more lines are too long
+6
View File
@@ -79,3 +79,9 @@
- **Các file ảnh hưởng:** `app/static/js/app.jsx`
- **Ghi chú/Test (nếu có):** `npm run build` — build passes.
---
### [2026-07-25 10:18] Task: Lead-in margin, bar 0-indexed, grid clip fix
- **Tóm tắt thay đổi:** Thêm leadInMargin (1 bar) đẩy timeline content sang phải — bar 0, time 0s tại mép margin. Playhead bắt đầu từ đó. Bar numbering 0-indexed (bar 0 thay vì 1). Sửa CLIP_BUFFER từ 200px → max(400, barWidth+200) để bar number không bị che. Đồng bộ leadInMargin vào tStart/tEnd canvas + mouse handlers (ruler, clip drag, section resize).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`
- **Ghi chú/Test (nếu có):** `npm run build` — build passes.
---