Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/studio/src/components/StudioRightPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ export function StudioRightPanel({
handleUpdateArcSegment,
handleUnroll,
handleUpdateKeyframeEase,
handleUpdateSegmentEase,
handleSetAllKeyframeEases,
handleGsapAddKeyframe,
handleGsapRemoveKeyframe,
Expand Down Expand Up @@ -408,6 +409,7 @@ export function StudioRightPanel({
onUpdateArcSegment={handleUpdateArcSegment}
onUnroll={handleUnroll}
onUpdateKeyframeEase={handleUpdateKeyframeEase}
onUpdateSegmentEase={handleUpdateSegmentEase}
onSetAllKeyframeEases={handleSetAllKeyframeEases}
recordingState={recordingState}
recordingDuration={recordingDuration}
Expand Down
223 changes: 175 additions & 48 deletions packages/studio/src/components/editor/AnimationCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,39 +2,88 @@

import React, { act } from "react";
import { createRoot } from "react-dom/client";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import { afterEach, describe, expect, it, vi } from "vitest";
import { AnimationCard } from "./AnimationCard";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import { EASE_PRESETS } from "./easePresetLibrary";

const trackStudioSegmentEaseEdit = vi.hoisted(() => vi.fn());
vi.mock("../../telemetry/events", () => ({ trackStudioSegmentEaseEdit }));

(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;

const ANIMATION: GsapAnimation = {
id: "position-tween",
targetSelector: "#clip-1",
method: "to",
position: 0,
duration: 2,
ease: "power1.out",
properties: { x: 200 },
keyframes: {
format: "percentage",
keyframes: [
{ percentage: 0, properties: { x: 0 } },
{ percentage: 50, properties: { x: 100 } },
{ percentage: 100, properties: { x: 200 } },
],
},
};

const FLAT_ANIMATION: GsapAnimation = {
...ANIMATION,
id: "flat-position-tween",
keyframes: undefined,
};

afterEach(() => {
document.body.innerHTML = "";
trackStudioSegmentEaseEdit.mockClear();
});

function baseAnimation(overrides: Partial<GsapAnimation> = {}): GsapAnimation {
return {
id: "anim-1",
method: "to",
position: 0.8,
duration: 1.2,
ease: "power2.out",
properties: { opacity: 1 },
...overrides,
} as GsapAnimation;
function renderCard(
focusedSegment: { tweenPercentage: number; collidingAnimationIds?: string[] } | null,
onEaseCommit = vi.fn(),
defaultExpanded = false,
animation = ANIMATION,
onUpdateMeta = vi.fn(),
onUpdateSegmentEase = vi.fn(),
) {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
const render = (nextFocusedSegment: { tweenPercentage: number } | null) => {
act(() => {
root.render(
<AnimationCard
animation={animation}
defaultExpanded={defaultExpanded}
focusedSegment={nextFocusedSegment}
onFocusSegmentConsumed={vi.fn()}
onUpdateProperty={vi.fn()}
onUpdateMeta={onUpdateMeta}
onDeleteAnimation={vi.fn()}
onAddProperty={vi.fn()}
onRemoveProperty={vi.fn()}
onUpdateKeyframeEase={onEaseCommit}
onUpdateSegmentEase={onUpdateSegmentEase}
/>,
);
});
};
render(focusedSegment);
return { host, root, render };
}

const noop = () => {};
function findButton(host: HTMLElement, text: string): HTMLButtonElement | undefined {
return Array.from(host.querySelectorAll("button")).find((button) =>
button.textContent?.includes(text),
);
}

function selectPreset(host: HTMLElement, presetId: string): string {
const presetConfig = EASE_PRESETS.find((candidate) => candidate.id === presetId);
if (!presetConfig) throw new Error(`Missing ease preset: ${presetId}`);

const dropdown = host.querySelector<HTMLButtonElement>("[data-ease-type-dropdown]");
expect(dropdown).not.toBeNull();
act(() => dropdown?.click());
Expand All @@ -45,38 +94,121 @@ function selectPreset(host: HTMLElement, presetId: string): string {
return presetConfig.ease;
}

function renderExpandedCard({
animation,
flat,
onUpdateMeta = vi.fn(),
onUpdateKeyframeEase = vi.fn(),
}: {
animation: GsapAnimation;
flat?: boolean;
onUpdateMeta?: ReturnType<typeof vi.fn>;
onUpdateKeyframeEase?: ReturnType<typeof vi.fn>;
}) {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => {
root.render(
<AnimationCard
animation={animation}
defaultExpanded
flat={flat}
onUpdateProperty={noop}
onUpdateMeta={onUpdateMeta}
onDeleteAnimation={noop}
onAddProperty={noop}
onRemoveProperty={noop}
onUpdateKeyframeEase={onUpdateKeyframeEase}
/>,
function restoreScrollIntoView(descriptor: PropertyDescriptor | undefined): void {
if (descriptor) Object.defineProperty(HTMLElement.prototype, "scrollIntoView", descriptor);
else Reflect.deleteProperty(HTMLElement.prototype, "scrollIntoView");
}

describe("AnimationCard", () => {
it("scrolls a focused segment into view but not a manually toggled segment", () => {
const originalScrollIntoView = Object.getOwnPropertyDescriptor(
HTMLElement.prototype,
"scrollIntoView",
);
const scrollIntoView = vi.fn();
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
configurable: true,
value: scrollIntoView,
});

const view = renderCard({ tweenPercentage: 50 });
try {
expect(scrollIntoView).toHaveBeenCalledOnce();
expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest", behavior: "smooth" });

view.render(null);
const manualToggle = findButton(view.host, "50% → 100%");
expect(manualToggle).toBeDefined();
act(() => manualToggle?.click());
expect(scrollIntoView).toHaveBeenCalledOnce();
} finally {
act(() => view.root.unmount());
restoreScrollIntoView(originalScrollIntoView);
}
});
return { host, root };

it("tracks a committed segment ease alongside the existing update", () => {
const onEaseCommit = vi.fn();
const view = renderCard(null, onEaseCommit, true);
const segment = findButton(view.host, "0% → 50%");
expect(segment).toBeDefined();
act(() => segment?.click());
const ease = selectPreset(view.host, "quad-out");

expect(onEaseCommit).toHaveBeenCalledWith(ANIMATION.id, 50, ease);
expect(trackStudioSegmentEaseEdit).toHaveBeenCalledWith({ action: "commit", ease });
act(() => view.root.unmount());
});

it("commits a focused multi-id segment ease through the bulk callback", () => {
const onUpdateKeyframeEase = vi.fn();
const onUpdateSegmentEase = vi.fn();
const collidingAnimationIds = [ANIMATION.id, "scale-tween", "opacity-tween"];
const view = renderCard(
{ tweenPercentage: 50, collidingAnimationIds },
onUpdateKeyframeEase,
false,
ANIMATION,
vi.fn(),
onUpdateSegmentEase,
);
const ease = selectPreset(view.host, "quad-out");

expect(onUpdateSegmentEase).toHaveBeenCalledExactlyOnceWith(collidingAnimationIds, 50, ease);
expect(onUpdateKeyframeEase).not.toHaveBeenCalled();
act(() => view.root.unmount());
});

it("keeps a focused single-id segment ease on the single callback", () => {
const onUpdateKeyframeEase = vi.fn();
const onUpdateSegmentEase = vi.fn();
const view = renderCard(
{ tweenPercentage: 50, collidingAnimationIds: [ANIMATION.id] },
onUpdateKeyframeEase,
false,
ANIMATION,
vi.fn(),
onUpdateSegmentEase,
);
const ease = selectPreset(view.host, "quad-out");

expect(onUpdateKeyframeEase).toHaveBeenCalledExactlyOnceWith(ANIMATION.id, 50, ease);
expect(onUpdateSegmentEase).not.toHaveBeenCalled();
act(() => view.root.unmount());
});

it("commits a focused flat tween segment ease through tween metadata", () => {
const onUpdateMeta = vi.fn();
const onUpdateKeyframeEase = vi.fn();
const view = renderCard(
{ tweenPercentage: 100 },
onUpdateKeyframeEase,
false,
FLAT_ANIMATION,
onUpdateMeta,
);
const ease = selectPreset(view.host, "quad-out");

expect(onUpdateMeta).toHaveBeenCalledExactlyOnceWith(FLAT_ANIMATION.id, { ease });
expect(onUpdateKeyframeEase).not.toHaveBeenCalled();
act(() => view.root.unmount());
});
});

function baseAnimation(overrides: Partial<GsapAnimation> = {}): GsapAnimation {
return {
id: "anim-1",
method: "to",
position: 0.8,
duration: 1.2,
ease: "power2.out",
properties: { opacity: 1 },
...overrides,
} as GsapAnimation;
}

const noop = () => {};

describe("AnimationCard ease editing", () => {
it("commits one preset change to the selected keyframe segment", () => {
const onUpdateKeyframeEase = vi.fn();
Expand All @@ -90,7 +222,7 @@ describe("AnimationCard ease editing", () => {
],
},
});
const view = renderExpandedCard({ animation, onUpdateKeyframeEase });
const view = renderCard(null, onUpdateKeyframeEase, true, animation);

const segment = Array.from(view.host.querySelectorAll("button")).find((button) =>
button.textContent?.includes("0% → 50%"),
Expand All @@ -111,12 +243,7 @@ describe("AnimationCard ease editing", () => {
const onUpdateMeta = vi.fn();
const onUpdateKeyframeEase = vi.fn();
const animation = baseAnimation({ id: "flat-tween" });
const view = renderExpandedCard({
animation,
flat: true,
onUpdateMeta,
onUpdateKeyframeEase,
});
const view = renderCard(null, onUpdateKeyframeEase, true, animation, onUpdateMeta);

const ease = selectPreset(view.host, "quad-out");

Expand Down
23 changes: 20 additions & 3 deletions packages/studio/src/components/editor/AnimationCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ interface AnimationCardProps extends GsapAnimationEditCallbacks {
animation: GsapAnimation;
defaultExpanded: boolean;
flat?: boolean;
focusedSegment?: { tweenPercentage: number } | null;
focusedSegment?: { tweenPercentage: number; collidingAnimationIds?: string[] } | null;
onFocusSegmentConsumed?: () => void;
}

Expand All @@ -47,13 +47,17 @@ export const AnimationCard = memo(function AnimationCard({
onSetArcPath,
onUpdateArcSegment,
onUpdateKeyframeEase,
onUpdateSegmentEase,
onSetAllKeyframeEases,
onUnroll,
}: AnimationCardProps) {
const [expanded, setExpanded] = useState(defaultExpanded);
const [addingProp, setAddingProp] = useState(false);
const [addingFromProp, setAddingFromProp] = useState(false);
const [expandedKfPct, setExpandedKfPct] = useState<number | null>(null);
const [focusedCollidingAnimationIds, setFocusedCollidingAnimationIds] = useState<
string[] | undefined
>();
const cardRef = useRef<HTMLDivElement>(null);
const pendingAutoScrollRef = useRef(false);

Expand All @@ -62,6 +66,7 @@ export const AnimationCard = memo(function AnimationCard({
setExpanded(true);
pendingAutoScrollRef.current = true;
setExpandedKfPct(focusedSegment.tweenPercentage);
setFocusedCollidingAnimationIds(focusedSegment.collidingAnimationIds);
onFocusSegmentConsumed?.();
}, [focusedSegment, onFocusSegmentConsumed]);

Expand Down Expand Up @@ -288,9 +293,21 @@ export const AnimationCard = memo(function AnimationCard({
keyframes={animation.keyframes.keyframes}
globalEase={animation.keyframes.easeEach ?? animation.ease ?? "none"}
expandedPct={expandedKfPct}
onToggle={setExpandedKfPct}
collidingAnimationIds={focusedCollidingAnimationIds}
onToggle={(pct) => {
setExpandedKfPct(pct);
setFocusedCollidingAnimationIds(undefined);
}}
onEaseCommit={(pct, ease) => {
onUpdateKeyframeEase(animation.id, pct, ease);
if (
focusedCollidingAnimationIds &&
focusedCollidingAnimationIds.length > 1 &&
onUpdateSegmentEase
) {
onUpdateSegmentEase(focusedCollidingAnimationIds, pct, ease);
} else {
onUpdateKeyframeEase(animation.id, pct, ease);
}
trackStudioSegmentEaseEdit({ action: "commit", ease });
}}
onApplyAll={
Expand Down
Loading
Loading