Skip to content
Merged
93 changes: 18 additions & 75 deletions packages/studio/src/components/editor/AnimationCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,29 +45,34 @@ function selectPreset(host: HTMLElement, presetId: string): string {
return presetConfig.ease;
}

function renderExpandedCard({
animation,
/** Every test mounts the same card; only expansion, flat mode, and the spies differ. */
function renderCard({
animation = baseAnimation(),
defaultExpanded = true,
flat,
onUpdateMeta = vi.fn(),
onUpdateKeyframeEase = vi.fn(),
onDeleteAnimation = noop,
}: {
animation: GsapAnimation;
animation?: GsapAnimation;
defaultExpanded?: boolean;
flat?: boolean;
onUpdateMeta?: ReturnType<typeof vi.fn>;
onUpdateKeyframeEase?: ReturnType<typeof vi.fn>;
}) {
onDeleteAnimation?: (id: string) => void;
} = {}) {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => {
root.render(
<AnimationCard
animation={animation}
defaultExpanded
defaultExpanded={defaultExpanded}
flat={flat}
onUpdateProperty={noop}
onUpdateMeta={onUpdateMeta}
onDeleteAnimation={noop}
onDeleteAnimation={onDeleteAnimation}
onAddProperty={noop}
onRemoveProperty={noop}
onUpdateKeyframeEase={onUpdateKeyframeEase}
Expand All @@ -90,7 +95,7 @@ describe("AnimationCard ease editing", () => {
],
},
});
const view = renderExpandedCard({ animation, onUpdateKeyframeEase });
const view = renderCard({ animation, onUpdateKeyframeEase });

const segment = Array.from(view.host.querySelectorAll("button")).find((button) =>
button.textContent?.includes("0% β†’ 50%"),
Expand All @@ -101,6 +106,7 @@ describe("AnimationCard ease editing", () => {

expect(onUpdateKeyframeEase).toHaveBeenCalledExactlyOnceWith(animation.id, 50, ease);
expect(trackStudioSegmentEaseEdit).toHaveBeenCalledExactlyOnceWith({
action: "commit",
ease,
});
act(() => view.root.unmount());
Expand All @@ -110,7 +116,7 @@ describe("AnimationCard ease editing", () => {
const onUpdateMeta = vi.fn();
const onUpdateKeyframeEase = vi.fn();
const animation = baseAnimation({ id: "flat-tween" });
const view = renderExpandedCard({
const view = renderCard({
animation,
flat: true,
onUpdateMeta,
Expand All @@ -128,70 +134,23 @@ describe("AnimationCard ease editing", () => {

describe("AnimationCard flat branch", () => {
it("renders a mint border-left and panel-token colors when flat", () => {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => {
root.render(
<AnimationCard
animation={baseAnimation()}
defaultExpanded={false}
flat
onUpdateProperty={noop}
onUpdateMeta={noop}
onDeleteAnimation={noop}
onAddProperty={noop}
onRemoveProperty={noop}
/>,
);
});
const { host, root } = renderCard({ defaultExpanded: false, flat: true });
const card = host.querySelector('[data-flat-effect-card="true"]');
expect(card).not.toBeNull();
expect(card?.className).toContain("border-panel-accent");
act(() => root.unmount());
});

it("still renders the legacy (non-flat) appearance when flat is omitted", () => {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => {
root.render(
<AnimationCard
animation={baseAnimation()}
defaultExpanded={false}
onUpdateProperty={noop}
onUpdateMeta={noop}
onDeleteAnimation={noop}
onAddProperty={noop}
onRemoveProperty={noop}
/>,
);
});
const { host, root } = renderCard({ defaultExpanded: false });
expect(host.querySelector('[data-flat-effect-card="true"]')).toBeNull();
expect(host.textContent).toContain("power2.out");
act(() => root.unmount());
});

it("toggles expanded state when the collapsed header button is clicked, in both modes", () => {
for (const flat of [false, true]) {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => {
root.render(
<AnimationCard
animation={baseAnimation()}
defaultExpanded={false}
flat={flat || undefined}
onUpdateProperty={noop}
onUpdateMeta={noop}
onDeleteAnimation={noop}
onAddProperty={noop}
onRemoveProperty={noop}
/>,
);
});
const { host, root } = renderCard({ defaultExpanded: false, flat: flat || undefined });
expect(host.textContent).not.toContain("Remove");
const button = host.querySelector("button");
expect(button).not.toBeNull();
Expand All @@ -205,23 +164,7 @@ describe("AnimationCard flat branch", () => {

it("invokes onDeleteAnimation with the animation id when Remove is clicked, in flat mode", () => {
const onDeleteAnimation = vi.fn();
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => {
root.render(
<AnimationCard
animation={baseAnimation()}
defaultExpanded={true}
flat
onUpdateProperty={noop}
onUpdateMeta={noop}
onDeleteAnimation={onDeleteAnimation}
onAddProperty={noop}
onRemoveProperty={noop}
/>,
);
});
const { host, root } = renderCard({ flat: true, onDeleteAnimation });
const buttons = Array.from(host.querySelectorAll("button"));
const removeButton = buttons.find((b) => b.textContent === "Remove");
expect(removeButton).not.toBeUndefined();
Expand Down
2 changes: 1 addition & 1 deletion packages/studio/src/components/editor/AnimationCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,7 @@ export const AnimationCard = memo(function AnimationCard({
onToggle={setExpandedKfPct}
onEaseCommit={(pct, ease) => {
onUpdateKeyframeEase(animation.id, pct, ease);
trackStudioSegmentEaseEdit({ ease });
trackStudioSegmentEaseEdit({ action: "commit", ease });
}}
onApplyAll={
onSetAllKeyframeEases
Expand Down
4 changes: 2 additions & 2 deletions packages/studio/src/hooks/gsapDragCommit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { usePlayerStore } from "../player/store/playerStore";
import { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeKeyframes";
import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
import { roundTo3 } from "../utils/rounding";
import { computeElementPercentage } from "./gsapShared";
import { computeElementPercentage, idSelector } from "./gsapShared";
import { computeDraggedGsapPosition } from "./draggedGsapPosition";
import type { RuntimeTweenChange } from "./gsapRuntimePatch";
import { isGestureTransactionCommit, runGestureTransaction } from "./gestureTransaction";
Expand Down Expand Up @@ -117,7 +117,7 @@ export async function materializeIfDynamic(
const allScanned = scanAllRuntimeKeyframes(iframe);
if (allScanned.size === 0) return;
const allElements = Array.from(allScanned.entries()).map(([id, data]) => ({
selector: `#${id}`,
selector: idSelector(id),
keyframes: data.keyframes,
easeEach: data.easeEach,
}));
Expand Down
134 changes: 133 additions & 1 deletion packages/studio/src/hooks/gsapKeyframeCacheHelpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ const animWithKeyframes = (id: string): GsapAnimation => ({
});

beforeEach(() => {
usePlayerStore.setState({ keyframeCache: new Map(), elements: [] });
usePlayerStore.setState({ keyframeCache: new Map(), gsapAnimations: new Map(), elements: [] });
});

describe("clearKeyframeCacheForElement", () => {
Expand Down Expand Up @@ -84,6 +84,20 @@ describe("clearKeyframeCacheForFile", () => {
}
});

// Several composition files re-scan concurrently, so a clear that walked the
// index.html alias would delete rows a sibling file had just written.
it("leaves an index.html-owned element alone when another file re-scans", () => {
seed("index.html#title");
seed("title");
seed("comp.html#a");

clearKeyframeCacheForFile("comp.html");

expect(cache().has("index.html#title")).toBe(true);
expect(cache().has("title")).toBe(true);
expect(cache().has("comp.html#a")).toBe(false);
});

it("leaves entries that belong to a different source file", () => {
seed("comp.html#a");
seed("a");
Expand All @@ -98,6 +112,41 @@ describe("clearKeyframeCacheForFile", () => {
});

describe("updateKeyframeCacheFromParsed", () => {
it("serializes a multi-keyframe tween with a stable shape and animation identity", () => {
const animation: GsapAnimation = {
...animWithKeyframes("hero"),
duration: 2,
resolvedStart: 3,
keyframes: {
format: "percentage",
keyframes: [
{ percentage: 0, properties: { x: 0 } },
{ percentage: 50, properties: { x: 100 }, ease: "power1.inOut" },
{ percentage: 100, properties: { x: 200 } },
],
easeEach: "power1.inOut",
},
};
usePlayerStore.setState({
elements: [
{
id: "hero-clip",
domId: "hero",
tag: "div",
start: 2,
duration: 4,
track: 0,
},
],
});

updateKeyframeCacheFromParsed([animation], "scene.html", "hero", {});

expect(JSON.stringify(cache().get("scene.html#hero"))).toBe(
'{"format":"percentage","keyframes":[{"percentage":25,"properties":{"x":0},"tweenPercentage":0,"propertyGroup":"position","animationId":"hero"},{"percentage":50,"properties":{"x":100},"ease":"power1.inOut","tweenPercentage":50,"propertyGroup":"position","animationId":"hero"},{"percentage":75,"properties":{"x":200},"tweenPercentage":100,"propertyGroup":"position","animationId":"hero"}],"easeEach":"power1.inOut"}',
);
});

it("clears the bare key when the selected element no longer has keyframes", () => {
// Element previously had keyframes, so a bare entry exists (writes set both).
seed("index.html#box");
Expand All @@ -118,4 +167,87 @@ describe("updateKeyframeCacheFromParsed", () => {
expect(cache().has("index.html#hero")).toBe(true);
expect(cache().has("hero")).toBe(true);
});

it("caches flat tweens as clip-relative start and end keyframes", () => {
const animation: GsapAnimation = {
id: "flat-box",
targetSelector: "#box",
method: "to",
position: 1,
properties: { x: 420 },
duration: 2,
resolvedStart: 1,
ease: "power2.out",
propertyGroup: "position",
};
usePlayerStore.setState({
elements: [{ id: "box-clip", domId: "box", tag: "div", start: 1, duration: 2, track: 0 }],
});

updateKeyframeCacheFromParsed([animation], "scene.html", "box", {});

expect(cache().get("scene.html#box")).toEqual({
format: "percentage",
keyframes: [
{
percentage: 0,
properties: { x: 0 },
tweenPercentage: 0,
propertyGroup: "position",
animationId: "flat-box",
},
{
percentage: 100,
properties: { x: 420 },
ease: "power2.out",
tweenPercentage: 100,
propertyGroup: "position",
animationId: "flat-box",
},
],
});
expect(usePlayerStore.getState().gsapAnimations.get("scene.html#box")).toEqual([animation]);
});

it("records an ungrouped tween in gsapAnimations too, so the two stores agree", () => {
// `{ x, opacity }` spans two property groups, so the parser leaves
// propertyGroup undefined. Skipping it here used to cache diamonds with no
// source animation behind them: the collapsed row drew keyframes the
// expanded lanes could not render.
const animation: GsapAnimation = {
id: "mixed-box",
targetSelector: "#box",
method: "to",
position: 0,
properties: { x: 100, opacity: 0 },
duration: 1,
resolvedStart: 0,
};
usePlayerStore.setState({
elements: [{ id: "box-clip", domId: "box", tag: "div", start: 0, duration: 1, track: 0 }],
});

updateKeyframeCacheFromParsed([animation], "scene.html", "box", {});

expect(cache().has("scene.html#box")).toBe(true);
expect(usePlayerStore.getState().gsapAnimations.get("scene.html#box")).toEqual([animation]);
});

it("does not cache a flat tween without animatable numeric properties", () => {
const animation: GsapAnimation = {
id: "flat-box",
targetSelector: "#box",
method: "to",
position: 0,
properties: { backgroundColor: "#fff" },
duration: 1,
propertyGroup: "visual",
};

updateKeyframeCacheFromParsed([animation], "scene.html", "box", {});

expect(cache().has("scene.html#box")).toBe(false);
expect(cache().has("box")).toBe(false);
expect(usePlayerStore.getState().gsapAnimations.has("scene.html#box")).toBe(false);
});
});
Loading
Loading