mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 14:50:02 +00:00
perf(studio): stabilize virtualized keyframe retiming (#2705)
This commit is contained in:
@@ -3,23 +3,51 @@
|
|||||||
import React, { act } from "react";
|
import React, { act } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
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 { 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;
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
document.body.innerHTML = "";
|
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 {
|
function pointerEvent(type: string, init: PointerEventInit): Event {
|
||||||
if (typeof PointerEvent === "function") return new PointerEvent(type, init);
|
const event =
|
||||||
return new MouseEvent(type, init);
|
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()) {
|
function renderDiamonds(onClickKeyframe = vi.fn()) {
|
||||||
const host = document.createElement("div");
|
const host = createTimelineHost();
|
||||||
document.body.append(host);
|
|
||||||
const root = createRoot(host);
|
const root = createRoot(host);
|
||||||
act(() => {
|
act(() => {
|
||||||
root.render(
|
root.render(
|
||||||
@@ -46,6 +74,78 @@ function renderDiamonds(onClickKeyframe = vi.fn()) {
|
|||||||
return { host, root, onClickKeyframe };
|
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", () => {
|
describe("TimelineClipDiamonds", () => {
|
||||||
it("marks only the nearest keyframe in a dense lane as under the playhead", () => {
|
it("marks only the nearest keyframe in a dense lane as under the playhead", () => {
|
||||||
const host = document.createElement("div");
|
const host = document.createElement("div");
|
||||||
@@ -161,6 +261,23 @@ describe("TimelineClipDiamonds", () => {
|
|||||||
act(() => root.unmount());
|
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", () => {
|
it("treats primary pointerup without drag as a keyframe click", () => {
|
||||||
const { host, root, onClickKeyframe } = renderDiamonds();
|
const { host, root, onClickKeyframe } = renderDiamonds();
|
||||||
const diamond = host.querySelector<HTMLButtonElement>('button[title="50%"]');
|
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", () => {
|
it("treats a drag-armed press that resolves to a no-op move as a click", () => {
|
||||||
const onClickKeyframe = vi.fn();
|
const onClickKeyframe = vi.fn();
|
||||||
const onMoveKeyframe = vi.fn();
|
const onMoveKeyframe = vi.fn();
|
||||||
const host = document.createElement("div");
|
const host = createTimelineHost();
|
||||||
document.body.append(host);
|
|
||||||
const root = createRoot(host);
|
const root = createRoot(host);
|
||||||
act(() => {
|
act(() => {
|
||||||
root.render(
|
root.render(
|
||||||
@@ -314,8 +430,7 @@ describe("TimelineClipDiamonds", () => {
|
|||||||
it("reselects a retimed keyframe with its post-move tween percentage", () => {
|
it("reselects a retimed keyframe with its post-move tween percentage", () => {
|
||||||
const onClickKeyframe = vi.fn();
|
const onClickKeyframe = vi.fn();
|
||||||
const onMoveKeyframe = vi.fn().mockResolvedValue(true);
|
const onMoveKeyframe = vi.fn().mockResolvedValue(true);
|
||||||
const host = document.createElement("div");
|
const host = createTimelineHost();
|
||||||
document.body.append(host);
|
|
||||||
const root = createRoot(host);
|
const root = createRoot(host);
|
||||||
act(() => {
|
act(() => {
|
||||||
root.render(
|
root.render(
|
||||||
@@ -474,8 +589,7 @@ describe("TimelineClipDiamonds", () => {
|
|||||||
|
|
||||||
it("composes a rapid second retime from the pending position", () => {
|
it("composes a rapid second retime from the pending position", () => {
|
||||||
const onMoveKeyframe = vi.fn().mockResolvedValue(true);
|
const onMoveKeyframe = vi.fn().mockResolvedValue(true);
|
||||||
const host = document.createElement("div");
|
const host = createTimelineHost();
|
||||||
document.body.append(host);
|
|
||||||
const root = createRoot(host);
|
const root = createRoot(host);
|
||||||
act(() => {
|
act(() => {
|
||||||
root.render(
|
root.render(
|
||||||
@@ -548,16 +662,120 @@ describe("TimelineClipDiamonds", () => {
|
|||||||
},
|
},
|
||||||
85,
|
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());
|
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([
|
it.each([
|
||||||
["returns false", () => Promise.resolve(false)],
|
["returns false", () => Promise.resolve(false)],
|
||||||
["rejects", () => Promise.reject(new Error("retime failed"))],
|
["rejects", () => Promise.reject(new Error("retime failed"))],
|
||||||
])("clears a failed pending retime when the callback %s", async (_label, settle) => {
|
])("clears a failed pending retime when the callback %s", async (_label, settle) => {
|
||||||
const onMoveKeyframe = vi.fn().mockImplementationOnce(settle).mockResolvedValue(true);
|
const onMoveKeyframe = vi.fn().mockImplementationOnce(settle).mockResolvedValue(true);
|
||||||
const host = document.createElement("div");
|
const host = createTimelineHost();
|
||||||
document.body.append(host);
|
|
||||||
const root = createRoot(host);
|
const root = createRoot(host);
|
||||||
act(() => {
|
act(() => {
|
||||||
root.render(
|
root.render(
|
||||||
@@ -678,6 +896,215 @@ describe("TimelineClipDiamonds", () => {
|
|||||||
act(() => root.unmount());
|
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
|
// Regression: onClickKeyframe's state updates can re-render the diamond
|
||||||
// button out from under the gesture before the browser auto-synthesizes the
|
// button out from under the gesture before the browser auto-synthesizes the
|
||||||
// "click" event that follows a button's pointerdown+pointerup. That orphaned
|
// "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.
|
// own clip. suppressClickRef lets that ancestor ignore the stray click.
|
||||||
it("arms suppressClickRef synchronously on a keyframe click", () => {
|
it("arms suppressClickRef synchronously on a keyframe click", () => {
|
||||||
const suppressClickRef = { current: false };
|
const suppressClickRef = { current: false };
|
||||||
const host = document.createElement("div");
|
const host = createTimelineHost();
|
||||||
document.body.append(host);
|
|
||||||
const root = createRoot(host);
|
const root = createRoot(host);
|
||||||
act(() => {
|
act(() => {
|
||||||
root.render(
|
root.render(
|
||||||
@@ -722,8 +1148,7 @@ describe("TimelineClipDiamonds", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const renderSegmentLane = (lastAmbiguous: boolean, clipWidthPx = 200) => {
|
const renderSegmentLane = (lastAmbiguous: boolean, clipWidthPx = 200) => {
|
||||||
const host = document.createElement("div");
|
const host = createTimelineHost();
|
||||||
document.body.append(host);
|
|
||||||
const root = createRoot(host);
|
const root = createRoot(host);
|
||||||
const kf = (percentage: number, extra: Record<string, unknown> = {}) => ({
|
const kf = (percentage: number, extra: Record<string, unknown> = {}) => ({
|
||||||
percentage,
|
percentage,
|
||||||
@@ -811,8 +1236,7 @@ describe("TimelineClipDiamonds", () => {
|
|||||||
it("hides the inline ease button on a segment with no source animation id", () => {
|
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
|
// 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.
|
// target; the segment ending on it must not render a (dead) ease button.
|
||||||
const host = document.createElement("div");
|
const host = createTimelineHost();
|
||||||
document.body.append(host);
|
|
||||||
const root = createRoot(host);
|
const root = createRoot(host);
|
||||||
const kf = (percentage: number, animationId?: string) => ({
|
const kf = (percentage: number, animationId?: string) => ({
|
||||||
percentage,
|
percentage,
|
||||||
|
|||||||
@@ -1,21 +1,20 @@
|
|||||||
import { memo, useEffect, useRef, useState } from "react";
|
import { memo, useEffect, useRef, useState } from "react";
|
||||||
import { BEAT_BAND_H } from "./BeatStrip";
|
import { BEAT_BAND_H } from "./BeatStrip";
|
||||||
import {
|
|
||||||
KEYFRAME_DRAG_THRESHOLD_PX,
|
|
||||||
previewClipPct,
|
|
||||||
resolveKeyframeDrag,
|
|
||||||
} from "../../components/editor/keyframeDrag";
|
|
||||||
import { TimelineDiamondConnectors } from "./TimelineDiamondConnectors";
|
import { TimelineDiamondConnectors } from "./TimelineDiamondConnectors";
|
||||||
import { clipToTweenPercentage } from "../../components/editor/KeyframeNavigation";
|
|
||||||
import { LANE_H } from "./timelineLayout";
|
import { LANE_H } from "./timelineLayout";
|
||||||
import { STUDIO_PREVIEW_FPS } from "../lib/time";
|
import { STUDIO_PREVIEW_FPS } from "../lib/time";
|
||||||
import { timelineKeyframeSelectionKey } from "./timelineKeyframeIdentity";
|
import { timelineKeyframeSelectionKey } from "./timelineKeyframeIdentity";
|
||||||
|
import {
|
||||||
|
beginTimelineKeyframeRetime,
|
||||||
|
readPendingTimelineKeyframeRetimes,
|
||||||
|
subscribeTimelineKeyframeRetimePreview,
|
||||||
|
type TimelineKeyframeRetimeHandle,
|
||||||
|
} from "./useTimelineKeyframeHandlers";
|
||||||
import {
|
import {
|
||||||
DIAMOND_RATIO,
|
DIAMOND_RATIO,
|
||||||
KF_MAX_PCT,
|
KF_MAX_PCT,
|
||||||
KF_MIN_PCT,
|
KF_MIN_PCT,
|
||||||
keyframeTarget,
|
keyframeTarget,
|
||||||
type DragState,
|
|
||||||
type TimelineClipDiamondsProps,
|
type TimelineClipDiamondsProps,
|
||||||
type TimelineDiamondKeyframe,
|
type TimelineDiamondKeyframe,
|
||||||
type TimelineDiamondLaneProps,
|
type TimelineDiamondLaneProps,
|
||||||
@@ -86,65 +85,29 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
|
|||||||
globalEase = "none",
|
globalEase = "none",
|
||||||
}: TimelineDiamondLaneProps) {
|
}: TimelineDiamondLaneProps) {
|
||||||
// Hooks must run before the early return below.
|
// Hooks must run before the early return below.
|
||||||
const dragRef = useRef<DragState | null>(null);
|
// The retime itself lives on the stable scroll viewport (beginTimelineKeyframeRetime),
|
||||||
// Pending retime destination (clip + tween %) per keyframe key, so a rapid
|
// so a row unmounted by virtualization mid-drag does not drop the gesture.
|
||||||
// second drag composes from where the first move left the keyframe (whose
|
// This lane only arms it and renders the preview it publishes.
|
||||||
// cache entry has not rebuilt yet) instead of the stale rendered value.
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
const pendingRetimeRef = useRef<Map<string, { clipPct: number; tweenPct: number }> | null>(null);
|
const retimeHandleRef = useRef<TimelineKeyframeRetimeHandle | null>(null);
|
||||||
// Lazy: `useRef(new Map())` allocates a Map on every render and throws all but
|
// Retime destinations already dispatched but not yet in the keyframe cache, so
|
||||||
// the first away, once per mounted lane.
|
// a rapid second drag composes from where the first move left the keyframe
|
||||||
pendingRetimeRef.current ??= new Map();
|
// instead of the stale rendered value.
|
||||||
const pendingRetimes = pendingRetimeRef.current;
|
const pendingRetimes = readPendingTimelineKeyframeRetimes(rootRef.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]);
|
|
||||||
// Visual-only preview of the dragged diamond's clip-% — no runtime/GSAP hold
|
// 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
|
// (that optimistic hold was the #1763 flake). The atomic move-keyframe commit
|
||||||
// on drop re-keys the diamond from source.
|
// on drop re-keys the diamond from source.
|
||||||
const [preview, setPreview] = useState<{ kfKey: string; clipPct: number } | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
const onKeyDown = (event: KeyboardEvent) => {
|
const source = rootRef.current;
|
||||||
if (event.key !== "Escape" || !dragRef.current || dragRef.current.cancelled) return;
|
if (!source) return;
|
||||||
dragRef.current.cancelled = true;
|
return subscribeTimelineKeyframeRetimePreview(source, (nextPreview) => {
|
||||||
cancelPreviewFrame();
|
setPreview(
|
||||||
setPreview(null);
|
nextPreview === null
|
||||||
};
|
? null
|
||||||
document.addEventListener("keydown", onKeyDown);
|
: { kfKey: nextPreview.keyframeKey, clipPct: nextPreview.clipPercentage },
|
||||||
return () => {
|
);
|
||||||
document.removeEventListener("keydown", onKeyDown);
|
});
|
||||||
cancelPreviewFrame();
|
|
||||||
};
|
|
||||||
}, []);
|
}, []);
|
||||||
// The button element can re-render (reposition/unmount) synchronously from
|
// The button element can re-render (reposition/unmount) synchronously from
|
||||||
// the state updates onClickKeyframe/onMoveKeyframe trigger, before the
|
// 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.
|
// O(keyframes squared) allocations on every playhead tick.
|
||||||
const pendingClipPctOf = (keyframe: TimelineDiamondKeyframe) =>
|
const pendingClipPctOf = (keyframe: TimelineDiamondKeyframe) =>
|
||||||
pendingRetimes.get(timelineKeyframeSelectionKey(elementId, keyframeTarget(keyframe)))
|
pendingRetimes.get(timelineKeyframeSelectionKey(elementId, keyframeTarget(keyframe)))
|
||||||
?.clipPct ?? keyframe.percentage;
|
?.clipPercentage ?? keyframe.percentage;
|
||||||
const siblingRows = new Map<
|
const siblingRows = new Map<
|
||||||
string | undefined,
|
string | undefined,
|
||||||
{ keyframes: TimelineDiamondKeyframe[]; clipPcts: number[] }
|
{ keyframes: TimelineDiamondKeyframe[]; clipPcts: number[] }
|
||||||
@@ -247,6 +210,7 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
ref={rootRef}
|
||||||
className="absolute inset-0"
|
className="absolute inset-0"
|
||||||
style={{
|
style={{
|
||||||
// Above the clip's trim-handle strips (TimelineClip.tsx, z-index 4) so
|
// 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>) => {
|
const onPointerDown = (e: React.PointerEvent<HTMLButtonElement>) => {
|
||||||
if (e.button !== 0) return;
|
if (e.button !== 0) return;
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (canDrag) {
|
if (!canDrag) return;
|
||||||
e.currentTarget.setPointerCapture?.(e.pointerId);
|
retimeHandleRef.current = beginTimelineKeyframeRetime({
|
||||||
dragRef.current = {
|
event: e,
|
||||||
kfKey,
|
elementId,
|
||||||
startX: e.clientX,
|
keyframeKey: kfKey,
|
||||||
lastX: e.clientX,
|
target,
|
||||||
index: siblingIndex,
|
keyframes: keyframesData.keyframes,
|
||||||
fromClipPct: pendingRetimes.get(kfKey)?.clipPct ?? kf.percentage,
|
clipWidthPx,
|
||||||
moved: false,
|
// 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,
|
||||||
const onPointerMove = (e: React.PointerEvent<HTMLButtonElement>) => {
|
sortedClipPercentages: siblingClipPcts,
|
||||||
const d = dragRef.current;
|
keyframeKeyOf: (keyframe) =>
|
||||||
if (!d || d.kfKey !== kfKey || d.cancelled) return;
|
timelineKeyframeSelectionKey(elementId, keyframeTarget(keyframe)),
|
||||||
d.lastX = e.clientX;
|
onMove: (fromTarget, toClipPercentage) =>
|
||||||
if (!d.moved && Math.abs(e.clientX - d.startX) >= KEYFRAME_DRAG_THRESHOLD_PX) {
|
onMoveKeyframe?.(fromTarget, toClipPercentage) ?? Promise.resolve(false),
|
||||||
d.moved = true;
|
onSelect: (nextTarget, additive) => {
|
||||||
}
|
if (additive) onShiftClickKeyframe?.(nextTarget);
|
||||||
if (!d.moved || previewFrameRef.current !== null) return;
|
else onClickKeyframe?.(nextTarget);
|
||||||
previewFrameRef.current = requestAnimationFrame(() => {
|
},
|
||||||
previewFrameRef.current = null;
|
suppressNextClick,
|
||||||
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,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
const onPointerUp = (e: React.PointerEvent<HTMLButtonElement>) => {
|
const onPointerUp = (e: React.PointerEvent<HTMLButtonElement>) => {
|
||||||
const d = dragRef.current;
|
// The viewport coordinator owns an armed retime; this local path is
|
||||||
if (d?.kfKey === kfKey && d.cancelled) {
|
// only for diamonds that cannot be dragged.
|
||||||
// Escape already ended this drag; the release is not a click.
|
if (canDrag) {
|
||||||
dragRef.current = null;
|
retimeHandleRef.current?.commit(e);
|
||||||
e.currentTarget.releasePointerCapture?.(e.pointerId);
|
retimeHandleRef.current = null;
|
||||||
suppressNextClick();
|
e.stopPropagation();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// No drag armed (canDrag false / non-primary press) → treat as a click.
|
if (e.button !== 0) return;
|
||||||
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);
|
|
||||||
suppressNextClick();
|
suppressNextClick();
|
||||||
// Single-diamond retime by design: a multi-select drag would have to
|
if (e.shiftKey) onShiftClickKeyframe?.(target);
|
||||||
// move every selected keyframe as one mutation, which the script ops
|
else onClickKeyframe?.(target);
|
||||||
// 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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -476,18 +333,16 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
|
|||||||
overflow: "visible",
|
overflow: "visible",
|
||||||
}}
|
}}
|
||||||
onPointerDown={onPointerDown}
|
onPointerDown={onPointerDown}
|
||||||
onPointerMove={onPointerMove}
|
onPointerMove={canDrag ? (e) => retimeHandleRef.current?.update(e) : undefined}
|
||||||
onPointerUp={onPointerUp}
|
onPointerUp={onPointerUp}
|
||||||
onPointerCancel={(e) => {
|
onPointerCancel={
|
||||||
// Browser/OS cancellation (or lost capture) ends the drag without a
|
canDrag
|
||||||
// pointerup, so clear the armed drag and preview or a ghost diamond
|
? (e) => {
|
||||||
// stays stuck at the last previewed position.
|
retimeHandleRef.current?.cancel(e);
|
||||||
if (dragRef.current?.kfKey !== kfKey) return;
|
retimeHandleRef.current = null;
|
||||||
dragRef.current = null;
|
}
|
||||||
cancelPreviewFrame();
|
: undefined
|
||||||
setPreview(null);
|
}
|
||||||
e.currentTarget.releasePointerCapture?.(e.pointerId);
|
|
||||||
}}
|
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|||||||
@@ -93,20 +93,6 @@ export const DIAMOND_RATIO = 0.8;
|
|||||||
export const KF_MIN_PCT = -5;
|
export const KF_MIN_PCT = -5;
|
||||||
export const KF_MAX_PCT = 105;
|
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
|
* 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
|
* 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
|
* the other, and would strip the animation id the retime/delete mutations use to
|
||||||
* pick between two animations that collide at one percentage.
|
* 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 {
|
return {
|
||||||
percentage: keyframe.percentage,
|
percentage: keyframe.percentage,
|
||||||
tweenPercentage: keyframe.tweenPercentage,
|
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 { mountReactHarness } from "../../hooks/domSelectionTestHarness";
|
||||||
import type { TimelineElement } from "../store/playerStore";
|
import type { TimelineElement } from "../store/playerStore";
|
||||||
import { usePlayerStore } from "../store/playerStore";
|
import { usePlayerStore } from "../store/playerStore";
|
||||||
|
import * as telemetry from "../../telemetry/events";
|
||||||
import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity";
|
import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity";
|
||||||
import { useTimelineKeyframeHandlers } from "./useTimelineKeyframeHandlers";
|
import { useTimelineKeyframeHandlers } from "./useTimelineKeyframeHandlers";
|
||||||
|
|
||||||
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
(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 = {
|
const ELEMENT: TimelineElement = {
|
||||||
id: "clip-1",
|
id: "clip-1",
|
||||||
label: "Hero card",
|
label: "Hero card",
|
||||||
@@ -46,7 +44,7 @@ const COLLIDING_TARGET: TimelineKeyframeTarget = {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
document.body.innerHTML = "";
|
document.body.innerHTML = "";
|
||||||
trackStudioSegmentEaseEdit.mockClear();
|
vi.restoreAllMocks();
|
||||||
usePlayerStore.setState({ focusedEaseSegment: null });
|
usePlayerStore.setState({ focusedEaseSegment: null });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -78,6 +76,9 @@ function mountHandlers(options: Partial<Parameters<typeof useTimelineKeyframeHan
|
|||||||
|
|
||||||
describe("useTimelineKeyframeHandlers", () => {
|
describe("useTimelineKeyframeHandlers", () => {
|
||||||
it("tracks opening the segment ease editor when a timeline segment is selected", () => {
|
it("tracks opening the segment ease editor when a timeline segment is selected", () => {
|
||||||
|
const trackStudioSegmentEaseEdit = vi
|
||||||
|
.spyOn(telemetry, "trackStudioSegmentEaseEdit")
|
||||||
|
.mockImplementation(() => {});
|
||||||
const { root, handlers } = mountHandlers();
|
const { root, handlers } = mountHandlers();
|
||||||
act(() => handlers.onSelectSegment?.(ELEMENT.id, TARGET));
|
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 { trackStudioSegmentEaseEdit } from "../../telemetry/events";
|
||||||
|
import type { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth";
|
||||||
import type { TimelineElement, KeyframeCacheEntry } from "../store/playerStore";
|
import type { TimelineElement, KeyframeCacheEntry } from "../store/playerStore";
|
||||||
import { usePlayerStore } from "../store/playerStore";
|
import { usePlayerStore } from "../store/playerStore";
|
||||||
import type { KeyframeDiamondContextMenuState } from "./KeyframeDiamondContextMenu";
|
import type { KeyframeDiamondContextMenuState } from "./KeyframeDiamondContextMenu";
|
||||||
|
import {
|
||||||
|
applyTimelineHorizontalAutoScrollStep,
|
||||||
|
resolveTimelineAutoScrollLoopAction,
|
||||||
|
} from "./timelineEditing";
|
||||||
import {
|
import {
|
||||||
timelineKeyframeSelectionKey,
|
timelineKeyframeSelectionKey,
|
||||||
type TimelineKeyframeTarget,
|
type TimelineKeyframeTarget,
|
||||||
} from "./timelineKeyframeIdentity";
|
} 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 {
|
interface UseTimelineKeyframeHandlersInput {
|
||||||
expandedElements: TimelineElement[];
|
expandedElements: TimelineElement[];
|
||||||
keyframeCache: Map<string, KeyframeCacheEntry>;
|
keyframeCache: Map<string, KeyframeCacheEntry>;
|
||||||
|
|||||||
Reference in New Issue
Block a user