fix(studio): a ruler label that slides under the track header is hidden, not sliced (#4424)

* fix(studio): a ruler label that slides under the track header is covered once the scroll settles

The track-header corner of the ruler is sticky and opaque and tick labels scroll natively under it, so the ruler could rest on a fragment ('0:05', '0'). A single mask subscribes to the viewport scroll with useSyncExternalStore, finds the one label the header slices, and covers it when the scroll settles. Moving it live on every tick crossing cost 2-3x the viewport gate's budget on a large unvirtualized timeline, so mid-scroll a fragment can still show.

* fix(studio): the ruler mask never stays off after its scroll listener is swapped

Unsubscribing mid-scroll left scrollingRef true, so the mask stayed hidden until the next scroll. Reset it with the settle timer.
This commit is contained in:
Miguel Ángel
2026-09-24 14:03:23 -04:00
committed by GitHub
parent 6a5fcd4cf2
commit 69fea290a9
4 changed files with 202 additions and 3 deletions
@@ -0,0 +1,98 @@
// @vitest-environment happy-dom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import { TimelineRuler } from "./TimelineRuler";
import { TIMELINE_SCROLL_SETTLE_MS } from "./useTimelineScrollViewport";
import type { TimelineTheme } from "./timelineTheme";
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
afterEach(() => {
vi.useRealTimers();
});
describe("TimelineRuler", () => {
it("masks the one tick label the track header would slice, once the scroll settles", () => {
vi.useFakeTimers();
const scroller = document.createElement("div");
let scrollLeft = 0;
Object.defineProperty(scroller, "scrollLeft", { get: () => scrollLeft });
const host = document.createElement("div");
scroller.append(host);
const root = createRoot(host);
act(() =>
root.render(
<TimelineRuler
major={[0, 10, 20]}
minor={[]}
pps={20}
trackContentWidth={600}
totalH={100}
effectiveDuration={30}
majorTickInterval={10}
theme={{} as TimelineTheme}
contentOrigin={80}
scrollRef={{ current: scroller }}
/>,
),
);
const mask = () =>
host.querySelector<HTMLElement>("[data-timeline-ruler-label-mask]")?.style.left ?? null;
const scrollTo = (left: number) =>
act(() => {
scrollLeft = left;
scroller.dispatchEvent(new Event("scroll"));
});
const settle = () => act(() => vi.advanceTimersByTime(TIMELINE_SCROLL_SETTLE_MS));
expect(mask()).toBeNull();
// The 10s label starts at 200 - 0.5 + 5 = 204.5 in ruler space.
scrollTo(204);
expect(mask()).toBeNull();
settle();
expect(mask()).toBe("4.5px");
scrollTo(205);
expect(mask()).toBeNull();
settle();
expect(mask()).toBe("204.5px");
act(() => root.unmount());
});
it("masks right away when the scroll element is swapped mid-scroll", () => {
vi.useFakeTimers();
const make = (left: number) => {
const el = document.createElement("div");
Object.defineProperty(el, "scrollLeft", { get: () => left });
return el;
};
const first = make(205);
const host = document.createElement("div");
const root = createRoot(host);
const render = (el: HTMLDivElement) =>
act(() =>
root.render(
<TimelineRuler
major={[0, 10, 20]}
minor={[]}
pps={20}
trackContentWidth={600}
totalH={100}
effectiveDuration={30}
majorTickInterval={10}
theme={{} as TimelineTheme}
contentOrigin={80}
scrollRef={{ current: el }}
/>,
),
);
render(first);
act(() => first.dispatchEvent(new Event("scroll")));
render(make(205));
expect(host.querySelector<HTMLElement>("[data-timeline-ruler-label-mask]")?.style.left).toBe(
"204.5px",
);
act(() => root.unmount());
});
});
@@ -1,4 +1,5 @@
import { memo } from "react";
import { memo, useCallback, useRef, useSyncExternalStore, type RefObject } from "react";
import { TIMELINE_SCROLL_SETTLE_MS } from "./useTimelineScrollViewport";
import type { TimelineTheme } from "./timelineTheme";
import { RULER_H, getTimelineBeatEntries } from "./timelineLayout";
import { formatTimelineTickLabel } from "./timelineRulerGeometry";
@@ -19,8 +20,95 @@ interface TimelineRulerProps {
beatAnalysis?: MusicBeatAnalysis | null;
contentOrigin: number;
renderTimeRange?: TimelineTimeRange;
scrollRef?: RefObject<HTMLDivElement | null>;
}
const TICK_LABEL_INSET_PX = 5;
// Index of the major tick whose label starts under the track-header corner, which
// covers ruler x < scrollLeft; -1 when none does.
function tickIndexUnderHeader(major: number[], pps: number, scrollLeft: number): number {
let lo = 0;
let hi = major.length - 1;
let found = -1;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
if (major[mid]! * pps - 0.5 + TICK_LABEL_INSET_PX < scrollLeft) {
found = mid;
lo = mid + 1;
} else hi = mid - 1;
}
return found;
}
/**
* Paints over the one label the header corner would slice, once a scroll settles. Moving it
* on every tick crossing cost 2-3x the scroll budget on a large timeline, so mid-scroll a
* fragment can still show.
*/
const HeaderSlicedLabelMask = memo(function HeaderSlicedLabelMask({
scrollRef,
major,
pps,
background,
}: {
scrollRef: RefObject<HTMLDivElement | null>;
major: number[];
pps: number;
background: string;
}) {
const scrollingRef = useRef(false);
const subscribe = useCallback(
(onChange: () => void) => {
const el = scrollRef.current;
let settle: ReturnType<typeof setTimeout> | undefined;
const onScroll = () => {
if (!scrollingRef.current) {
scrollingRef.current = true;
onChange();
}
clearTimeout(settle);
settle = setTimeout(() => {
scrollingRef.current = false;
onChange();
}, TIMELINE_SCROLL_SETTLE_MS);
};
el?.addEventListener("scroll", onScroll, { passive: true });
return () => {
clearTimeout(settle);
scrollingRef.current = false;
el?.removeEventListener("scroll", onScroll);
};
},
[scrollRef],
);
const index = useSyncExternalStore(
subscribe,
() =>
scrollingRef.current
? -1
: tickIndexUnderHeader(major, pps, scrollRef.current?.scrollLeft ?? 0),
() => -1,
);
if (index < 0) return null;
const labelLeft = major[index]! * pps - 0.5 + TICK_LABEL_INSET_PX;
const next = major[index + 1];
return (
<div
data-timeline-ruler-label-mask=""
className="absolute pointer-events-none"
style={{
left: labelLeft,
top: 4,
height: 12,
// Up to the next label, so only this label's text band is covered.
width: next === undefined ? 80 : (next - major[index]!) * pps - TICK_LABEL_INSET_PX,
background,
}}
/>
);
});
export const TimelineRuler = memo(function TimelineRuler({
major,
minor,
@@ -33,6 +121,7 @@ export const TimelineRuler = memo(function TimelineRuler({
beatAnalysis,
contentOrigin,
renderTimeRange,
scrollRef,
}: TimelineRulerProps) {
const timeDisplayMode = usePlayerStore((s) => s.timeDisplayMode);
const beatTimes = beatAnalysis?.beatTimes ?? [];
@@ -131,7 +220,7 @@ export const TimelineRuler = memo(function TimelineRuler({
className="absolute font-mono tabular-nums leading-none whitespace-nowrap"
style={{
color: theme.tickText,
left: 5,
left: TICK_LABEL_INSET_PX,
top: 5,
fontSize: 10,
}}
@@ -143,6 +232,14 @@ export const TimelineRuler = memo(function TimelineRuler({
<div className="w-px" style={{ height: RULER_H, background: theme.tickMajor }} />
</div>
))}
{scrollRef && (
<HeaderSlicedLabelMask
scrollRef={scrollRef}
major={major}
pps={pps}
background={theme.shellBackground}
/>
)}
</div>
</div>
</>
@@ -17,6 +17,7 @@ export function TimelineRulerPart() {
beatAnalysis={props.beatAnalysis}
contentOrigin={props.contentOrigin}
renderTimeRange={props.rowsVirtualized ? props.renderTimeRange : undefined}
scrollRef={props.scrollRef}
/>
);
}
@@ -3,6 +3,9 @@ import { useMountEffect } from "../../hooks/useMountEffect";
import { shouldShowTimelineShortcutHint } from "./timelineLayout";
import { STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED } from "./timelineRowVirtualizationFlag";
/** How long the timeline waits after the last scroll event before treating the scroll as settled. */
export const TIMELINE_SCROLL_SETTLE_MS = 100;
export interface TimelineScrollViewportSnapshot {
readonly scrollLeft: number;
readonly scrollTop: number;
@@ -91,7 +94,7 @@ export function useTimelineScrollViewport(
scrollingRef.current = false;
if (viewportRafRef.current) cancelAnimationFrame(viewportRafRef.current);
publish();
}, 100);
}, TIMELINE_SCROLL_SETTLE_MS);
}, []);
const syncShortcutHintVisibility = useCallback(() => {