mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 10:06:21 +00:00
perf(studio): stabilize virtualized keyframe retiming (#2705)
This commit is contained in:
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user