mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -321,6 +321,29 @@ export function getTimelinePlayheadLeft(time: number, pixelsPerSecond: number):
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inverse of {@link getTimelinePlayheadLeft}: the scrub time under a viewport
|
||||
* clientX. Clamped to [0, duration], NOT rejected — the scrub surface starts
|
||||
* `GUTTER + TRACKS_LEFT_PAD` px right of the viewport edge, so any pointer left
|
||||
* of t=0 maps to a negative offset. Callers used to bail on that instead of
|
||||
* clamping, which made the last 80px of the drag to zero silently do nothing:
|
||||
* the playhead stuck wherever the last in-range sample landed, and only a very
|
||||
* slow drag that happened to sample inside the sliver before the origin reached
|
||||
* 0. Every scrub path shares this so they cannot diverge on the edge again.
|
||||
*/
|
||||
export function getTimelineScrubTime(input: {
|
||||
clientX: number;
|
||||
viewportLeft: number;
|
||||
scrollLeft: number;
|
||||
pixelsPerSecond: number;
|
||||
duration: number;
|
||||
}): number {
|
||||
const { clientX, viewportLeft, scrollLeft, pixelsPerSecond, duration } = input;
|
||||
if (!(pixelsPerSecond > 0) || !Number.isFinite(duration)) return 0;
|
||||
const x = clientX - viewportLeft + scrollLeft - GUTTER - TRACKS_LEFT_PAD;
|
||||
return Math.max(0, Math.min(duration, x / pixelsPerSecond));
|
||||
}
|
||||
|
||||
export function getTimelineCanvasHeight(trackCount: number): number {
|
||||
// RULER_H + top pad + lanes + bottom pad. The old TIMELINE_SCROLL_BUFFER is
|
||||
// subsumed by TRACKS_BOTTOM_PAD (which is larger), so the drag-into-void space
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
GUTTER,
|
||||
TRACKS_LEFT_PAD,
|
||||
getTimelinePlayheadLeft,
|
||||
getTimelineScrubTime,
|
||||
getTimelineScrollLeftForZoomTransition,
|
||||
getTimelineScrollLeftForZoomAnchor,
|
||||
shouldAutoScrollTimeline,
|
||||
@@ -130,9 +131,13 @@ export function useTimelinePlayhead({
|
||||
const el = scrollRef.current;
|
||||
if (!el || effectiveDuration <= 0) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const x = clientX - rect.left + el.scrollLeft - GUTTER - TRACKS_LEFT_PAD;
|
||||
if (x < 0) return;
|
||||
const time = Math.max(0, Math.min(effectiveDuration, x / pps));
|
||||
const time = getTimelineScrubTime({
|
||||
clientX,
|
||||
viewportLeft: rect.left,
|
||||
scrollLeft: el.scrollLeft,
|
||||
pixelsPerSecond: pps,
|
||||
duration: effectiveDuration,
|
||||
});
|
||||
liveTime.notify(time);
|
||||
onSeek?.(time);
|
||||
},
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
} from "./timelineEditing";
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
import { liveTime, usePlayerStore } from "../store/playerStore";
|
||||
import { GUTTER, TRACKS_LEFT_PAD } from "./timelineLayout";
|
||||
import { GUTTER, TRACKS_LEFT_PAD, getTimelineScrubTime } from "./timelineLayout";
|
||||
import {
|
||||
computeMarqueeSelection,
|
||||
getMarqueeRect,
|
||||
@@ -286,11 +286,15 @@ export function useTimelineRangeSelection({
|
||||
const el = scrollRef.current;
|
||||
if (el) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const x = clientX - rect.left + el.scrollLeft - GUTTER - TRACKS_LEFT_PAD;
|
||||
if (x >= 0) {
|
||||
const dur = el.scrollWidth / pps;
|
||||
liveTime.notify(Math.max(0, Math.min(dur, x / pps)));
|
||||
}
|
||||
liveTime.notify(
|
||||
getTimelineScrubTime({
|
||||
clientX,
|
||||
viewportLeft: rect.left,
|
||||
scrollLeft: el.scrollLeft,
|
||||
pixelsPerSecond: pps,
|
||||
duration: el.scrollWidth / pps,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (!seekRafRef.current) {
|
||||
seekRafRef.current = requestAnimationFrame(() => {
|
||||
|
||||
Reference in New Issue
Block a user