fix(studio): resolve recent timeline regressions

This commit is contained in:
Miguel Ángel
2026-07-01 03:18:45 +00:00
parent 602590b44d
commit 9b311588be
11 changed files with 339 additions and 13 deletions
@@ -3,7 +3,7 @@
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach } from "vitest";
import { describe, it, expect } from "vitest";
import { describe, it, expect, vi } from "vitest";
import {
Timeline,
formatTimelineTickLabel,
@@ -54,6 +54,56 @@ describe("Timeline provider boundary", () => {
act(() => root.unmount());
});
it("opens the keyframe context menu without seeking to that keyframe", () => {
const host = document.createElement("div");
document.body.append(host);
Object.defineProperty(host, "clientWidth", {
configurable: true,
value: 720,
});
usePlayerStore.setState({
duration: 4,
timelineReady: true,
currentTime: 0.25,
selectedElementId: "clip-1",
elements: [{ id: "clip-1", tag: "div", start: 0, duration: 4, track: 0 }],
keyframeCache: new Map([
[
"clip-1",
{
format: "percentage",
keyframes: [{ percentage: 50, properties: { x: 100 }, tweenPercentage: 50 }],
},
],
]),
});
const onSeek = vi.fn();
const root = createRoot(host);
act(() => {
root.render(React.createElement(Timeline, { onSeek }));
});
const diamond = host.querySelector<HTMLButtonElement>('button[title="50%"]');
expect(diamond).not.toBeNull();
act(() => {
diamond!.dispatchEvent(
new MouseEvent("contextmenu", {
bubbles: true,
cancelable: true,
button: 2,
clientX: 120,
clientY: 40,
}),
);
});
expect(onSeek).not.toHaveBeenCalled();
act(() => root.unmount());
});
});
describe("generateTicks", () => {
@@ -487,8 +487,6 @@ export const Timeline = memo(function Timeline({
if (el) {
setSelectedElementId(elId);
onSelectElement?.(el);
const absTime = el.start + (pct / 100) * el.duration;
onSeek?.(absTime);
}
const kfData = keyframeCache.get(elId);
const kf = kfData?.keyframes.find((k) => Math.abs(k.percentage - pct) < 0.2);
@@ -0,0 +1,73 @@
// @vitest-environment happy-dom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import { TimelineClipDiamonds } from "./TimelineClipDiamonds";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
afterEach(() => {
document.body.innerHTML = "";
});
function pointerEvent(type: string, init: PointerEventInit): Event {
if (typeof PointerEvent === "function") return new PointerEvent(type, init);
return new MouseEvent(type, init);
}
function renderDiamonds(onClickKeyframe = vi.fn()) {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => {
root.render(
<TimelineClipDiamonds
keyframesData={{
format: "percentage",
keyframes: [
{ percentage: 0, properties: { x: 0 } },
{ percentage: 50, properties: { x: 100 } },
],
}}
clipWidthPx={200}
clipHeightPx={48}
accentColor="#4ba3d2"
isSelected
currentPercentage={0}
elementId="clip-1"
selectedKeyframes={new Set()}
onClickKeyframe={onClickKeyframe}
/>,
);
});
return { host, root, onClickKeyframe };
}
describe("TimelineClipDiamonds", () => {
it("treats primary pointerup without drag as a keyframe click", () => {
const { host, root, onClickKeyframe } = renderDiamonds();
const diamond = host.querySelector<HTMLButtonElement>('button[title="50%"]');
expect(diamond).not.toBeNull();
act(() => {
diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0 }));
});
expect(onClickKeyframe).toHaveBeenCalledWith(50);
act(() => root.unmount());
});
it("does not treat secondary pointerup as a keyframe click", () => {
const { host, root, onClickKeyframe } = renderDiamonds();
const diamond = host.querySelector<HTMLButtonElement>('button[title="50%"]');
expect(diamond).not.toBeNull();
act(() => {
diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 2 }));
});
expect(onClickKeyframe).not.toHaveBeenCalled();
act(() => root.unmount());
});
});
@@ -178,6 +178,7 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
const d = dragRef.current;
// No drag armed (canDrag false / non-primary press) → treat as a click.
if (!d || d.kfKey !== kfKey) {
if (e.button !== 0) return;
if (e.shiftKey) onShiftClickKeyframe?.(elementId, kf.percentage);
else onClickKeyframe?.(kf.percentage);
return;