Files
hyperframes/packages/studio/src/hooks/gsapRuntimeKeyframes.test.ts
T
Miguel Ángel 5d59835446 fix(studio): static-position drag no longer freezes an element beside an animated rotation (#2016)
## What

Brief description of the change.

## Why

Why is this change needed?

## How

How was this implemented? Any notable design decisions?

## Test plan

How was this tested?

- [ ] Unit tests added/updated
- [ ] Manual testing performed
- [ ] Documentation updated (if applicable)
2026-07-07 03:45:17 -04:00

257 lines
8.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it } from "vitest";
import {
arcPathFromMotionPathValue,
hasNonHoldTweenForElement,
readRuntimeKeyframes,
} from "./gsapRuntimeKeyframes";
// Build a fake preview iframe whose runtime timeline holds the given child tweens
// and resolves `selector` to `el`.
function fakeIframe(el: { id: string }, children: unknown[], now?: number): HTMLIFrameElement {
const timeline = {
getChildren: () => children,
duration: () => 14.6,
...(now != null ? { time: () => now } : {}),
};
return {
contentWindow: { __timelines: { "index.html": timeline } },
contentDocument: { querySelector: (sel: string) => (sel === `#${el.id}` ? el : null) },
} as unknown as HTMLIFrameElement;
}
describe("readRuntimeKeyframes — zero-duration set must not shadow the keyframed tween", () => {
const el = { id: "puck-b" };
const holdSet = {
targets: () => [el],
// `data` is the STUDIO_HOLD_MARKER sentinel ("hf-hold") from core's gsapParser.
// TODO(core follow-up): re-export STUDIO_HOLD_MARKER via the @hyperframes/core/
// gsap-parser subpath so this fixture can import the const instead of the literal.
vars: { x: 0, y: 0, data: "hf-hold" },
duration: () => 0,
startTime: () => 0,
};
const kfTween = {
targets: () => [el],
vars: {
keyframes: [
{ x: 0, y: 0 },
{ x: -180, y: -60 },
{ x: -320, y: 40 },
{ x: -460, y: -20 },
],
duration: 3.4,
ease: "power1.inOut",
},
duration: () => 3.4,
startTime: () => 1.0,
};
it("reads all 4 keyframes from the to() even when a hold-set precedes it", () => {
const read = readRuntimeKeyframes(fakeIframe(el, [holdSet, kfTween]), "#puck-b");
expect(read?.keyframes).toHaveLength(4);
});
it("returns null when the element only has a zero-duration set (no real motion)", () => {
expect(readRuntimeKeyframes(fakeIframe(el, [holdSet]), "#puck-b")).toBeNull();
});
});
describe("readRuntimeKeyframes — multiple tweens pick the one under the playhead", () => {
const el = { id: "puck-a" };
// Two non-overlapping gesture recordings → two separate keyframed tweens.
const gestureA = {
targets: () => [el],
vars: {
keyframes: [
{ x: 0, y: 0 },
{ x: -100, y: 50 },
],
duration: 2.03,
},
duration: () => 2.03,
startTime: () => 1.033, // range [1.033, 3.063]
};
const gestureB = {
targets: () => [el],
vars: {
keyframes: [
{ x: 10, y: 10 },
{ x: 20, y: 20 },
{ x: 30, y: 30 },
],
duration: 1.129,
},
duration: () => 1.129,
startTime: () => 3.342, // range [3.342, 4.471]
};
it("playhead inside the SECOND tween reads the second tween (not the first)", () => {
const read = readRuntimeKeyframes(fakeIframe(el, [gestureA, gestureB], 3.373), "#puck-a");
expect(read?.keyframes).toHaveLength(3); // gestureB
});
it("playhead inside the FIRST tween reads the first tween", () => {
const read = readRuntimeKeyframes(fakeIframe(el, [gestureA, gestureB], 2.0), "#puck-a");
expect(read?.keyframes).toHaveLength(2); // gestureA
});
it("playhead outside every range falls back to the first keyframed tween", () => {
const read = readRuntimeKeyframes(fakeIframe(el, [gestureA, gestureB], 9.0), "#puck-a");
expect(read?.keyframes).toHaveLength(2); // gestureA (first)
});
});
describe("readRuntimeKeyframes — requireChannels keeps the motion path on the positional tween", () => {
// An animated element with a position tween AND a (longer) size tween, both at
// start 0 — the shape the per-keyframe size resize produces.
const el = { id: "dot-a" };
const positionTween = {
targets: () => [el],
vars: {
keyframes: [
{ x: -1252, y: -394 },
{ x: 244, y: -316 },
],
duration: 2.4,
},
duration: () => 2.4,
startTime: () => 0, // range [0, 2.4]
};
const sizeTween = {
targets: () => [el],
vars: {
keyframes: [
{ width: 120, height: 96 },
{ width: 325, height: 300 },
],
duration: 3.243,
},
duration: () => 3.243,
startTime: () => 0, // range [0, 3.243] — outlives the position tween
};
it("playhead past the position range (2.43.243s) still returns the position tween, not size", () => {
// Without the filter the size tween (the only one containing the playhead)
// would win and blank the path.
const read = readRuntimeKeyframes(
fakeIframe(el, [positionTween, sizeTween], 3.0),
"#dot-a",
undefined,
["x", "y"],
);
expect(read?.keyframes[0]?.properties).toHaveProperty("x");
});
it("without requireChannels the size tween shadows the path past the position range (documents the bug)", () => {
const read = readRuntimeKeyframes(fakeIframe(el, [positionTween, sizeTween], 3.0), "#dot-a");
expect(read?.keyframes[0]?.properties).toHaveProperty("width");
});
});
describe("hasNonHoldTweenForElement — strict live-tween existence (drag stale-parse guard)", () => {
const el = { id: "puck-b" };
const holdSet = {
targets: () => [el],
vars: { x: 0, y: 0, data: "hf-hold" },
duration: () => 0,
startTime: () => 0,
};
const liveTween = {
targets: () => [el],
vars: { x: -120, y: 40, duration: 1 },
duration: () => 1,
startTime: () => 1,
};
it("true when a non-hold tween targets the element", () => {
expect(hasNonHoldTweenForElement(fakeIframe(el, [liveTween]), "#puck-b")).toBe(true);
});
it("false when only a zero-duration hold/set remains (post delete-all)", () => {
expect(hasNonHoldTweenForElement(fakeIframe(el, [holdSet]), "#puck-b")).toBe(false);
});
it("false when the element has no tweens at all", () => {
expect(hasNonHoldTweenForElement(fakeIframe(el, []), "#puck-b")).toBe(false);
});
});
describe("hasNonHoldTweenForElement — channel-scoped (position vs sibling rotation)", () => {
const el = { id: "puck-c" };
const rotationTween = {
targets: () => [el],
vars: { keyframes: { "0%": { rotation: 0 }, "100%": { rotation: 90 } }, duration: 1 },
duration: () => 1,
startTime: () => 0,
};
const positionTween = {
targets: () => [el],
vars: { keyframes: { "0%": { x: 0, y: 0 }, "100%": { x: 100, y: 40 } }, duration: 1 },
duration: () => 1,
startTime: () => 0,
};
it("unfiltered: a rotation-only keyframed tween counts as non-hold", () => {
expect(hasNonHoldTweenForElement(fakeIframe(el, [rotationTween]), "#puck-c")).toBe(true);
});
it("position-scoped: a sibling rotation-only tween is NOT a live position tween", () => {
// The primary fix: a keyframed rotation must not route a static position hold
// into the keyframe branch. Scoping to position channels returns false here.
expect(
hasNonHoldTweenForElement(fakeIframe(el, [rotationTween]), "#puck-c", undefined, ["x", "y"]),
).toBe(false);
});
it("position-scoped: a real keyframed position tween still counts", () => {
expect(
hasNonHoldTweenForElement(fakeIframe(el, [positionTween]), "#puck-c", undefined, ["x", "y"]),
).toBe(true);
});
});
describe("arcPathFromMotionPathValue", () => {
it("builds arc config from object form { path, curviness }", () => {
const arc = arcPathFromMotionPathValue({
path: [
{ x: 0, y: 0 },
{ x: 100, y: -50 },
{ x: 200, y: 0 },
{ x: 300, y: 80 },
],
curviness: 2,
});
expect(arc?.enabled).toBe(true);
expect(arc?.segments).toHaveLength(3); // 4 waypoints → 3 segments
expect(arc?.segments.every((s) => s.curviness === 2)).toBe(true);
});
it("builds arc config from bare array form (default curviness 1)", () => {
const arc = arcPathFromMotionPathValue([
{ x: 0, y: 0 },
{ x: 50, y: 50 },
]);
expect(arc?.enabled).toBe(true);
expect(arc?.segments).toHaveLength(1);
expect(arc?.segments[0]!.curviness).toBe(1);
});
it("carries autoRotate", () => {
const arc = arcPathFromMotionPathValue({
path: [
{ x: 0, y: 0 },
{ x: 10, y: 10 },
],
autoRotate: true,
});
expect(arc?.autoRotate).toBe(true);
});
it("returns undefined for fewer than 2 points, missing path, or string path", () => {
expect(arcPathFromMotionPathValue({ path: [{ x: 0, y: 0 }] })).toBeUndefined();
expect(arcPathFromMotionPathValue({ curviness: 2 })).toBeUndefined();
expect(arcPathFromMotionPathValue({ path: "M0 0 L10 10" })).toBeUndefined();
expect(arcPathFromMotionPathValue(null)).toBeUndefined();
});
});