diff --git a/packages/studio/src/player/components/TimelineClipDiamonds.test.tsx b/packages/studio/src/player/components/TimelineClipDiamonds.test.tsx
index d8ed941846..507ac156a7 100644
--- a/packages/studio/src/player/components/TimelineClipDiamonds.test.tsx
+++ b/packages/studio/src/player/components/TimelineClipDiamonds.test.tsx
@@ -3,22 +3,44 @@
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
+import type { TimelineElement } from "../store/playerStore";
+import { usePlayerStore } from "../store/playerStore";
import { TimelineClipDiamonds, TimelineDiamondLane } from "./TimelineClipDiamonds";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
afterEach(() => {
document.body.innerHTML = "";
+ usePlayerStore.setState({ elements: [], timelineSessionEpoch: 0 });
});
+const RETIME_ELEMENT: TimelineElement = {
+ id: "clip-1",
+ label: "Clip",
+ tag: "div",
+ start: 0,
+ duration: 10,
+ track: 0,
+};
+
function pointerEvent(type: string, init: PointerEventInit): Event {
- if (typeof PointerEvent === "function") return new PointerEvent(type, init);
- return new MouseEvent(type, init);
+ const event =
+ typeof PointerEvent === "function" ? new PointerEvent(type, init) : new MouseEvent(type, init);
+ if (!("pointerId" in event)) {
+ Object.defineProperty(event, "pointerId", { value: init.pointerId ?? 0 });
+ }
+ return event;
}
-function renderDiamonds(onClickKeyframe = vi.fn()) {
+function createTimelineHost() {
const host = document.createElement("div");
+ host.setAttribute("data-timeline-scroll-viewport", "");
document.body.append(host);
+ return host;
+}
+
+function renderDiamonds(onClickKeyframe = vi.fn()) {
+ const host = createTimelineHost();
const root = createRoot(host);
act(() => {
root.render(
@@ -44,6 +66,59 @@ function renderDiamonds(onClickKeyframe = vi.fn()) {
return { host, root, onClickKeyframe };
}
+function renderRetimeLane(onMoveKeyframe = vi.fn().mockResolvedValue(true), strict = false) {
+ usePlayerStore.setState({ elements: [RETIME_ELEMENT] });
+ const host = createTimelineHost();
+ const root = createRoot(host);
+ const onClickKeyframe = vi.fn();
+ const lane = (
+
+ );
+ act(() => {
+ root.render(strict ? {lane} : lane);
+ });
+ const diamond = host.querySelector('button[title="50%"]');
+ expect(diamond).not.toBeNull();
+ return { diamond: diamond!, host, onClickKeyframe, onMoveKeyframe, root };
+}
+
describe("TimelineClipDiamonds", () => {
it("keeps dense keyframe hit regions and visuals from overlapping", () => {
const host = document.createElement("div");
@@ -81,6 +156,23 @@ describe("TimelineClipDiamonds", () => {
act(() => root.unmount());
});
+ it("publishes retime previews after StrictMode effect replay", () => {
+ const { diamond, host, root } = renderRetimeLane(undefined, true);
+ const initialLeft = diamond.style.left;
+ act(() => {
+ diamond.dispatchEvent(
+ pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100, pointerId: 7 }),
+ );
+ window.dispatchEvent(
+ pointerEvent("pointermove", { bubbles: true, clientX: 120, pointerId: 7 }),
+ );
+ });
+ expect(host.querySelector('button[title="50%"]')?.style.left).not.toBe(
+ initialLeft,
+ );
+ act(() => root.unmount());
+ });
+
it("treats primary pointerup without drag as a keyframe click", () => {
const { host, root, onClickKeyframe } = renderDiamonds();
const diamond = host.querySelector('button[title="50%"]');
@@ -115,8 +207,7 @@ describe("TimelineClipDiamonds", () => {
it("treats a drag-armed press that resolves to a no-op move as a click", () => {
const onClickKeyframe = vi.fn();
const onMoveKeyframe = vi.fn();
- const host = document.createElement("div");
- document.body.append(host);
+ const host = createTimelineHost();
const root = createRoot(host);
act(() => {
root.render(
@@ -183,8 +274,7 @@ describe("TimelineClipDiamonds", () => {
it("reselects a retimed keyframe with its post-move tween percentage", () => {
const onClickKeyframe = vi.fn();
const onMoveKeyframe = vi.fn().mockResolvedValue(true);
- const host = document.createElement("div");
- document.body.append(host);
+ const host = createTimelineHost();
const root = createRoot(host);
act(() => {
root.render(
@@ -258,8 +348,7 @@ describe("TimelineClipDiamonds", () => {
it("composes a rapid second retime from the pending position", () => {
const onMoveKeyframe = vi.fn().mockResolvedValue(true);
- const host = document.createElement("div");
- document.body.append(host);
+ const host = createTimelineHost();
const root = createRoot(host);
act(() => {
root.render(
@@ -332,6 +421,19 @@ describe("TimelineClipDiamonds", () => {
},
85,
);
+
+ act(() => {
+ usePlayerStore.setState({ timelineSessionEpoch: 1 });
+ diamond!.dispatchEvent(
+ pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100 }),
+ );
+ diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 120 }));
+ });
+ expect(onMoveKeyframe).toHaveBeenNthCalledWith(
+ 3,
+ expect.objectContaining({ percentage: 50 }),
+ 60,
+ );
act(() => root.unmount());
});
@@ -340,8 +442,7 @@ describe("TimelineClipDiamonds", () => {
["rejects", () => Promise.reject(new Error("retime failed"))],
])("clears a failed pending retime when the callback %s", async (_label, settle) => {
const onMoveKeyframe = vi.fn().mockImplementationOnce(settle).mockResolvedValue(true);
- const host = document.createElement("div");
- document.body.append(host);
+ const host = createTimelineHost();
const root = createRoot(host);
act(() => {
root.render(
@@ -415,6 +516,141 @@ describe("TimelineClipDiamonds", () => {
act(() => root.unmount());
});
+ it("commits once from the stable viewport after the source lane unmounts", () => {
+ const { diamond, onMoveKeyframe, root } = renderRetimeLane();
+
+ act(() => {
+ diamond.dispatchEvent(
+ pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100, pointerId: 7 }),
+ );
+ root.unmount();
+ window.dispatchEvent(
+ pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 120, pointerId: 7 }),
+ );
+ window.dispatchEvent(
+ pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 140, pointerId: 7 }),
+ );
+ });
+
+ expect(onMoveKeyframe).toHaveBeenCalledExactlyOnceWith(
+ {
+ percentage: 50,
+ tweenPercentage: 50,
+ propertyGroup: "position",
+ animationId: "anim-1",
+ },
+ 60,
+ );
+ });
+
+ it("includes horizontal viewport scrolling in the retime destination", () => {
+ const { diamond, host, onMoveKeyframe, root } = renderRetimeLane();
+
+ act(() => {
+ diamond.dispatchEvent(
+ pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100, pointerId: 7 }),
+ );
+ host.scrollLeft = 20;
+ window.dispatchEvent(
+ pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 100, pointerId: 7 }),
+ );
+ });
+
+ expect(onMoveKeyframe).toHaveBeenCalledExactlyOnceWith(expect.any(Object), 60);
+ act(() => root.unmount());
+ });
+
+ it("ignores another pointer and lets the owning pointer finish", () => {
+ const { diamond, onMoveKeyframe, root } = renderRetimeLane();
+
+ act(() => {
+ diamond.dispatchEvent(
+ pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100, pointerId: 7 }),
+ );
+ window.dispatchEvent(
+ pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 140, pointerId: 8 }),
+ );
+ window.dispatchEvent(
+ pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 120, pointerId: 7 }),
+ );
+ });
+
+ expect(onMoveKeyframe).toHaveBeenCalledOnce();
+ act(() => root.unmount());
+ });
+
+ it("cancels without mutation on pointer cancel or Escape and allows the next retime", () => {
+ const { diamond, onMoveKeyframe, root } = renderRetimeLane();
+
+ act(() => {
+ diamond.dispatchEvent(
+ pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100, pointerId: 7 }),
+ );
+ window.dispatchEvent(
+ pointerEvent("pointercancel", {
+ bubbles: true,
+ button: 0,
+ clientX: 120,
+ pointerId: 7,
+ }),
+ );
+ window.dispatchEvent(
+ pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 120, pointerId: 7 }),
+ );
+
+ diamond.dispatchEvent(
+ pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100, pointerId: 9 }),
+ );
+ window.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Escape" }));
+ window.dispatchEvent(
+ pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 120, pointerId: 9 }),
+ );
+
+ diamond.dispatchEvent(
+ pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100, pointerId: 11 }),
+ );
+ window.dispatchEvent(
+ pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 120, pointerId: 11 }),
+ );
+ });
+
+ expect(onMoveKeyframe).toHaveBeenCalledOnce();
+ act(() => root.unmount());
+ });
+
+ it("cancels on project switch or source removal without poisoning the next gesture", () => {
+ const { diamond, onMoveKeyframe, root } = renderRetimeLane();
+
+ act(() => {
+ diamond.dispatchEvent(
+ pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100, pointerId: 7 }),
+ );
+ usePlayerStore.setState({ timelineSessionEpoch: 1 });
+ window.dispatchEvent(
+ pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 120, pointerId: 7 }),
+ );
+
+ diamond.dispatchEvent(
+ pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100, pointerId: 9 }),
+ );
+ usePlayerStore.setState({ elements: [] });
+ window.dispatchEvent(
+ pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 120, pointerId: 9 }),
+ );
+
+ usePlayerStore.setState({ elements: [RETIME_ELEMENT] });
+ diamond.dispatchEvent(
+ pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100, pointerId: 11 }),
+ );
+ window.dispatchEvent(
+ pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 120, pointerId: 11 }),
+ );
+ });
+
+ expect(onMoveKeyframe).toHaveBeenCalledOnce();
+ act(() => root.unmount());
+ });
+
// Regression: onClickKeyframe's state updates can re-render the diamond
// button out from under the gesture before the browser auto-synthesizes the
// "click" event that follows a button's pointerdown+pointerup. That orphaned
@@ -424,8 +660,7 @@ describe("TimelineClipDiamonds", () => {
// own clip. suppressClickRef lets that ancestor ignore the stray click.
it("arms suppressClickRef synchronously on a keyframe click", () => {
const suppressClickRef = { current: false };
- const host = document.createElement("div");
- document.body.append(host);
+ const host = createTimelineHost();
const root = createRoot(host);
act(() => {
root.render(
@@ -458,8 +693,7 @@ describe("TimelineClipDiamonds", () => {
});
const renderSegmentLane = (lastAmbiguous: boolean) => {
- const host = document.createElement("div");
- document.body.append(host);
+ const host = createTimelineHost();
const root = createRoot(host);
const kf = (percentage: number, extra: Record = {}) => ({
percentage,
@@ -532,8 +766,7 @@ describe("TimelineClipDiamonds", () => {
it("hides the inline ease button on a segment with no source animation id", () => {
// A runtime-scanned keyframe has no animationId, so there is no tween to
// target; the segment ending on it must not render a (dead) ease button.
- const host = document.createElement("div");
- document.body.append(host);
+ const host = createTimelineHost();
const root = createRoot(host);
const kf = (percentage: number, animationId?: string) => ({
percentage,
diff --git a/packages/studio/src/player/components/TimelineClipDiamonds.tsx b/packages/studio/src/player/components/TimelineClipDiamonds.tsx
index 8362439f03..3e0f312df2 100644
--- a/packages/studio/src/player/components/TimelineClipDiamonds.tsx
+++ b/packages/studio/src/player/components/TimelineClipDiamonds.tsx
@@ -1,18 +1,17 @@
import { Fragment, memo, useEffect, useRef, useState } from "react";
import { BEAT_BAND_H } from "./BeatStrip";
-import {
- KEYFRAME_DRAG_THRESHOLD_PX,
- previewClipPct,
- resolveKeyframeDrag,
-} from "../../components/editor/keyframeDrag";
import { MiniCurveSvg } from "../../components/editor/EaseCurveSection";
-import { clipToTweenPercentage } from "../../components/editor/KeyframeNavigation";
import { LANE_H } from "./timelineLayout";
import {
timelineKeyframeSelectionKey,
type TimelineKeyframeTarget,
} from "./timelineKeyframeIdentity";
import type { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth";
+import {
+ beginTimelineKeyframeRetime,
+ type TimelineKeyframeRetimeHandle,
+} from "./useTimelineKeyframeHandlers";
+
export interface TimelineDiamondKeyframe {
percentage: number;
/** Tween-relative percentage (the retime mutation keys on this, not clip %). */
@@ -90,13 +89,6 @@ const DIAMOND_RATIO = 0.8;
const KF_MIN_PCT = -5;
const KF_MAX_PCT = 105;
-type DragState = {
- kfKey: string;
- startX: number;
- fromClipPct: number;
- moved: boolean;
-};
-
function keyframeTarget(
keyframe: TimelineDiamondKeyframe,
groupAware: boolean,
@@ -132,21 +124,14 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
globalEase = "none",
}: TimelineDiamondLaneProps) {
// Hooks must run before the early return below.
- const dragRef = useRef(null);
- // Pending retime destination (clip + tween %) per keyframe key, so a rapid
- // second drag composes from where the first move left the keyframe (whose
- // cache entry has not rebuilt yet) instead of the stale rendered value.
- const pendingRetimeRef = useRef(new Map());
+ const mountedRef = useRef(true);
+ const retimeHandleRef = useRef(null);
useEffect(() => {
- // Clear a pending entry once the authoritative cache reflects a keyframe at
- // ~its destination. Match by tolerance, not equality: cache writers round
- // clip %s, so an exact check would leak an entry after every successful retime.
- for (const [key, pending] of pendingRetimeRef.current) {
- if (keyframesData.keyframes.some((k) => Math.abs(k.percentage - pending.clipPct) < 0.2)) {
- pendingRetimeRef.current.delete(key);
- }
- }
- }, [keyframesData.keyframes]);
+ mountedRef.current = true;
+ return () => {
+ mountedRef.current = false;
+ };
+ }, []);
// Visual-only preview of the dragged diamond's clip-% — no runtime/GSAP hold
// (that optimistic hold was the #1763 flake). The atomic move-keyframe commit
// on drop re-keys the diamond from source.
@@ -322,113 +307,42 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
if (e.button !== 0) return;
e.stopPropagation();
if (canDrag) {
- e.currentTarget.setPointerCapture?.(e.pointerId);
- dragRef.current = {
- kfKey,
- startX: e.clientX,
- fromClipPct: pendingRetimeRef.current.get(kfKey)?.clipPct ?? kf.percentage,
- moved: false,
- };
- }
- };
- const onPointerMove = (e: React.PointerEvent) => {
- const d = dragRef.current;
- if (!d || d.kfKey !== kfKey) return;
- if (!d.moved && Math.abs(e.clientX - d.startX) >= KEYFRAME_DRAG_THRESHOLD_PX) {
- d.moved = true;
- }
- if (d.moved) {
- setPreview({
- kfKey,
- clipPct: previewClipPct({
- pointerDownX: d.startX,
- pointerMoveX: e.clientX,
- clipWidthPx,
- draggedClipPct: d.fromClipPct,
- draggedIndex: i,
- sortedClipPcts,
- }),
+ retimeHandleRef.current = beginTimelineKeyframeRetime({
+ event: e,
+ elementId,
+ keyframeKey: kfKey,
+ target,
+ keyframes: keyframesData.keyframes,
+ clipWidthPx,
+ draggedIndex: i,
+ sortedClipPercentages: sortedClipPcts,
+ onPreview: (clipPercentage) => {
+ if (!mountedRef.current) return;
+ setPreview(clipPercentage === null ? null : { kfKey, clipPct: clipPercentage });
+ },
+ onMove: (fromTarget, toClipPercentage) =>
+ onMoveKeyframe?.(fromTarget, toClipPercentage) ?? Promise.resolve(false),
+ onSelect: (nextTarget, additive) => {
+ if (additive) onShiftClickKeyframe?.(nextTarget);
+ else onClickKeyframe?.(nextTarget);
+ },
+ suppressNextClick,
});
}
};
const onPointerUp = (e: React.PointerEvent) => {
- const d = dragRef.current;
- // No drag armed (canDrag false / non-primary press) → treat as a click.
- if (!d || d.kfKey !== kfKey) {
- if (e.button !== 0) return;
- suppressNextClick();
- if (e.shiftKey) onShiftClickKeyframe?.(target);
- else onClickKeyframe?.(target);
+ // The stable viewport coordinator owns an armed retime. This local
+ // path remains only for non-draggable diamonds.
+ if (canDrag) {
+ retimeHandleRef.current?.commit(e);
+ retimeHandleRef.current = null;
+ e.stopPropagation();
return;
}
- e.stopPropagation();
- dragRef.current = null;
- setPreview(null);
- e.currentTarget.releasePointerCapture?.(e.pointerId);
+ if (e.button !== 0) return;
suppressNextClick();
- const res = resolveKeyframeDrag({
- pointerDownX: d.startX,
- pointerUpX: e.clientX,
- clipWidthPx,
- draggedClipPct: d.fromClipPct,
- draggedIndex: i,
- sortedClipPcts,
- });
- if (res.kind === "click" || res.kind === "noop") {
- // "noop" is a press with enough pointer jitter to arm a drag (canDrag
- // is on for every diamond once the clip is selected) that resolved
- // back onto ~the same position — no real retime, so treat it as the
- // click it was. Otherwise a normal click with a few px of mouse/
- // trackpad drift silently does nothing: no selection, no move.
- if (e.shiftKey) onShiftClickKeyframe?.(target);
- else onClickKeyframe?.(target);
- } else if (res.kind === "move" && res.toClipPct != null) {
- const animKfs =
- target.animationId === undefined
- ? keyframesData.keyframes
- : keyframesData.keyframes.filter((k) => k.animationId === target.animationId);
- // Clamp to the mapped tween range: clipToTweenPercentage extrapolates
- // linearly, so a boundary drag past the range would otherwise reselect
- // an out-of-range tween % (e.g. 150%) even though the mutation clamps
- // the moved endpoint back to the boundary.
- const tweenPcts = animKfs
- .map((k) => k.tweenPercentage)
- .filter((v): v is number => typeof v === "number");
- const clampTween = (v: number) =>
- tweenPcts.length
- ? Math.max(Math.min(...tweenPcts), Math.min(Math.max(...tweenPcts), v))
- : v;
- const newTweenPct = clampTween(clipToTweenPercentage(animKfs, res.toClipPct));
- // For a rapid second retime the diamond still renders the stale cache
- // position, so identify the FROM keyframe by the pending (already-moved)
- // position; the mutation locates the source keyframe by this identity.
- const pendingBefore = pendingRetimeRef.current.get(kfKey);
- const fromTarget = pendingBefore
- ? {
- ...target,
- percentage: pendingBefore.clipPct,
- tweenPercentage: pendingBefore.tweenPct,
- }
- : target;
- const pending = { clipPct: res.toClipPct, tweenPct: newTweenPct };
- pendingRetimeRef.current.set(kfKey, pending);
- const clearPending = () => {
- if (pendingRetimeRef.current.get(kfKey) === pending) {
- pendingRetimeRef.current.delete(kfKey);
- }
- };
- void onMoveKeyframe?.(fromTarget, res.toClipPct).then((committed) => {
- if (!committed) clearPending();
- }, clearPending);
- // A retime still targeted this exact diamond — park/select it at its
- // new position, same as a plain click, or a drag that actually moved
- // something looks identical to one that silently did nothing.
- onClickKeyframe?.({
- ...target,
- percentage: res.toClipPct,
- tweenPercentage: newTweenPct,
- });
- }
+ if (e.shiftKey) onShiftClickKeyframe?.(target);
+ else onClickKeyframe?.(target);
};
return (
@@ -459,17 +373,16 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
overflow: "visible",
}}
onPointerDown={onPointerDown}
- onPointerMove={onPointerMove}
+ onPointerMove={canDrag ? (e) => retimeHandleRef.current?.update(e) : undefined}
onPointerUp={onPointerUp}
- onPointerCancel={(e) => {
- // Browser/OS cancellation (or lost capture) ends the drag without a
- // pointerup, so clear the armed drag and preview or a ghost diamond
- // stays stuck at the last previewed position.
- if (dragRef.current?.kfKey !== kfKey) return;
- dragRef.current = null;
- setPreview(null);
- e.currentTarget.releasePointerCapture?.(e.pointerId);
- }}
+ onPointerCancel={
+ canDrag
+ ? (e) => {
+ retimeHandleRef.current?.cancel(e);
+ retimeHandleRef.current = null;
+ }
+ : undefined
+ }
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
diff --git a/packages/studio/src/player/components/useTimelineKeyframeHandlers.test.tsx b/packages/studio/src/player/components/useTimelineKeyframeHandlers.test.tsx
index 4dcee4bad5..f1060933a8 100644
--- a/packages/studio/src/player/components/useTimelineKeyframeHandlers.test.tsx
+++ b/packages/studio/src/player/components/useTimelineKeyframeHandlers.test.tsx
@@ -5,14 +5,12 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { mountReactHarness } from "../../hooks/domSelectionTestHarness";
import type { TimelineElement } from "../store/playerStore";
import { usePlayerStore } from "../store/playerStore";
+import * as telemetry from "../../telemetry/events";
import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity";
import { useTimelineKeyframeHandlers } from "./useTimelineKeyframeHandlers";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
-const trackStudioSegmentEaseEdit = vi.hoisted(() => vi.fn());
-vi.mock("../../telemetry/events", () => ({ trackStudioSegmentEaseEdit }));
-
const ELEMENT: TimelineElement = {
id: "clip-1",
label: "Hero card",
@@ -46,12 +44,15 @@ const COLLIDING_TARGET: TimelineKeyframeTarget = {
afterEach(() => {
document.body.innerHTML = "";
- trackStudioSegmentEaseEdit.mockClear();
+ vi.restoreAllMocks();
usePlayerStore.setState({ focusedEaseSegment: null });
});
describe("useTimelineKeyframeHandlers", () => {
it("tracks opening the segment ease editor when a timeline segment is selected", () => {
+ const trackStudioSegmentEaseEdit = vi
+ .spyOn(telemetry, "trackStudioSegmentEaseEdit")
+ .mockImplementation(() => {});
let onSelectSegment: ((elementId: string, target: TimelineKeyframeTarget) => void) | undefined;
function Harness() {
diff --git a/packages/studio/src/player/components/useTimelineKeyframeHandlers.ts b/packages/studio/src/player/components/useTimelineKeyframeHandlers.ts
index 657aac930a..bb54a10563 100644
--- a/packages/studio/src/player/components/useTimelineKeyframeHandlers.ts
+++ b/packages/studio/src/player/components/useTimelineKeyframeHandlers.ts
@@ -1,13 +1,369 @@
-import { useCallback, type MouseEvent as ReactMouseEvent } from "react";
+import {
+ useCallback,
+ type MouseEvent as ReactMouseEvent,
+ type PointerEvent as ReactPointerEvent,
+} from "react";
+import { clipToTweenPercentage } from "../../components/editor/KeyframeNavigation";
+import {
+ KEYFRAME_DRAG_THRESHOLD_PX,
+ previewClipPct,
+ resolveKeyframeDrag,
+} from "../../components/editor/keyframeDrag";
import { trackStudioSegmentEaseEdit } from "../../telemetry/events";
import type { TimelineElement, KeyframeCacheEntry } from "../store/playerStore";
import { usePlayerStore } from "../store/playerStore";
import type { KeyframeDiamondContextMenuState } from "./KeyframeDiamondContextMenu";
+import {
+ applyTimelineAutoScrollStep,
+ resolveTimelineAutoScrollLoopAction,
+} from "./timelineEditing";
import {
timelineKeyframeSelectionKey,
type TimelineKeyframeTarget,
} from "./timelineKeyframeIdentity";
+interface TimelineRetimeKeyframe {
+ percentage: number;
+ tweenPercentage?: number;
+ animationId?: string;
+}
+
+interface TimelineKeyframeRetimeInput {
+ event: ReactPointerEvent;
+ elementId: string;
+ keyframeKey: string;
+ target: TimelineKeyframeTarget;
+ keyframes: readonly TimelineRetimeKeyframe[];
+ clipWidthPx: number;
+ draggedIndex: number;
+ sortedClipPercentages: readonly number[];
+ onPreview: (clipPercentage: number | null) => void;
+ onMove: (target: TimelineKeyframeTarget, toClipPercentage: number) => Promise;
+ onSelect: (target: TimelineKeyframeTarget, additive: boolean) => void;
+ suppressNextClick: () => void;
+}
+
+interface PendingTimelineKeyframeRetime {
+ elementId: string;
+ clipPercentage: number;
+ tweenPercentage: number;
+ sessionEpoch: number;
+}
+
+interface TimelineKeyframeRetimeActor extends TimelineKeyframeRetimeInput {
+ phase: "active" | "committing" | "cancelled" | "complete";
+ pointerId: number | null;
+ pointerDownX: number;
+ lastClientX: number;
+ lastClientY: number;
+ originScrollLeft: number;
+ fromClipPercentage: number;
+ moved: boolean;
+ sessionEpoch: number;
+ sourceWasPresent: boolean;
+ scrollRaf: number;
+ unsubscribeStore: (() => void) | null;
+ teardownListeners: (() => void) | null;
+}
+
+interface TimelineKeyframeRetimeCoordinator {
+ actor: TimelineKeyframeRetimeActor | null;
+ pending: Map;
+}
+
+type TimelineRetimePointerEvent = Pick<
+ PointerEvent,
+ "clientX" | "clientY" | "pointerId" | "shiftKey"
+>;
+
+const keyframeRetimeCoordinators = new WeakMap();
+
+function getRetimeOwner(target: HTMLElement): EventTarget {
+ return target.closest("[data-timeline-scroll-viewport]") ?? target.ownerDocument;
+}
+
+function getRetimeCoordinator(owner: EventTarget): TimelineKeyframeRetimeCoordinator {
+ const existing = keyframeRetimeCoordinators.get(owner);
+ if (existing) return existing;
+ const coordinator: TimelineKeyframeRetimeCoordinator = { actor: null, pending: new Map() };
+ keyframeRetimeCoordinators.set(owner, coordinator);
+ return coordinator;
+}
+
+function stablePointerId(pointerId: number): number | null {
+ return Number.isFinite(pointerId) ? pointerId : null;
+}
+
+export interface TimelineKeyframeRetimeHandle {
+ update: (event: ReactPointerEvent) => void;
+ commit: (event: ReactPointerEvent) => void;
+ cancel: (event: ReactPointerEvent) => void;
+}
+
+/**
+ * Starts a keyframe retime on the stable timeline viewport. The row/button is
+ * only an entry point: window listeners own the gesture through virtualization.
+ */
+export function beginTimelineKeyframeRetime(
+ input: TimelineKeyframeRetimeInput,
+): TimelineKeyframeRetimeHandle {
+ const source = input.event.currentTarget;
+ const owner = getRetimeOwner(source);
+ const viewport = owner instanceof HTMLElement ? owner : null;
+ const coordinator = getRetimeCoordinator(owner);
+ const sessionEpoch = usePlayerStore.getState().timelineSessionEpoch;
+
+ const cancel = (actor: TimelineKeyframeRetimeActor) => {
+ if (actor.phase !== "active") return;
+ actor.phase = "cancelled";
+ actor.onPreview(null);
+ if (actor.scrollRaf) cancelAnimationFrame(actor.scrollRaf);
+ actor.unsubscribeStore?.();
+ actor.teardownListeners?.();
+ if (viewport && actor.pointerId !== null) {
+ try {
+ viewport.releasePointerCapture(actor.pointerId);
+ } catch {
+ // Window listeners remain the native fallback when capture is unavailable.
+ }
+ }
+ if (coordinator.actor === actor) {
+ coordinator.actor = null;
+ }
+ actor.phase = "complete";
+ };
+
+ if (coordinator.actor) cancel(coordinator.actor);
+
+ for (const [key, pending] of coordinator.pending) {
+ if (pending.sessionEpoch !== sessionEpoch) coordinator.pending.delete(key);
+ }
+
+ for (const [key, pending] of coordinator.pending) {
+ if (
+ pending.elementId === input.elementId &&
+ input.keyframes.some(
+ (keyframe) => Math.abs(keyframe.percentage - pending.clipPercentage) < 0.2,
+ )
+ ) {
+ coordinator.pending.delete(key);
+ }
+ }
+
+ const pending = coordinator.pending.get(input.keyframeKey);
+ const actor: TimelineKeyframeRetimeActor = {
+ ...input,
+ phase: "active",
+ pointerId: stablePointerId(input.event.pointerId),
+ pointerDownX: input.event.clientX,
+ lastClientX: input.event.clientX,
+ lastClientY: input.event.clientY,
+ originScrollLeft: viewport?.scrollLeft ?? 0,
+ fromClipPercentage: pending?.clipPercentage ?? input.target.percentage,
+ moved: false,
+ sessionEpoch,
+ sourceWasPresent: usePlayerStore
+ .getState()
+ .elements.some((element) => (element.key ?? element.id) === input.elementId),
+ scrollRaf: 0,
+ unsubscribeStore: null,
+ teardownListeners: null,
+ };
+ coordinator.actor = actor;
+
+ const matchesPointer = (event: TimelineRetimePointerEvent) =>
+ actor.pointerId === null || event.pointerId === actor.pointerId;
+ const pointerXWithScroll = () =>
+ actor.lastClientX + (viewport?.scrollLeft ?? 0) - actor.originScrollLeft;
+ const publishPreview = () => {
+ actor.onPreview(
+ previewClipPct({
+ pointerDownX: actor.pointerDownX,
+ pointerMoveX: pointerXWithScroll(),
+ clipWidthPx: actor.clipWidthPx,
+ draggedClipPct: actor.fromClipPercentage,
+ draggedIndex: actor.draggedIndex,
+ sortedClipPcts: actor.sortedClipPercentages,
+ }),
+ );
+ };
+ const stopAutoScroll = () => {
+ if (actor.scrollRaf) cancelAnimationFrame(actor.scrollRaf);
+ actor.scrollRaf = 0;
+ };
+ const stepAutoScroll = () => {
+ actor.scrollRaf = 0;
+ if (
+ actor.phase !== "active" ||
+ !viewport ||
+ !applyTimelineAutoScrollStep(viewport, actor.lastClientX, actor.lastClientY)
+ ) {
+ return;
+ }
+ publishPreview();
+ actor.scrollRaf = requestAnimationFrame(stepAutoScroll);
+ };
+ const syncAutoScroll = () => {
+ if (!viewport || !actor.moved) return;
+ const action = resolveTimelineAutoScrollLoopAction(
+ viewport,
+ actor.lastClientX,
+ actor.lastClientY,
+ actor.scrollRaf !== 0,
+ );
+ if (action === "stop") stopAutoScroll();
+ else if (action === "start") actor.scrollRaf = requestAnimationFrame(stepAutoScroll);
+ };
+ const teardown = () => {
+ stopAutoScroll();
+ actor.unsubscribeStore?.();
+ actor.unsubscribeStore = null;
+ actor.teardownListeners?.();
+ actor.teardownListeners = null;
+ };
+ const releaseCapture = () => {
+ if (!viewport || actor.pointerId === null) return;
+ try {
+ viewport.releasePointerCapture(actor.pointerId);
+ } catch {
+ // Capture may already have been released by the browser.
+ }
+ };
+ const finishCommit = (event: TimelineRetimePointerEvent) => {
+ if (actor.phase !== "active" || !matchesPointer(event)) return;
+ if (actor.sessionEpoch !== usePlayerStore.getState().timelineSessionEpoch) {
+ cancel(actor);
+ return;
+ }
+ actor.phase = "committing";
+ actor.lastClientX = event.clientX;
+ actor.lastClientY = event.clientY;
+ teardown();
+ releaseCapture();
+ actor.onPreview(null);
+ if (coordinator.actor === actor) {
+ coordinator.actor = null;
+ }
+ actor.suppressNextClick();
+
+ const result = resolveKeyframeDrag({
+ pointerDownX: actor.pointerDownX,
+ pointerUpX: pointerXWithScroll(),
+ clipWidthPx: actor.clipWidthPx,
+ draggedClipPct: actor.fromClipPercentage,
+ draggedIndex: actor.draggedIndex,
+ sortedClipPcts: actor.sortedClipPercentages,
+ });
+ if (result.kind === "click" || result.kind === "noop") {
+ actor.onSelect(actor.target, event.shiftKey);
+ } else if (result.kind === "move" && result.toClipPct !== undefined) {
+ const animationKeyframes =
+ actor.target.animationId === undefined
+ ? actor.keyframes
+ : actor.keyframes.filter((keyframe) => keyframe.animationId === actor.target.animationId);
+ const tweenPercentages = animationKeyframes
+ .map((keyframe) => keyframe.tweenPercentage)
+ .filter((value): value is number => typeof value === "number");
+ const mappedTweenPercentage = clipToTweenPercentage(animationKeyframes, result.toClipPct);
+ const newTweenPercentage = tweenPercentages.length
+ ? Math.max(
+ Math.min(...tweenPercentages),
+ Math.min(Math.max(...tweenPercentages), mappedTweenPercentage),
+ )
+ : mappedTweenPercentage;
+ const pendingBefore = coordinator.pending.get(actor.keyframeKey);
+ const fromTarget = pendingBefore
+ ? {
+ ...actor.target,
+ percentage: pendingBefore.clipPercentage,
+ tweenPercentage: pendingBefore.tweenPercentage,
+ }
+ : actor.target;
+ const nextPending = {
+ elementId: actor.elementId,
+ clipPercentage: result.toClipPct,
+ tweenPercentage: newTweenPercentage,
+ sessionEpoch: actor.sessionEpoch,
+ };
+ coordinator.pending.set(actor.keyframeKey, nextPending);
+ const clearPending = () => {
+ if (coordinator.pending.get(actor.keyframeKey) === nextPending) {
+ coordinator.pending.delete(actor.keyframeKey);
+ }
+ };
+ void actor.onMove(fromTarget, result.toClipPct).then((committed) => {
+ if (!committed) clearPending();
+ }, clearPending);
+ actor.onSelect(
+ {
+ ...actor.target,
+ percentage: result.toClipPct,
+ tweenPercentage: newTweenPercentage,
+ },
+ false,
+ );
+ }
+ actor.phase = "complete";
+ };
+ const onPointerMove = (event: TimelineRetimePointerEvent) => {
+ if (actor.phase !== "active" || !matchesPointer(event)) return;
+ actor.lastClientX = event.clientX;
+ actor.lastClientY = event.clientY;
+ if (
+ !actor.moved &&
+ Math.abs(pointerXWithScroll() - actor.pointerDownX) >= KEYFRAME_DRAG_THRESHOLD_PX
+ ) {
+ actor.moved = true;
+ }
+ if (actor.moved) publishPreview();
+ syncAutoScroll();
+ };
+ const onPointerUp = (event: TimelineRetimePointerEvent) => finishCommit(event);
+ const onPointerCancel = (event: TimelineRetimePointerEvent) => {
+ if (actor.phase === "active" && matchesPointer(event)) cancel(actor);
+ };
+ const onLostPointerCapture = (event: PointerEvent) => {
+ if (actor.phase === "active" && matchesPointer(event)) cancel(actor);
+ };
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (event.key === "Escape") cancel(actor);
+ };
+
+ window.addEventListener("pointermove", onPointerMove, true);
+ window.addEventListener("pointerup", onPointerUp, true);
+ window.addEventListener("pointercancel", onPointerCancel, true);
+ window.addEventListener("keydown", onKeyDown);
+ viewport?.addEventListener("lostpointercapture", onLostPointerCapture);
+ actor.teardownListeners = () => {
+ window.removeEventListener("pointermove", onPointerMove, true);
+ window.removeEventListener("pointerup", onPointerUp, true);
+ window.removeEventListener("pointercancel", onPointerCancel, true);
+ window.removeEventListener("keydown", onKeyDown);
+ viewport?.removeEventListener("lostpointercapture", onLostPointerCapture);
+ };
+ actor.unsubscribeStore = usePlayerStore.subscribe((state) => {
+ const sourceStillPresent = state.elements.some(
+ (element) => (element.key ?? element.id) === actor.elementId,
+ );
+ if (
+ state.timelineSessionEpoch !== actor.sessionEpoch ||
+ (actor.sourceWasPresent && !sourceStillPresent)
+ ) {
+ coordinator.pending.clear();
+ cancel(actor);
+ }
+ });
+
+ if (viewport && actor.pointerId !== null) {
+ try {
+ viewport.setPointerCapture(actor.pointerId);
+ } catch {
+ // Window listeners are the native fallback when capture is unavailable.
+ }
+ }
+ return { update: onPointerMove, commit: onPointerUp, cancel: onPointerCancel };
+}
+
interface UseTimelineKeyframeHandlersInput {
expandedElements: TimelineElement[];
keyframeCache: Map;