Skip to content
Merged
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
3 changes: 2 additions & 1 deletion packages/studio/src/components/StudioRightPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ import type { EditHistoryKind } from "../utils/editHistory";
import { useSlideshowPersist, type UseSlideshowPersistParams } from "../hooks/useSlideshowPersist";
import { useSlideshowTabState } from "../hooks/useSlideshowTabState";
import { DesignPanelPromoteProvider } from "./DesignPanelPromoteProvider";

import { useStudioPlaybackContext, useStudioShellContext } from "../contexts/StudioContext";
import { usePanelLayoutContext } from "../contexts/PanelLayoutContext";
import { useFileManagerContext } from "../contexts/FileManagerContext";
Expand Down Expand Up @@ -156,6 +155,7 @@ export function StudioRightPanel({
handleUpdateArcSegment,
handleUnroll,
handleUpdateKeyframeEase,
handleUpdateSegmentEase,
handleSetAllKeyframeEases,
handleGsapAddKeyframe,
handleGsapRemoveKeyframe,
Expand Down Expand Up @@ -406,6 +406,7 @@ export function StudioRightPanel({
onUnroll={handleUnroll}
onUpdateKeyframeEase={handleUpdateKeyframeEase}
onSetAllKeyframeEases={handleSetAllKeyframeEases}
onUpdateSegmentEase={handleUpdateSegmentEase}
recordingState={recordingState}
recordingDuration={recordingDuration}
onToggleRecording={onToggleRecording}
Expand Down
200 changes: 188 additions & 12 deletions packages/studio/src/components/editor/AnimationCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,34 +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";
import type { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth";

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 renderFocusCard(
focusedSegment: {
tweenPercentage: number;
collidingAnimationTargets?: AnimationKeyframeTarget[];
} | 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);
Expand All @@ -45,6 +99,8 @@ function selectPreset(host: HTMLElement, presetId: string): string {
return presetConfig.ease;
}

const noop = () => {};

/** Every test mounts the same card; only expansion, flat mode, and the spies differ. */
function renderCard({
animation = baseAnimation(),
Expand Down Expand Up @@ -82,6 +138,126 @@ function renderCard({
return { host, root };
}

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 = renderFocusCard({ 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);
}
});

it("tracks a committed segment ease alongside the existing update", () => {
const onEaseCommit = vi.fn();
const view = renderFocusCard(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 collidingAnimationTargets = [
{ animationId: ANIMATION.id, tweenPercentage: 50 },
{ animationId: "scale-tween", tweenPercentage: 75 },
{ animationId: "opacity-tween", tweenPercentage: 25 },
];
const view = renderFocusCard(
{ tweenPercentage: 50, collidingAnimationTargets },
onUpdateKeyframeEase,
false,
ANIMATION,
vi.fn(),
onUpdateSegmentEase,
);
const ease = selectPreset(view.host, "quad-out");

expect(onUpdateSegmentEase).toHaveBeenCalledExactlyOnceWith(collidingAnimationTargets, 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 = renderFocusCard(
{
tweenPercentage: 50,
collidingAnimationTargets: [{ animationId: ANIMATION.id, tweenPercentage: 50 }],
},
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 = renderFocusCard(
{ 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;
}
describe("AnimationCard ease editing", () => {
it("commits one preset change to the selected keyframe segment", () => {
const onUpdateKeyframeEase = vi.fn();
Expand Down
27 changes: 24 additions & 3 deletions packages/studio/src/components/editor/AnimationCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,16 @@ import {
parseNumericOrString,
BOOLEAN_PROPS,
} from "./AnimationCardParts";
import type { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth";

interface AnimationCardProps extends GsapAnimationEditCallbacks {
animation: GsapAnimation;
defaultExpanded: boolean;
flat?: boolean;
focusedSegment?: { tweenPercentage: number } | null;
focusedSegment?: {
tweenPercentage: number;
collidingAnimationTargets?: AnimationKeyframeTarget[];
} | null;
onFocusSegmentConsumed?: () => void;
}

Expand All @@ -47,13 +51,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 [focusedCollidingAnimationTargets, setFocusedCollidingAnimationTargets] = useState<
AnimationKeyframeTarget[] | undefined
>();
const cardRef = useRef<HTMLDivElement>(null);
const pendingAutoScrollRef = useRef(false);

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

Expand Down Expand Up @@ -288,9 +297,21 @@ export const AnimationCard = memo(function AnimationCard({
keyframes={animation.keyframes.keyframes}
globalEase={animation.keyframes.easeEach ?? animation.ease ?? "none"}
expandedPct={expandedKfPct}
onToggle={setExpandedKfPct}
collidingAnimationTargets={focusedCollidingAnimationTargets}
onToggle={(pct) => {
setExpandedKfPct(pct);
setFocusedCollidingAnimationTargets(undefined);
}}
onEaseCommit={(pct, ease) => {
onUpdateKeyframeEase(animation.id, pct, ease);
if (
focusedCollidingAnimationTargets &&
focusedCollidingAnimationTargets.length > 1 &&
onUpdateSegmentEase
) {
onUpdateSegmentEase(focusedCollidingAnimationTargets, ease);
} else {
onUpdateKeyframeEase(animation.id, pct, ease);
}
trackStudioSegmentEaseEdit({ action: "commit", ease });
}}
onApplyAll={
Expand Down
38 changes: 36 additions & 2 deletions packages/studio/src/components/editor/EaseCurveSection.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,30 @@ import React, { act, useState } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import { EaseCurveSection, MiniCurveSvg } from "./EaseCurveSection";
import type { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth";

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

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

function renderSection(ease = "none", onCustomEaseCommit = vi.fn()) {
function renderSection(
ease = "none",
onCustomEaseCommit = vi.fn(),
collidingAnimationTargets?: AnimationKeyframeTarget[],
) {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => {
root.render(<EaseCurveSection ease={ease} onCustomEaseCommit={onCustomEaseCommit} />);
root.render(
<EaseCurveSection
ease={ease}
onCustomEaseCommit={onCustomEaseCommit}
collidingAnimationTargets={collidingAnimationTargets}
/>,
);
});
return { host, root, onCustomEaseCommit };
}
Expand Down Expand Up @@ -82,6 +93,29 @@ function editorLabel(host: HTMLElement): string | null {
}

describe("EaseCurveSection preset grid", () => {
it("shows the number of animations for a multi-id segment", () => {
const { host, root } = renderSection("power2.out", vi.fn(), [
{ animationId: "move-x", tweenPercentage: 20 },
{ animationId: "move-y", tweenPercentage: 50 },
{ animationId: "fade", tweenPercentage: 80 },
]);

expect(host.textContent).toContain("Applies to 3 animations");

act(() => root.unmount());
});

it.each([undefined, [{ animationId: "move-x", tweenPercentage: 20 }]])(
"does not show a property count for a non-colliding segment",
(collidingAnimationTargets) => {
const { host, root } = renderSection("power2.out", vi.fn(), collidingAnimationTargets);

expect(host.textContent).not.toContain("Applies to");

act(() => root.unmount());
},
);

it.each([
["curve", "none", "linear", ["flow-7", "spring-bouncy"]],
["spring", "spring(0.42)", "spring-bouncy", ["linear", "flow-7"]],
Expand Down
Loading
Loading