fix(studio): freeze timeline zoom during extend-drag so clips don't jump

Dragging a clip past the video end fed back on itself: the growing preview
length shrank the fit-to-width zoom, which remapped the pointer, moved the
clip, and grew the length again (visible jumping). Split the committed
basis duration (drives zoom) from the displayed effective duration (adds
the live preview for ruler + width). Zoom holds fixed through the gesture
and the extra length scrolls; it re-fits once on drop. Duration math
extracted to pure tested helpers in timelineLayout.
This commit is contained in:
Miguel Angel Simon Sierra
2026-07-08 23:46:28 -04:00
parent d7e7fca8ec
commit 580676bbf4
3 changed files with 89 additions and 16 deletions
@@ -30,6 +30,8 @@ import {
generateTicks,
getTimelineCanvasHeight,
shouldShowTimelineShortcutHint,
computeTimelineBasisDuration,
computeTimelineEffectiveDuration,
} from "./timelineLayout";
import { useResolvedTimelineEditCallbacks } from "./useResolvedTimelineEditCallbacks";
import type { TimelineProps } from "./TimelineTypes";
@@ -228,20 +230,25 @@ export const Timeline = memo(function Timeline({
setRangeSelectionRef,
});
const effectiveDuration = useMemo(() => {
const safeDur = Number.isFinite(duration) ? duration : 0;
let maxEnd = safeDur;
if (rawElements.length > 0) {
maxEnd = Math.max(maxEnd, ...rawElements.map((el) => el.start + el.duration));
}
if (draggedClip?.started) {
maxEnd = Math.max(maxEnd, draggedClip.previewStart + draggedClip.element.duration);
}
if (resizingClip?.started) {
maxEnd = Math.max(maxEnd, resizingClip.previewStart + resizingClip.previewDuration);
}
return Number.isFinite(maxEnd) ? maxEnd : safeDur;
}, [rawElements, duration, draggedClip, resizingClip]);
// basisDuration drives the zoom (committed, no live preview); effectiveDuration
// adds the active drag/resize preview so the ruler/width follow a past-end drag.
// See timelineLayout for why the split prevents jump-during-extend.
const basisDuration = useMemo(
() =>
computeTimelineBasisDuration(
duration,
rawElements.map((el) => el.start + el.duration),
),
[rawElements, duration],
);
const effectiveDuration = useMemo(
() =>
computeTimelineEffectiveDuration(basisDuration, [
draggedClip?.started ? draggedClip.previewStart + draggedClip.element.duration : null,
resizingClip?.started ? resizingClip.previewStart + resizingClip.previewDuration : null,
]),
[basisDuration, draggedClip, resizingClip],
);
durationRef.current = effectiveDuration;
const displayTrackOrder = useMemo(() => {
@@ -273,9 +280,10 @@ export const Timeline = memo(function Timeline({
const selectedElementRef = useRef<TimelineElement | null>(selectedElement);
selectedElementRef.current = selectedElement;
// Fit to basisDuration, not effectiveDuration, so a live drag can't rezoom.
const fitPps =
viewportWidth > GUTTER && effectiveDuration > 0
? (viewportWidth - GUTTER - 2) / effectiveDuration
viewportWidth > GUTTER && basisDuration > 0
? (viewportWidth - GUTTER - 2) / basisDuration
: 100;
const pps = getTimelinePixelsPerSecond(fitPps, zoomMode, manualZoomPercent);
ppsRef.current = pps;
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { computeTimelineBasisDuration, computeTimelineEffectiveDuration } from "./timelineLayout";
describe("computeTimelineBasisDuration", () => {
it("uses the root duration when it exceeds every clip end", () => {
expect(computeTimelineBasisDuration(12, [4, 6, 9])).toBe(12);
});
it("grows to the furthest committed clip end past the root duration", () => {
expect(computeTimelineBasisDuration(12, [4, 18, 9])).toBe(18);
});
it("falls back to the root duration with no clips / non-finite ends", () => {
expect(computeTimelineBasisDuration(10, [])).toBe(10);
expect(computeTimelineBasisDuration(Number.NaN, [])).toBe(0);
});
});
describe("computeTimelineEffectiveDuration", () => {
it("returns the basis when there is no active preview", () => {
expect(computeTimelineEffectiveDuration(12, [null, null])).toBe(12);
});
it("extends to a drag/resize preview end beyond the basis", () => {
expect(computeTimelineEffectiveDuration(12, [20, null])).toBe(20);
expect(computeTimelineEffectiveDuration(12, [null, 16])).toBe(16);
});
it("never shrinks below the basis for a preview inside the current length", () => {
// The invariant behind the jump fix: the basis (which drives zoom) is
// independent of the preview, and a smaller preview end can't reduce it.
expect(computeTimelineEffectiveDuration(12, [8])).toBe(12);
});
});
@@ -9,6 +9,37 @@ export const CLIP_Y = 3;
export const CLIP_HANDLE_W = 18;
const TIMELINE_SCROLL_BUFFER = 20;
/* ── Timeline duration ─────────────────────────────────────────────── */
// Committed timeline length: root duration or the furthest committed clip end,
// with NO live drag/resize preview. This drives the zoom (fit-to-width pps) so
// the pixels-per-second mapping stays fixed while you drag — otherwise a clip
// dragged past the end grows the duration, shrinks pps, and jumps under the
// pointer (a positive-feedback loop). The zoom re-fits once on drop.
export function computeTimelineBasisDuration(
rootDuration: number,
clipEnds: readonly number[],
): number {
const safeDur = Number.isFinite(rootDuration) ? rootDuration : 0;
if (clipEnds.length === 0) return safeDur;
const maxEnd = Math.max(safeDur, ...clipEnds);
return Number.isFinite(maxEnd) ? maxEnd : safeDur;
}
// Displayed length: the basis plus any active drag/resize preview end, so the
// ruler and track width grow to follow a clip dragged past the current end
// (with the zoom held fixed, the extra length becomes scrollable content).
export function computeTimelineEffectiveDuration(
basisDuration: number,
previewEnds: readonly (number | null)[],
): number {
let maxEnd = basisDuration;
for (const end of previewEnds) {
if (end != null && Number.isFinite(end)) maxEnd = Math.max(maxEnd, end);
}
return maxEnd;
}
/* ── Tick generation ──────────────────────────────────────────────── */
function getMajorTickInterval(duration: number, pixelsPerSecond?: number): number {
const zoomIntervals = [0.02, 0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600];