Files
hyperframes/packages/studio/src/components/nle/useTimelineEditCallbacks.test.tsx
T
Miguel Angel Simon Sierra ba8df2661c fix(studio): resolve keyframe fallbacks against the clicked element
Three review follow-ups on the editor-callback consolidation.

The keyframe-target resolve now takes the clicked element's key and reads
that element's keyframe cache. The diamond context menu and move-to-playhead
pass no explicit target, so they fell through to the cache of whatever
element happened to be selected: opening the menu on a non-selected
element's diamond resolved against the wrong keyframes.

PropertyPanelFlat opens the Motion group by adjusting state during render
instead of in an effect, so the AnimationCard mounts on the same commit the
focus request arrives on rather than a frame later.

Both animation sections pass a module-level focus consumer instead of a
fresh inline arrow, so AnimationCard's focus effect stops re-running on
every parent render.
2026-07-27 19:52:07 +02:00

360 lines
11 KiB
TypeScript

// @vitest-environment happy-dom
import React, { act } from "react";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { TimelineElement } from "../../player";
import type { TimelineEditCallbacks } from "../../player/components/timelineCallbacks";
import { usePlayerStore } from "../../player/store/playerStore";
import { installReactActEnvironment, mountReactHarness } from "../../hooks/domSelectionTestHarness";
installReactActEnvironment();
const mocks = vi.hoisted(() => ({
actions: {
handleGsapRemoveKeyframe: vi.fn(),
handleGsapMoveKeyframeToPlayhead: vi.fn(),
handleGsapMoveKeyframe: vi.fn(),
handleGsapResizeKeyframedTween: vi.fn(),
handleGsapUpdateMeta: vi.fn(),
handleGsapAddKeyframe: vi.fn(),
handleGsapAddKeyframeBatch: vi.fn().mockResolvedValue(undefined),
handleGsapConvertToKeyframes: vi.fn(),
handleGsapRemoveAllKeyframes: vi.fn(),
handleGsapDeleteAnimation: vi.fn(),
buildDomSelectionForTimelineElement: vi.fn(),
},
selection: { id: "box", selector: "#box", sourceFile: "index.html" },
animations: Array<GsapAnimation>(),
}));
vi.mock("../../contexts/StudioContext", () => ({
useStudioShellContext: () => ({ projectId: "project", activeCompPath: "index.html" }),
}));
vi.mock("../../contexts/DomEditContext", () => ({
useDomEditActionsContext: () => mocks.actions,
useDomEditSelectionContext: () => ({
domEditSelection: mocks.selection,
selectedGsapAnimations: mocks.animations,
}),
}));
import { useTimelineEditCallbacks } from "./useTimelineEditCallbacks";
const element: TimelineElement = {
id: "box",
key: "index.html#box",
domId: "box",
tag: "div",
start: 0,
duration: 1,
track: 0,
sourceFile: "index.html",
};
const flatAnimation: GsapAnimation = {
id: "box-to-0-position",
targetSelector: "#box",
method: "to",
position: 0,
resolvedStart: 0,
duration: 1,
properties: { x: 420 },
propertyGroup: "position",
};
const otherFlatAnimation: GsapAnimation = {
...flatAnimation,
id: "circle-to-0-position",
targetSelector: "#circle",
};
const otherKeyframedAnimation: GsapAnimation = {
...otherFlatAnimation,
keyframes: {
format: "percentage",
keyframes: [
{ percentage: 0, properties: { x: 0 } },
{ percentage: 100, properties: { x: 420 } },
],
},
};
function authoredInteriorAnimation(): GsapAnimation {
return {
...flatAnimation,
keyframes: {
format: "percentage",
keyframes: [
{ percentage: 0, properties: { x: 0 } },
{ percentage: 50, properties: { x: 210 } },
{ percentage: 100, properties: { x: 420 } },
],
},
};
}
function renderCallbacks(): { callbacks: TimelineEditCallbacks; unmount: () => void } {
let callbacks: TimelineEditCallbacks | null = null;
function Harness() {
callbacks = useTimelineEditCallbacks({
handleTimelineElementMove: vi.fn(),
handleTimelineElementsMove: vi.fn(),
handleTimelineElementResize: vi.fn(),
handleTimelineGroupResize: vi.fn(),
handleToggleTrackHidden: vi.fn(),
handleBlockedTimelineEdit: vi.fn(),
handleTimelineElementSplit: vi.fn(),
handleRazorSplit: vi.fn(),
handleRazorSplitAll: vi.fn(),
});
return null;
}
const root = mountReactHarness(<Harness />);
if (!callbacks) throw new Error("timeline callbacks did not initialize");
return { callbacks, unmount: () => act(() => root.unmount()) };
}
beforeEach(() => {
vi.clearAllMocks();
mocks.animations = [flatAnimation];
mocks.actions.buildDomSelectionForTimelineElement.mockResolvedValue(mocks.selection);
usePlayerStore.setState({
currentTime: 0.5,
elements: [element],
domClipChildren: [],
keyframeCache: new Map(),
gsapAnimations: new Map([["box", [flatAnimation]]]),
});
});
afterEach(() => {
usePlayerStore.setState({
elements: [],
domClipChildren: [],
keyframeCache: new Map(),
gsapAnimations: new Map(),
});
});
describe("useTimelineEditCallbacks — flat tween keyframe lanes", () => {
it("adds an interior point through the add-keyframe persist boundary", async () => {
const view = renderCallbacks();
await act(async () => {
await view.callbacks.onTogglePropertyGroupKeyframe?.(element, {
animationId: flatAnimation.id,
propertyGroup: "position",
tweenPercentage: 50,
properties: { x: 210 },
remove: false,
});
});
expect(mocks.actions.handleGsapAddKeyframeBatch).toHaveBeenCalledWith(
flatAnimation.id,
50,
{ x: 210 },
undefined,
mocks.selection,
);
expect(mocks.actions.handleGsapConvertToKeyframes).not.toHaveBeenCalled();
view.unmount();
});
it("safely no-ops a boundary drag while the tween is still flat", () => {
const view = renderCallbacks();
act(() => {
view.callbacks.onMoveKeyframe?.("box", 0, 25, "position", 0, flatAnimation.id);
});
expect(mocks.actions.handleGsapMoveKeyframe).not.toHaveBeenCalled();
expect(mocks.actions.handleGsapResizeKeyframedTween).not.toHaveBeenCalled();
view.unmount();
});
it("deletes a non-selected element flat boundary through the clicked element's selection", async () => {
const circle: TimelineElement = {
...element,
id: "circle",
key: "scenes/main.html#circle",
domId: "circle",
};
usePlayerStore.setState({
elements: [element, circle],
gsapAnimations: new Map([["scenes/main.html#circle", [otherFlatAnimation]]]),
});
const view = renderCallbacks();
await act(async () => {
view.callbacks.onDeleteKeyframe?.(
"scenes/main.html#circle",
0,
"position",
0,
otherFlatAnimation.id,
);
await Promise.resolve();
await Promise.resolve();
});
// Persisted through the CLICKED element's own selection, not the current one.
expect(mocks.actions.handleGsapDeleteAnimation).toHaveBeenCalledWith(
otherFlatAnimation.id,
mocks.selection,
);
expect(mocks.actions.handleGsapRemoveKeyframe).not.toHaveBeenCalled();
view.unmount();
});
it("removes a non-selected element authored endpoint through the clicked element's selection", async () => {
const circle: TimelineElement = {
...element,
id: "circle",
key: "scenes/main.html#circle",
domId: "circle",
};
usePlayerStore.setState({
elements: [element, circle],
gsapAnimations: new Map([["index.html#circle", [otherKeyframedAnimation]]]),
});
const view = renderCallbacks();
await act(async () => {
view.callbacks.onDeleteKeyframe?.(
"scenes/main.html#circle",
100,
"position",
100,
otherKeyframedAnimation.id,
);
await Promise.resolve();
await Promise.resolve();
});
expect(mocks.actions.handleGsapRemoveKeyframe).toHaveBeenCalledWith(
otherKeyframedAnimation.id,
100,
undefined,
mocks.selection,
);
expect(mocks.actions.handleGsapDeleteAnimation).not.toHaveBeenCalled();
view.unmount();
});
// The diamond context menu opens on whatever diamond was clicked, which need
// not belong to the selected element, and it passes no explicit target — so
// the resolve falls back to the cache. Reading the SELECTED element's cache
// there resolves against the wrong element's keyframes.
it("resolves a cache fallback against the clicked element, not the selected one", () => {
const circle: TimelineElement = {
...element,
id: "circle",
key: "scenes/main.html#circle",
domId: "circle",
};
usePlayerStore.setState({
elements: [element, circle],
gsapAnimations: new Map([["scenes/main.html#circle", [otherKeyframedAnimation]]]),
keyframeCache: new Map([
// Decoy at the same clip-% under the selected element's key.
[
"box",
{
format: "percentage",
keyframes: [{ percentage: 100, tweenPercentage: 100, properties: { x: 420 } }],
},
],
[
"scenes/main.html#circle",
{
format: "percentage",
keyframes: [
{
percentage: 100,
tweenPercentage: 100,
propertyGroup: "position",
animationId: otherKeyframedAnimation.id,
properties: { x: 420 },
},
],
},
],
]),
});
const view = renderCallbacks();
act(() => {
view.callbacks.onMoveKeyframeToPlayhead?.("scenes/main.html#circle", 100);
});
expect(mocks.actions.handleGsapMoveKeyframeToPlayhead).toHaveBeenCalledWith(
otherKeyframedAnimation.id,
100,
);
view.unmount();
});
it("keeps selected-element flat boundary deletion on the animation delete path", () => {
const view = renderCallbacks();
act(() => {
view.callbacks.onDeleteKeyframe?.("box", 0, "position", 0, flatAnimation.id);
});
expect(mocks.actions.handleGsapDeleteAnimation).toHaveBeenCalledWith(flatAnimation.id);
expect(mocks.actions.handleGsapRemoveKeyframe).not.toHaveBeenCalled();
view.unmount();
});
it("routes the flat lane-header remove toggle through the guarded delete path", async () => {
const view = renderCallbacks();
await act(async () => {
await view.callbacks.onTogglePropertyGroupKeyframe?.(element, {
animationId: flatAnimation.id,
propertyGroup: "position",
tweenPercentage: 100,
properties: { x: 420 },
remove: true,
});
});
expect(mocks.actions.handleGsapDeleteAnimation).toHaveBeenCalledWith(
flatAnimation.id,
mocks.selection,
);
expect(mocks.actions.handleGsapRemoveKeyframe).not.toHaveBeenCalled();
view.unmount();
});
it("keeps authored interior deletion on the per-keyframe path", () => {
mocks.animations = [authoredInteriorAnimation()];
usePlayerStore.setState({ gsapAnimations: new Map([["box", mocks.animations]]) });
const view = renderCallbacks();
act(() => {
view.callbacks.onDeleteKeyframe?.("box", 50, "position", 50, flatAnimation.id);
});
expect(mocks.actions.handleGsapRemoveKeyframe).toHaveBeenCalledWith(flatAnimation.id, 50);
expect(mocks.actions.handleGsapDeleteAnimation).not.toHaveBeenCalled();
view.unmount();
});
it("keeps an authored interior drag on the per-keyframe move path", () => {
mocks.animations = [authoredInteriorAnimation()];
const view = renderCallbacks();
act(() => {
view.callbacks.onMoveKeyframe?.("box", 50, 75, "position", 50, flatAnimation.id);
});
expect(mocks.actions.handleGsapMoveKeyframe).toHaveBeenCalledWith(flatAnimation.id, 50, 75);
expect(mocks.actions.handleGsapResizeKeyframedTween).not.toHaveBeenCalled();
view.unmount();
});
});