perf(studio): stabilize virtualized keyframe retiming (#2705)

This commit is contained in:
Miguel Ángel
2026-08-04 05:14:09 +02:00
committed by GitHub
parent f52398ceef
commit 44bee4c3cf
6 changed files with 995 additions and 250 deletions
@@ -3,23 +3,51 @@
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import { TimelineClipDiamonds, TimelineDiamondLane } from "./TimelineClipDiamonds";
import type { TimelineElement } from "../store/playerStore";
import { usePlayerStore } from "../store/playerStore";
import {
TimelineClipDiamonds,
TimelineDiamondLane,
type TimelineDiamondKeyframe,
} from "./TimelineClipDiamonds";
import { timelineKeyframeSelectionKey } from "./timelineKeyframeIdentity";
import { configureTimelineTestViewport } from "./timelineTestViewport";
import { readPendingTimelineKeyframeRetimes } from "./useTimelineKeyframeHandlers";
(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 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 = document.createElement("div");
document.body.append(host);
const host = createTimelineHost();
const root = createRoot(host);
act(() => {
root.render(
@@ -46,6 +74,78 @@ function renderDiamonds(onClickKeyframe = vi.fn()) {
return { host, root, onClickKeyframe };
}
function renderRetimeLane(
onMoveKeyframe = vi.fn().mockResolvedValue(true),
strict = false,
options: {
elementId?: string;
host?: HTMLDivElement;
keyframes?: TimelineDiamondKeyframe[];
} = {},
) {
const elementId = options.elementId ?? RETIME_ELEMENT.id;
if (!options.host) {
usePlayerStore.setState({ elements: [{ ...RETIME_ELEMENT, id: elementId, key: elementId }] });
}
const host = options.host ?? createTimelineHost();
const keyframes = options.keyframes ?? [
{
percentage: 0,
tweenPercentage: 0,
propertyGroup: "position",
animationId: "anim-1",
properties: { x: 0 },
},
{
percentage: 50,
tweenPercentage: 50,
propertyGroup: "position",
animationId: "anim-1",
properties: { x: 100 },
},
{
percentage: 100,
tweenPercentage: 100,
propertyGroup: "position",
animationId: "anim-1",
properties: { x: 200 },
},
];
const onClickKeyframe = vi.fn();
const mountLane = () => {
const laneHost = document.createElement("div");
host.append(laneHost);
const root = createRoot(laneHost);
const lane = (
<TimelineDiamondLane
keyframesData={{
format: "percentage",
keyframes,
}}
clipWidthPx={200}
clipHeightPx={48}
accentColor="#4ba3d2"
isSelected
currentPercentage={0}
elementId={elementId}
selectedKeyframes={new Set()}
onClickKeyframe={onClickKeyframe}
onMoveKeyframe={onMoveKeyframe}
groupAware
/>
);
act(() => {
root.render(strict ? <React.StrictMode>{lane}</React.StrictMode> : lane);
});
const diamond = laneHost.querySelector<HTMLButtonElement>(
`button[title="${keyframes[1]?.percentage}%"]`,
);
expect(diamond).not.toBeNull();
return { diamond: diamond!, root };
};
return { ...mountLane(), host, mountLane, onClickKeyframe, onMoveKeyframe };
}
describe("TimelineClipDiamonds", () => {
it("marks only the nearest keyframe in a dense lane as under the playhead", () => {
const host = document.createElement("div");
@@ -161,6 +261,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<HTMLButtonElement>('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<HTMLButtonElement>('button[title="50%"]');
@@ -246,8 +363,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(
@@ -314,8 +430,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(
@@ -474,8 +589,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(
@@ -548,16 +662,120 @@ 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());
});
it("preserves another element's pending retime when the active source is removed", () => {
const host = createTimelineHost();
const elementA = { ...RETIME_ELEMENT, id: "clip-a", key: "clip-a" };
const elementB = { ...RETIME_ELEMENT, id: "clip-b", key: "clip-b" };
usePlayerStore.setState({ elements: [elementA, elementB] });
const laneA = renderRetimeLane(vi.fn().mockResolvedValue(true), false, {
elementId: "clip-a",
host,
});
const onMoveB = vi.fn().mockResolvedValue(true);
const laneB = renderRetimeLane(onMoveB, false, { elementId: "clip-b", host });
act(() => {
laneB.diamond.dispatchEvent(
pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100, pointerId: 7 }),
);
window.dispatchEvent(
pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 150, pointerId: 7 }),
);
laneA.diamond.dispatchEvent(
pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100, pointerId: 9 }),
);
usePlayerStore.setState({ elements: [elementB] });
laneB.diamond.dispatchEvent(
pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 150, pointerId: 11 }),
);
window.dispatchEvent(
pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 170, pointerId: 11 }),
);
});
expect(onMoveB).toHaveBeenNthCalledWith(2, expect.objectContaining({ percentage: 75 }), 85);
act(() => {
laneA.root.unmount();
laneB.root.unmount();
});
});
it("retires a pending retime once the cache exposes its destination identity", () => {
const first = renderRetimeLane();
act(() => {
first.diamond.dispatchEvent(
pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100, pointerId: 7 }),
);
window.dispatchEvent(
pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 150, pointerId: 7 }),
);
});
expect(readPendingTimelineKeyframeRetimes(first.host).size).toBe(1);
act(() => first.root.unmount());
const destination = renderRetimeLane(vi.fn().mockResolvedValue(true), false, {
host: first.host,
keyframes: [
{
percentage: 0,
tweenPercentage: 0,
propertyGroup: "position",
animationId: "anim-1",
properties: { x: 0 },
},
{
percentage: 75,
tweenPercentage: 75,
propertyGroup: "position",
animationId: "anim-1",
properties: { x: 100 },
},
{
percentage: 100,
tweenPercentage: 100,
propertyGroup: "position",
animationId: "anim-1",
properties: { x: 200 },
},
],
});
act(() => {
destination.diamond.dispatchEvent(
pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 150, pointerId: 9 }),
);
});
expect(readPendingTimelineKeyframeRetimes(first.host).size).toBe(0);
act(() => {
window.dispatchEvent(pointerEvent("pointercancel", { bubbles: true, pointerId: 9 }));
destination.root.unmount();
});
});
it.each([
["returns false", () => Promise.resolve(false)],
["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(
@@ -678,6 +896,215 @@ 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("keeps the retime preview coherent when the source lane remounts", () => {
const { diamond, mountLane, onMoveKeyframe, root } = renderRetimeLane();
act(() => {
diamond.dispatchEvent(
pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100, pointerId: 7 }),
);
window.dispatchEvent(
pointerEvent("pointermove", { bubbles: true, button: 0, clientX: 120, pointerId: 7 }),
);
});
const beforeUnmountLeft = Number.parseFloat(diamond.style.left);
act(() => {
root.unmount();
window.dispatchEvent(
pointerEvent("pointermove", { bubbles: true, button: 0, clientX: 140, pointerId: 7 }),
);
});
const { diamond: remountedDiamond, root: remountedRoot } = mountLane();
const remountedLeft = Number.parseFloat(remountedDiamond.style.left);
expect(remountedLeft).toBeGreaterThan(beforeUnmountLeft);
act(() => {
window.dispatchEvent(
pointerEvent("pointermove", { bubbles: true, button: 0, clientX: 160, pointerId: 7 }),
);
});
expect(Number.parseFloat(remountedDiamond.style.left)).toBeGreaterThan(remountedLeft);
act(() => {
window.dispatchEvent(
pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 160, pointerId: 7 }),
);
});
expect(onMoveKeyframe).toHaveBeenCalledExactlyOnceWith(expect.any(Object), 80);
act(() => remountedRoot.unmount());
});
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("auto-scrolls horizontally without virtualizing the source row away", () => {
let frame: FrameRequestCallback | null = null;
vi.spyOn(globalThis, "requestAnimationFrame").mockImplementation((callback) => {
frame = callback;
return 1;
});
vi.spyOn(globalThis, "cancelAnimationFrame").mockImplementation(() => undefined);
const { diamond, host, root } = renderRetimeLane();
configureTimelineTestViewport(host, 10_000);
act(() => {
diamond.dispatchEvent(
pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100, pointerId: 7 }),
);
window.dispatchEvent(
pointerEvent("pointermove", {
bubbles: true,
button: 0,
clientX: 790,
clientY: 239,
pointerId: 7,
}),
);
});
expect(frame).not.toBeNull();
act(() => frame?.(0));
expect(host.scrollLeft).toBeGreaterThan(0);
expect(host.scrollTop).toBe(0);
act(() => {
window.dispatchEvent(pointerEvent("pointercancel", { bubbles: true, pointerId: 7 }));
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
@@ -687,8 +1114,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(
@@ -722,8 +1148,7 @@ describe("TimelineClipDiamonds", () => {
});
const renderSegmentLane = (lastAmbiguous: boolean, clipWidthPx = 200) => {
const host = document.createElement("div");
document.body.append(host);
const host = createTimelineHost();
const root = createRoot(host);
const kf = (percentage: number, extra: Record<string, unknown> = {}) => ({
percentage,
@@ -811,8 +1236,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,
@@ -1,21 +1,20 @@
import { memo, useEffect, useRef, useState } from "react";
import { BEAT_BAND_H } from "./BeatStrip";
import {
KEYFRAME_DRAG_THRESHOLD_PX,
previewClipPct,
resolveKeyframeDrag,
} from "../../components/editor/keyframeDrag";
import { TimelineDiamondConnectors } from "./TimelineDiamondConnectors";
import { clipToTweenPercentage } from "../../components/editor/KeyframeNavigation";
import { LANE_H } from "./timelineLayout";
import { STUDIO_PREVIEW_FPS } from "../lib/time";
import { timelineKeyframeSelectionKey } from "./timelineKeyframeIdentity";
import {
beginTimelineKeyframeRetime,
readPendingTimelineKeyframeRetimes,
subscribeTimelineKeyframeRetimePreview,
type TimelineKeyframeRetimeHandle,
} from "./useTimelineKeyframeHandlers";
import {
DIAMOND_RATIO,
KF_MAX_PCT,
KF_MIN_PCT,
keyframeTarget,
type DragState,
type TimelineClipDiamondsProps,
type TimelineDiamondKeyframe,
type TimelineDiamondLaneProps,
@@ -86,65 +85,29 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
globalEase = "none",
}: TimelineDiamondLaneProps) {
// Hooks must run before the early return below.
const dragRef = useRef<DragState | null>(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<Map<string, { clipPct: number; tweenPct: number }> | null>(null);
// Lazy: `useRef(new Map())` allocates a Map on every render and throws all but
// the first away, once per mounted lane.
pendingRetimeRef.current ??= new Map();
const pendingRetimes = pendingRetimeRef.current;
// The most recent retime dispatched from this lane, whichever diamond it came
// from. Selection is lane-wide, so "is my revert still relevant" is a lane-wide
// question, not a per-keyframe one.
const latestRetimeRef = useRef<{ clipPct: number; tweenPct: number } | null>(null);
useEffect(() => {
// Clear a pending entry once the authoritative cache reflects THAT 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. Match by identity too: a bare "some keyframe is near that %" test
// cleared the entry whenever an unrelated sibling happened to sit there,
// which is easy to hit on an evenly spaced row.
const pendingEntries = pendingRetimeRef.current;
if (!pendingEntries) return;
for (const [key, pending] of pendingEntries) {
const settled = keyframesData.keyframes.some(
(k) =>
timelineKeyframeSelectionKey(elementId, keyframeTarget(k)) === key &&
Math.abs(k.percentage - pending.clipPct) < 0.2,
);
if (settled) pendingEntries.delete(key);
}
}, [keyframesData.keyframes, elementId]);
// The retime itself lives on the stable scroll viewport (beginTimelineKeyframeRetime),
// so a row unmounted by virtualization mid-drag does not drop the gesture.
// This lane only arms it and renders the preview it publishes.
const rootRef = useRef<HTMLDivElement>(null);
const retimeHandleRef = useRef<TimelineKeyframeRetimeHandle | null>(null);
// Retime destinations already dispatched but not yet in the keyframe cache, so
// a rapid second drag composes from where the first move left the keyframe
// instead of the stale rendered value.
const pendingRetimes = readPendingTimelineKeyframeRetimes(rootRef.current);
// 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.
const [preview, setPreview] = useState<{ kfKey: string; clipPct: number } | null>(null);
// One preview render per frame: a 120Hz trackpad fires pointermove far faster
// than the lane can repaint, and every diamond in the row re-evaluates its
// memo on each of those renders.
const previewFrameRef = useRef<number | null>(null);
const cancelPreviewFrame = () => {
if (previewFrameRef.current === null) return;
cancelAnimationFrame(previewFrameRef.current);
previewFrameRef.current = null;
};
// Escape backs out of an in-flight retime, the way clip and element drags
// already do. Nothing was written yet (the commit happens on pointerup), so
// dropping the preview is the whole undo.
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape" || !dragRef.current || dragRef.current.cancelled) return;
dragRef.current.cancelled = true;
cancelPreviewFrame();
setPreview(null);
};
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("keydown", onKeyDown);
cancelPreviewFrame();
};
const source = rootRef.current;
if (!source) return;
return subscribeTimelineKeyframeRetimePreview(source, (nextPreview) => {
setPreview(
nextPreview === null
? null
: { kfKey: nextPreview.keyframeKey, clipPct: nextPreview.clipPercentage },
);
});
}, []);
// The button element can re-render (reposition/unmount) synchronously from
// the state updates onClickKeyframe/onMoveKeyframe trigger, before the
@@ -197,7 +160,7 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
// O(keyframes squared) allocations on every playhead tick.
const pendingClipPctOf = (keyframe: TimelineDiamondKeyframe) =>
pendingRetimes.get(timelineKeyframeSelectionKey(elementId, keyframeTarget(keyframe)))
?.clipPct ?? keyframe.percentage;
?.clipPercentage ?? keyframe.percentage;
const siblingRows = new Map<
string | undefined,
{ keyframes: TimelineDiamondKeyframe[]; clipPcts: number[] }
@@ -247,6 +210,7 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
return (
<div
ref={rootRef}
className="absolute inset-0"
style={{
// Above the clip's trim-handle strips (TimelineClip.tsx, z-index 4) so
@@ -294,150 +258,43 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
const onPointerDown = (e: React.PointerEvent<HTMLButtonElement>) => {
if (e.button !== 0) return;
e.stopPropagation();
if (canDrag) {
e.currentTarget.setPointerCapture?.(e.pointerId);
dragRef.current = {
kfKey,
startX: e.clientX,
lastX: e.clientX,
index: siblingIndex,
fromClipPct: pendingRetimes.get(kfKey)?.clipPct ?? kf.percentage,
moved: false,
};
}
};
const onPointerMove = (e: React.PointerEvent<HTMLButtonElement>) => {
const d = dragRef.current;
if (!d || d.kfKey !== kfKey || d.cancelled) return;
d.lastX = e.clientX;
if (!d.moved && Math.abs(e.clientX - d.startX) >= KEYFRAME_DRAG_THRESHOLD_PX) {
d.moved = true;
}
if (!d.moved || previewFrameRef.current !== null) return;
previewFrameRef.current = requestAnimationFrame(() => {
previewFrameRef.current = null;
const live = dragRef.current;
if (!live || live.kfKey !== kfKey || live.cancelled) return;
setPreview({
kfKey,
clipPct: previewClipPct({
pointerDownX: live.startX,
pointerMoveX: live.lastX,
clipWidthPx,
draggedClipPct: live.fromClipPct,
draggedIndex: live.index,
sortedClipPcts: siblingClipPcts,
}),
});
if (!canDrag) return;
retimeHandleRef.current = beginTimelineKeyframeRetime({
event: e,
elementId,
keyframeKey: kfKey,
target,
keyframes: keyframesData.keyframes,
clipWidthPx,
// Clamp against this keyframe's own tween, not the whole merged row:
// a merged row interleaves several animations, and two colliding at
// one percentage would otherwise pin each other's diamonds in place.
draggedIndex: siblingIndex,
sortedClipPercentages: siblingClipPcts,
keyframeKeyOf: (keyframe) =>
timelineKeyframeSelectionKey(elementId, keyframeTarget(keyframe)),
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<HTMLButtonElement>) => {
const d = dragRef.current;
if (d?.kfKey === kfKey && d.cancelled) {
// Escape already ended this drag; the release is not a click.
dragRef.current = null;
e.currentTarget.releasePointerCapture?.(e.pointerId);
suppressNextClick();
// The viewport coordinator owns an armed retime; this local path is
// only for diamonds that cannot be dragged.
if (canDrag) {
retimeHandleRef.current?.commit(e);
retimeHandleRef.current = null;
e.stopPropagation();
return;
}
// 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);
return;
}
e.stopPropagation();
dragRef.current = null;
cancelPreviewFrame();
setPreview(null);
e.currentTarget.releasePointerCapture?.(e.pointerId);
if (e.button !== 0) return;
suppressNextClick();
// Single-diamond retime by design: a multi-select drag would have to
// move every selected keyframe as one mutation, which the script ops
// do not express yet. Selecting several and dragging one moves only
// the dragged one.
const res = resolveKeyframeDrag({
pointerDownX: d.startX,
pointerUpX: e.clientX,
clipWidthPx,
draggedClipPct: d.fromClipPct,
draggedIndex: siblingIndex,
sortedClipPcts: siblingClipPcts,
});
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 = pendingRetimes.get(kfKey);
const fromTarget = pendingBefore
? {
...target,
percentage: pendingBefore.clipPct,
tweenPercentage: pendingBefore.tweenPct,
}
: target;
const pending = { clipPct: res.toClipPct, tweenPct: newTweenPct };
pendingRetimes.set(kfKey, pending);
latestRetimeRef.current = pending;
const clearPending = () => {
if (pendingRetimes.get(kfKey) === pending) {
pendingRetimes.delete(kfKey);
}
};
// A rejected drop (the destination time is already occupied) snaps
// the diamond back to its source position, so the pending entry AND
// the selection have to revert with it — parking on the ghost drop
// position strands the playhead + selection on a keyframe that does
// not exist there.
const revertRetime = () => {
// Only the newest gesture owns the selection. A rejected first drag
// whose commit settles after a second one started would otherwise
// park the selection back on ITS source keyframe, undoing a retime
// the user has already made and moving the playhead with it.
const isLatest = latestRetimeRef.current === pending;
clearPending();
if (isLatest) onClickKeyframe?.(fromTarget);
};
void onMoveKeyframe?.(fromTarget, res.toClipPct).then((committed) => {
if (!committed) revertRetime();
}, revertRetime);
// 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. Done
// optimistically so the gesture stays responsive; revertRetime puts
// it back if the move is rejected.
onClickKeyframe?.({
...target,
percentage: res.toClipPct,
tweenPercentage: newTweenPct,
});
}
if (e.shiftKey) onShiftClickKeyframe?.(target);
else onClickKeyframe?.(target);
};
return (
@@ -476,18 +333,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;
cancelPreviewFrame();
setPreview(null);
e.currentTarget.releasePointerCapture?.(e.pointerId);
}}
onPointerCancel={
canDrag
? (e) => {
retimeHandleRef.current?.cancel(e);
retimeHandleRef.current = null;
}
: undefined
}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
@@ -93,20 +93,6 @@ export const DIAMOND_RATIO = 0.8;
export const KF_MIN_PCT = -5;
export const KF_MAX_PCT = 105;
export type DragState = {
kfKey: string;
startX: number;
fromClipPct: number;
moved: boolean;
/** Latest pointer x, flushed to the preview once per frame. */
lastX: number;
/** Index in the sorted row, needed by the neighbour clamp off the render path. */
index: number;
/** Escape was pressed: the drag is dead, and the pointerup that follows is
* swallowed rather than falling through to the click branch. */
cancelled?: boolean;
};
/**
* The full identity of a diamond, used by every callback and by the selection
* key. Collapsed clip rows and expanded property lanes read the same cache, so
@@ -115,7 +101,11 @@ export type DragState = {
* the other, and would strip the animation id the retime/delete mutations use to
* pick between two animations that collide at one percentage.
*/
export function keyframeTarget(keyframe: TimelineDiamondKeyframe): TimelineKeyframeTarget {
export function keyframeTarget(
// The identity fields only, so callers holding a narrower keyframe row (the
// retime coordinator's) build the same key instead of re-listing the shape.
keyframe: Omit<TimelineDiamondKeyframe, "properties">,
): TimelineKeyframeTarget {
return {
percentage: keyframe.percentage,
tweenPercentage: keyframe.tweenPercentage,
@@ -0,0 +1,13 @@
/** Configure the shared viewport geometry used by timeline gesture hook tests. */
export function configureTimelineTestViewport(scroll: HTMLElement, scrollHeight: number): void {
scroll.getBoundingClientRect = () =>
({ left: 0, top: 0, right: 800, bottom: 240, width: 800, height: 240 }) as DOMRect;
Object.defineProperties(scroll, {
scrollLeft: { configurable: true, writable: true, value: 0 },
scrollTop: { configurable: true, writable: true, value: 0 },
scrollWidth: { configurable: true, value: 10_000 },
scrollHeight: { configurable: true, value: scrollHeight },
clientWidth: { configurable: true, value: 800 },
clientHeight: { configurable: true, value: 240 },
});
}
@@ -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,7 +44,7 @@ const COLLIDING_TARGET: TimelineKeyframeTarget = {
afterEach(() => {
document.body.innerHTML = "";
trackStudioSegmentEaseEdit.mockClear();
vi.restoreAllMocks();
usePlayerStore.setState({ focusedEaseSegment: null });
});
@@ -78,6 +76,9 @@ function mountHandlers(options: Partial<Parameters<typeof useTimelineKeyframeHan
describe("useTimelineKeyframeHandlers", () => {
it("tracks opening the segment ease editor when a timeline segment is selected", () => {
const trackStudioSegmentEaseEdit = vi
.spyOn(telemetry, "trackStudioSegmentEaseEdit")
.mockImplementation(() => {});
const { root, handlers } = mountHandlers();
act(() => handlers.onSelectSegment?.(ELEMENT.id, TARGET));
@@ -1,13 +1,475 @@
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 { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth";
import type { TimelineElement, KeyframeCacheEntry } from "../store/playerStore";
import { usePlayerStore } from "../store/playerStore";
import type { KeyframeDiamondContextMenuState } from "./KeyframeDiamondContextMenu";
import {
applyTimelineHorizontalAutoScrollStep,
resolveTimelineAutoScrollLoopAction,
} from "./timelineEditing";
import {
timelineKeyframeSelectionKey,
type TimelineKeyframeTarget,
} from "./timelineKeyframeIdentity";
interface TimelineRetimeKeyframe {
percentage: number;
tweenPercentage?: number;
propertyGroup?: string;
animationId?: string;
collidingAnimationTargets?: AnimationKeyframeTarget[];
}
interface TimelineKeyframeRetimeInput {
event: ReactPointerEvent<HTMLElement>;
elementId: string;
keyframeKey: string;
target: TimelineKeyframeTarget;
keyframes: readonly TimelineRetimeKeyframe[];
clipWidthPx: number;
draggedIndex: number;
sortedClipPercentages: readonly number[];
onMove: (target: TimelineKeyframeTarget, toClipPercentage: number) => Promise<boolean>;
onSelect: (target: TimelineKeyframeTarget, additive: boolean) => void;
suppressNextClick: () => void;
/**
* Selection key of a cache keyframe, used to retire a pending entry once the
* authoritative cache reflects THAT keyframe at its destination. Without it a
* bare "some keyframe is near that %" test retires the entry whenever an
* unrelated sibling happens to sit there, which is easy to hit on an evenly
* spaced row.
*/
keyframeKeyOf: (keyframe: TimelineRetimeKeyframe) => string;
}
interface PendingTimelineKeyframeRetime {
elementId: string;
clipPercentage: number;
tweenPercentage: number;
destinationKeyframeKey: string;
sessionEpoch: number;
}
interface TimelineKeyframeRetimePreview {
keyframeKey: string;
clipPercentage: 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<string, PendingTimelineKeyframeRetime>;
preview: TimelineKeyframeRetimePreview | null;
previewListeners: Set<(preview: TimelineKeyframeRetimePreview | null) => void>;
/**
* The most recent retime dispatched through this viewport, whichever diamond
* it came from. Selection is viewport-wide, so "is my revert still relevant"
* is a viewport-wide question, not a per-keyframe one.
*/
latest: PendingTimelineKeyframeRetime | null;
}
type TimelineRetimePointerEvent = Pick<
PointerEvent,
"clientX" | "clientY" | "pointerId" | "shiftKey"
>;
const keyframeRetimeCoordinators = new WeakMap<EventTarget, TimelineKeyframeRetimeCoordinator>();
function getRetimeOwner(target: HTMLElement): EventTarget {
return target.closest<HTMLElement>("[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(),
preview: null,
previewListeners: new Set(),
latest: null,
};
keyframeRetimeCoordinators.set(owner, coordinator);
return coordinator;
}
function stablePointerId(pointerId: number): number | null {
return Number.isFinite(pointerId) ? pointerId : null;
}
/**
* The retime destinations already dispatched from `source`'s viewport but not
* yet reflected in the keyframe cache. A renderer clamping a drag against its
* neighbours has to compose these in, or a second drag can cross a neighbour
* that already moved past it.
*/
export function readPendingTimelineKeyframeRetimes(
source: HTMLElement | null | undefined,
): ReadonlyMap<string, { clipPercentage: number; tweenPercentage: number }> {
if (!source) return EMPTY_PENDING_RETIMES;
return keyframeRetimeCoordinators.get(getRetimeOwner(source))?.pending ?? EMPTY_PENDING_RETIMES;
}
const EMPTY_PENDING_RETIMES: ReadonlyMap<
string,
{ clipPercentage: number; tweenPercentage: number }
> = new Map();
function publishRetimePreview(
coordinator: TimelineKeyframeRetimeCoordinator,
preview: TimelineKeyframeRetimePreview | null,
): void {
coordinator.preview = preview;
for (const listener of coordinator.previewListeners) listener(preview);
}
export function subscribeTimelineKeyframeRetimePreview(
source: HTMLElement,
listener: (preview: TimelineKeyframeRetimePreview | null) => void,
): () => void {
const coordinator = getRetimeCoordinator(getRetimeOwner(source));
coordinator.previewListeners.add(listener);
listener(coordinator.preview);
return () => coordinator.previewListeners.delete(listener);
}
function resolveRetimeTweenPercentage(
actor: TimelineKeyframeRetimeActor,
toClipPercentage: number,
): number {
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 mapped = clipToTweenPercentage(animationKeyframes, toClipPercentage);
if (tweenPercentages.length === 0) return mapped;
return Math.max(Math.min(...tweenPercentages), Math.min(Math.max(...tweenPercentages), mapped));
}
export interface TimelineKeyframeRetimeHandle {
update: (event: ReactPointerEvent<HTMLElement>) => void;
commit: (event: ReactPointerEvent<HTMLElement>) => void;
cancel: (event: ReactPointerEvent<HTMLElement>) => 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";
publishRetimePreview(coordinator, 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) {
// Tolerance, not equality: cache writers round clip %s, so an exact check
// would leak an entry after every successful retime.
if (
pending.elementId === input.elementId &&
input.keyframes.some(
(keyframe) =>
input.keyframeKeyOf(keyframe) === pending.destinationKeyframeKey &&
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 = () => {
publishRetimePreview(coordinator, {
keyframeKey: actor.keyframeKey,
clipPercentage: 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 ||
!applyTimelineHorizontalAutoScrollStep(viewport, actor.lastClientX)
) {
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 claimActorForCommit = (event: TimelineRetimePointerEvent): boolean => {
if (actor.phase !== "active" || !matchesPointer(event)) return false;
if (actor.sessionEpoch !== usePlayerStore.getState().timelineSessionEpoch) {
cancel(actor);
return false;
}
actor.phase = "committing";
actor.lastClientX = event.clientX;
actor.lastClientY = event.clientY;
teardown();
releaseCapture();
publishRetimePreview(coordinator, null);
if (coordinator.actor === actor) {
coordinator.actor = null;
}
actor.suppressNextClick();
return true;
};
const commitMove = (toClipPercentage: number) => {
const newTweenPercentage = resolveRetimeTweenPercentage(actor, toClipPercentage);
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: toClipPercentage,
tweenPercentage: newTweenPercentage,
destinationKeyframeKey: timelineKeyframeSelectionKey(actor.elementId, {
...actor.target,
percentage: toClipPercentage,
tweenPercentage: newTweenPercentage,
}),
sessionEpoch: actor.sessionEpoch,
};
coordinator.pending.set(actor.keyframeKey, nextPending);
coordinator.latest = nextPending;
const clearPending = () => {
if (coordinator.pending.get(actor.keyframeKey) === nextPending) {
coordinator.pending.delete(actor.keyframeKey);
}
};
// A rejected drop (the destination time is already occupied) snaps the
// diamond back to its source position, so the pending entry AND the
// selection have to revert with it: parking on the ghost drop position
// strands the playhead + selection on a keyframe that is not there.
const revertRetime = () => {
// Only the newest gesture owns the selection. A rejected first drag
// whose commit settles after a second one started would otherwise park
// the selection back on ITS source keyframe, undoing a retime the user
// has already made and moving the playhead with it.
const isLatest = coordinator.latest === nextPending;
clearPending();
if (isLatest) actor.onSelect(fromTarget, false);
};
void actor.onMove(fromTarget, toClipPercentage).then((committed) => {
if (!committed) revertRetime();
}, revertRetime);
actor.onSelect(
{
...actor.target,
percentage: toClipPercentage,
tweenPercentage: newTweenPercentage,
},
false,
);
};
const finishCommit = (event: TimelineRetimePointerEvent) => {
if (!claimActorForCommit(event)) return;
const result = resolveKeyframeDrag({
pointerDownX: actor.pointerDownX,
pointerUpX: pointerXWithScroll(),
clipWidthPx: actor.clipWidthPx,
draggedClipPct: actor.fromClipPercentage,
draggedIndex: actor.draggedIndex,
sortedClipPcts: actor.sortedClipPercentages,
});
if (result.kind === "move" && result.toClipPct !== undefined) {
commitMove(result.toClipPct);
} else {
actor.onSelect(actor.target, event.shiftKey);
}
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) {
coordinator.pending.clear();
coordinator.latest = null;
cancel(actor);
} else if (actor.sourceWasPresent && !sourceStillPresent) {
for (const [key, pending] of coordinator.pending) {
if (pending.elementId === actor.elementId) coordinator.pending.delete(key);
}
if (coordinator.latest?.elementId === actor.elementId) coordinator.latest = null;
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<string, KeyframeCacheEntry>;