fix(studio): clamp the timeline scrub to 0 instead of dropping it

Dragging the playhead to the start of the composition needed a very slow
drag. The scrub surface begins GUTTER + TRACKS_LEFT_PAD px right of the
viewport edge, and both scrub paths bailed out when the pointer sat left of
that origin rather than clamping. So the last 80px of the drag toward zero
silently did nothing: the playhead stuck at whatever the last in-range sample
reported, and only a drag slow enough to sample inside the thin sliver before
the origin ever reached 0.

Both paths now share getTimelineScrubTime, which clamps to [0, duration]. One
owner, so the live-feedback path and the committed-seek path cannot disagree
about the edge again.
This commit is contained in:
Miguel Angel Simon Sierra
2026-07-27 19:09:16 +02:00
parent 3c7400af89
commit b386b55f73
4 changed files with 85 additions and 9 deletions
@@ -7,6 +7,7 @@ import {
GUTTER,
TRACKS_LEFT_PAD,
getTimelineRowTop,
getTimelineScrubTime,
getTimelineRowFromY,
getTimelineCanvasHeight,
resolveTimelineAssetDrop,
@@ -105,3 +106,46 @@ describe("track-area breathing pad y-math", () => {
});
});
});
describe("getTimelineScrubTime", () => {
const at = (clientX: number, duration = 10) =>
getTimelineScrubTime({
clientX,
viewportLeft: 0,
scrollLeft: 0,
pixelsPerSecond: 100,
duration,
});
const origin = GUTTER + TRACKS_LEFT_PAD;
it("maps the content origin to t=0", () => {
expect(at(origin)).toBe(0);
expect(at(origin + 250)).toBe(2.5);
});
// The bug: a pointer left of the origin used to abort the scrub instead of
// clamping, so dragging the playhead to the start only worked if a sample
// happened to land in the few px before t=0.
it("clamps a pointer left of the origin to 0 instead of dropping the scrub", () => {
expect(at(origin - 1)).toBe(0);
expect(at(origin - 500)).toBe(0);
expect(at(0)).toBe(0);
});
it("clamps past the end to the duration", () => {
expect(at(origin + 5000)).toBe(10);
});
it("returns 0 for a degenerate zoom or duration", () => {
expect(
getTimelineScrubTime({
clientX: 500,
viewportLeft: 0,
scrollLeft: 0,
pixelsPerSecond: 0,
duration: 10,
}),
).toBe(0);
expect(at(origin + 250, Number.NaN)).toBe(0);
});
});