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:
+63
-46
@@ -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
Reference in New Issue
Block a user