From e3e754f93d3acafbd31f32df9f25ce531873e601 Mon Sep 17 00:00:00 2001 From: Danielku15 Date: Sat, 7 Nov 2026 12:17:14 +0100 Subject: [PATCH] fix(ui): draw the scrubber at its rendered width The scrubber drew into a fixed 600 (compact 400) unit viewBox stretched to its container, so bar numbers, tempo labels and the cursor knob were distorted on wide and narrow players, and the tick count stayed one per bar regardless of the space available. It now measures its width and draws in CSS pixels. Bar numbers and ticks step in powers of two chosen from the space a bar gets, so wide players label more bars and long songs on narrow players no longer collapse their ticks into a solid band. Labels show one-based bar numbers at the start of their bar, matching the rendered score, and stay inside the scrubber at both ends. Tempo labels flip to the left of a marker near the right edge and are dropped by their actual width when they collide. Closes #3010 --- packages/ui/src/player/controls/scrubber.tsx | 216 ++++++++++++------- packages/ui/test/scrubber.test.tsx | 144 +++++++++++++ 2 files changed, 283 insertions(+), 77 deletions(-) create mode 100644 packages/ui/test/scrubber.test.tsx diff --git a/packages/ui/src/player/controls/scrubber.tsx b/packages/ui/src/player/controls/scrubber.tsx index 9648a9561..ca7f9f874 100644 --- a/packages/ui/src/player/controls/scrubber.tsx +++ b/packages/ui/src/player/controls/scrubber.tsx @@ -17,10 +17,50 @@ export interface ScrubberProps { /** Bars a Page Up or Page Down press moves. */ const PAGE_BARS = 4; +/** Layout width until the scrubber is measured, e.g. while rendered on the server. */ +const FALLBACK_WIDTH = 600; +const FALLBACK_WIDTH_COMPACT = 400; + +/** Distances in CSS pixels; the drawing is never scaled. */ +const MIN_TICK_GAP = 4; +const MIN_LABEL_GAP = 48; +const LABEL_PADDING = 16; +const MIN_TEMPO_LABEL_GAP = 8; +const TEMPO_LABEL_OFFSET = 4; +const TEMPO_BPM_X = 6; +/** Advance of a digit in the monospace font, relative to the font size. */ +const MONO_ADVANCE = 0.6; + +/** The smallest power of two that is at least `value`. */ +function powerOfTwoAtLeast(value: number): number { + return 2 ** Math.max(0, Math.ceil(Math.log2(Math.max(1, value)))); +} + +function useWidth(ref: React.RefObject, fallback: number): number { + const [width, setWidth] = React.useState(fallback); + React.useLayoutEffect(() => { + const node = ref.current; + if (!node) { return; } + const update = () => { + const w = node.getBoundingClientRect().width; + if (w > 0) { setWidth(w); } + }; + update(); + if (typeof ResizeObserver === 'undefined') { return; } + const observer = new ResizeObserver(update); + observer.observe(node); + return () => observer.disconnect(); + }, [ref]); + return width; +} + /** * The song timeline with bar ticks, tempo markers and the loop range. With `onSeek` it is a slider: * pointer drags seek continuously; the arrow keys move by a bar, Page Up and Page Down by four, * Home and End to the first and last bar. + * + * It draws at its rendered width: tick and bar-number density follow the space a bar gets, labels + * show one-based bar numbers at the start of their bar. */ export const Scrubber: React.FC = ({ totalBars = 64, @@ -31,32 +71,109 @@ export const Scrubber: React.FC = ({ onSeek, onSeekBar, }) => { - const W = compact ? 400 : 600; + const wrapperRef = React.useRef(null); + const W = useWidth(wrapperRef, compact ? FALLBACK_WIDTH_COMPACT : FALLBACK_WIDTH); const tempoH = compact ? 0 : 12; const trackY = compact ? 8 : 22; - const labelY = compact ? 16 : 32; + const labelY = compact ? 21 : 36; + const labelFontSize = compact ? 7 : 8; const H = compact ? 22 : 38; const safeTotal = Math.max(1, totalBars); const xOf = (bar: number) => (Math.max(0, Math.min(bar, safeTotal)) / safeTotal) * W; const cursorX = xOf(Math.max(0, Math.min(currentBar, safeTotal))); - const labelStops = [0, 16, 32, 48, 64].filter((b) => b > 0 && b < safeTotal); - // Drop tempo markers whose label would collide with the previously kept one. - // Scores with many close-spaced tempo automations would otherwise stack - // their labels into unreadable overlaps. - const MIN_TEMPO_LABEL_GAP = 60; - const tempos: TempoMarker[] = []; - if (tempoMarkers) { - let lastX = -Infinity; - for (const t of tempoMarkers) { - if (t.atBar >= safeTotal) { continue; } - const x = xOf(t.atBar); - if (x - lastX < MIN_TEMPO_LABEL_GAP) { continue; } - tempos.push(t); - lastX = x; + + // The ruler only changes with the size, not with the playback position. + const ruler = React.useMemo(() => { + const barWidth = W / safeTotal; + const digitWidth = labelFontSize * MONO_ADVANCE; + const widestLabel = String(safeTotal).length * digitWidth; + const labelStep = powerOfTwoAtLeast(Math.max(MIN_LABEL_GAP, widestLabel + LABEL_PADDING) / barWidth); + const tickStep = Math.min(labelStep, powerOfTwoAtLeast(MIN_TICK_GAP / barWidth)); + const midStep = Math.max(tickStep, labelStep / 4); + const x = (bar: number) => (bar / safeTotal) * W; + + const ticks: React.ReactNode[] = []; + for (let b = 0; b <= safeTotal; b += tickStep) { + const major = b % labelStep === 0; + const mid = !major && b % midStep === 0; + const h = major ? 5 : mid ? 3 : 1.5; + const op = major ? 0.8 : mid ? 0.5 : 0.28; + ticks.push( + , + ); } - } - const wrapperRef = React.useRef(null); + // Labels center under their tick but stay inside the scrubber at both ends. + const labels: React.ReactNode[] = []; + for (let b = 0; b < safeTotal; b += labelStep) { + const text = String(b + 1); + const half = (text.length * digitWidth) / 2; + labels.push( + + {text} + , + ); + } + + // A tempo label sits right of its marker, or left of it where it would leave the scrubber. + // Markers whose label would collide with the previously kept one are dropped: scores with + // many close-spaced tempo automations would otherwise stack their labels into unreadable overlaps. + const tempos: React.ReactNode[] = []; + if (!compact && tempoMarkers) { + let lastRight = -Infinity; + for (const t of tempoMarkers) { + if (t.atBar >= safeTotal) { continue; } + const bpm = Math.round(t.bpm); + const mx = x(Math.max(0, t.atBar)); + const labelWidth = TEMPO_BPM_X + `= ${bpm}`.length * 8 * MONO_ADVANCE; + const flip = mx + TEMPO_LABEL_OFFSET + labelWidth > W; + const left = flip ? mx - TEMPO_LABEL_OFFSET - labelWidth : mx + TEMPO_LABEL_OFFSET; + if (left < 0 || left - lastRight < MIN_TEMPO_LABEL_GAP) { continue; } + lastRight = left + labelWidth; + tempos.push( + + + + + ♪ + + + = {bpm} + + + , + ); + } + } + return { ticks, labels, tempos }; + }, [W, safeTotal, compact, tempoMarkers, tempoH, trackY, labelY, labelFontSize]); + const [drag, setDrag] = React.useState(false); const seekAtClientX = React.useCallback( @@ -125,38 +242,14 @@ export const Scrubber: React.FC = ({ style={{ width: '100%', cursor: onSeek ? 'pointer' : undefined, touchAction: 'none' }} {...slider} > + {/* The viewBox matches the measured width; only the fallback width before measuring stretches. */}