feat(studio): bulk-edit easing for merged keyframes

This commit is contained in:
Miguel Angel Simon Sierra
2026-07-29 03:39:51 +02:00
parent 7482c22d82
commit 10d45def05
22 changed files with 687 additions and 65 deletions
@@ -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);
@@ -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(),
@@ -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();
@@ -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;
}
@@ -47,6 +51,7 @@ export const AnimationCard = memo(function AnimationCard({
onSetArcPath,
onUpdateArcSegment,
onUpdateKeyframeEase,
onUpdateSegmentEase,
onSetAllKeyframeEases,
onUnroll,
}: AnimationCardProps) {
@@ -54,6 +59,9 @@ export const AnimationCard = memo(function AnimationCard({
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);
@@ -62,6 +70,7 @@ export const AnimationCard = memo(function AnimationCard({
setExpanded(true);
pendingAutoScrollRef.current = true;
setExpandedKfPct(focusedSegment.tweenPercentage);
setFocusedCollidingAnimationTargets(focusedSegment.collidingAnimationTargets);
onFocusSegmentConsumed?.();
}, [focusedSegment, onFocusSegmentConsumed]);
@@ -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={
@@ -4,6 +4,7 @@ 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;
@@ -11,12 +12,22 @@ 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 };
}
@@ -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"]],
@@ -10,6 +10,7 @@ import { holdCurvePath, MiniCurveSvg, sampledPath } from "./easeCurveSvg";
import { EaseBezierField, SpringBounceField, WiggleField } from "./EaseParamFields";
import { EASE_CURVES, EASE_LABELS, resolveEaseCurveTuple } from "./gsapAnimationConstants";
import { roundToCenti } from "../../utils/rounding";
import type { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth";
export { MiniCurveSvg } from "./easeCurveSvg";
@@ -323,9 +324,11 @@ function EaseParameterField({
export function EaseCurveSection({
ease,
onCustomEaseCommit,
collidingAnimationTargets,
}: {
ease: string;
onCustomEaseCommit: (ease: string) => void;
collidingAnimationTargets?: AnimationKeyframeTarget[];
}) {
const springBounce = parseSpringBounce(ease);
const isSpring = springBounce !== null;
@@ -419,6 +422,11 @@ export function EaseCurveSection({
return (
<div className="rounded-lg bg-neutral-900/50 p-2">
<EaseTypeDropdown kind={mode} ease={ease} label={label} onSelect={onCustomEaseCommit} />
{collidingAnimationTargets && collidingAnimationTargets.length > 1 && (
<p className="mb-1 text-[9px] text-neutral-500">
Applies to {collidingAnimationTargets.length} animations
</p>
)}
<EaseModeToggle mode={mode} onCommit={onCustomEaseCommit} />
<span className="sr-only" aria-live="polite">
{MODE_LABELS[mode]} ease editor selected
@@ -0,0 +1,102 @@
// @vitest-environment happy-dom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import { DesignPanelInputProvider } from "../../contexts/DesignPanelInputContext";
import { usePlayerStore } from "../../player";
import { GsapAnimationSection } from "./GsapAnimationSection";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
vi.mock("./AnimationCard", () => ({
AnimationCard: ({
animation,
focusedSegment,
onFocusSegmentConsumed,
}: {
animation: GsapAnimation;
focusedSegment: { tweenPercentage: number } | null;
onFocusSegmentConsumed: () => void;
}) => (
<button
type="button"
data-testid={`animation-${animation.id}`}
data-focused={focusedSegment ? String(focusedSegment.tweenPercentage) : ""}
// Mirrors the real card: the consume callback only fires from the effect
// that runs when this card actually received a focusedSegment.
onClick={() => focusedSegment && onFocusSegmentConsumed()}
/>
),
}));
vi.mock("./GsapAddAnimationControl", () => ({ GsapAddAnimationControl: () => null }));
afterEach(() => {
document.body.innerHTML = "";
usePlayerStore.getState().reset();
});
const sharedAnimation: GsapAnimation = {
id: "shared-animation",
targetSelector: ".shared",
method: "to",
position: 0,
properties: { x: 100 },
};
const requiredCallbacks = {
onAddAnimation: vi.fn(),
onUpdateProperty: vi.fn(),
onUpdateMeta: vi.fn(),
onDeleteAnimation: vi.fn(),
onAddProperty: vi.fn(),
onRemoveProperty: vi.fn(),
};
function renderSection(elementId: string) {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
const render = (nextElementId: string) => {
act(() => {
root.render(
<DesignPanelInputProvider section="test">
<GsapAnimationSection
{...requiredCallbacks}
elementId={nextElementId}
animations={[sharedAnimation]}
/>
</DesignPanelInputProvider>,
);
});
};
render(elementId);
return { host, root, render };
}
describe("GsapAnimationSection", () => {
it("consumes a shared animation id only for the focused element", () => {
usePlayerStore.getState().setFocusedEaseSegment({
elementId: "index.html#second",
animationId: sharedAnimation.id,
tweenPercentage: 50,
});
const view = renderSection("index.html#first");
const card = view.host.querySelector<HTMLButtonElement>(
"[data-testid='animation-shared-animation']",
);
if (!card) throw new Error("expected animation card");
expect(card.dataset.focused).toBe("");
act(() => card.click());
expect(usePlayerStore.getState().focusedEaseSegment?.elementId).toBe("index.html#second");
view.render("index.html#second");
expect(card.dataset.focused).toBe("50");
act(() => card.click());
expect(usePlayerStore.getState().focusedEaseSegment).toBeNull();
act(() => view.root.unmount());
});
});
@@ -13,6 +13,7 @@ import { usePlayerStore } from "../../player";
import { GsapAddAnimationControl } from "./GsapAddAnimationControl";
interface GsapAnimationSectionProps extends GsapAnimationEditCallbacks {
elementId: string;
animations: GsapAnimation[];
multipleTimelines?: boolean;
unsupportedTimelinePattern?: boolean;
@@ -21,6 +22,7 @@ interface GsapAnimationSectionProps extends GsapAnimationEditCallbacks {
export const GsapAnimationSection = memo(function GsapAnimationSection({
animations,
elementId,
multipleTimelines,
unsupportedTimelinePattern,
onAddAnimation,
@@ -55,7 +57,10 @@ export const GsapAnimationSection = memo(function GsapAnimationSection({
animation={anim}
defaultExpanded={index === 0}
focusedSegment={
focusedEaseSegment?.animationId === anim.id ? focusedEaseSegment : null
focusedEaseSegment?.elementId === elementId &&
focusedEaseSegment.animationId === anim.id
? focusedEaseSegment
: null
}
onFocusSegmentConsumed={clearFocusedEaseSegment}
/>
@@ -1,6 +1,7 @@
import type { GsapPercentageKeyframe } from "@hyperframes/core/gsap-parser";
import { EASE_LABELS } from "./gsapAnimationConstants";
import { EaseCurveSection } from "./EaseCurveSection";
import type { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth";
// The full GSAP easing vocabulary offered by the "Set all…" bulk control —
// every standard family in in/out/inOut, so authors aren't limited to a curated
@@ -44,6 +45,7 @@ export function KeyframeEaseList({
keyframes,
globalEase,
expandedPct,
collidingAnimationTargets,
onToggle,
onEaseCommit,
onApplyAll,
@@ -51,6 +53,7 @@ export function KeyframeEaseList({
keyframes: GsapPercentageKeyframe[];
globalEase: string;
expandedPct: number | null;
collidingAnimationTargets?: AnimationKeyframeTarget[];
onToggle: (pct: number | null) => void;
onEaseCommit: (pct: number, ease: string) => void;
/** Apply one ease to every segment at once (clears per-segment overrides). */
@@ -119,6 +122,7 @@ export function KeyframeEaseList({
<div className="px-2 pb-2">
<EaseCurveSection
ease={segEase}
collidingAnimationTargets={collidingAnimationTargets}
onCustomEaseCommit={(ease) => onEaseCommit(kf.percentage, ease)}
/>
</div>
@@ -1,3 +1,4 @@
import { scopedElementKey } from "../../hooks/gsapKeyframeCacheHelpers";
import { memo, useEffect, useRef, useState, type RefObject } from "react";
import type { DomEditSelection } from "./domEditing";
import { useDomEditContext } from "../../contexts/DomEditContext";
@@ -103,7 +104,7 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
const activeKeyframePct = usePlayerStore((s) => s.activeKeyframePct);
const timelineElement = usePlayerStore((state) => {
if (!selection) return undefined;
const sourceScopedId = `${selection.sourceFile || "index.html"}#${selection.id}`;
const sourceScopedId = scopedElementKey(selection);
return state.elements.find(
(element) => (element.key ?? element.id) === sourceScopedId || element.id === selection.id,
);
@@ -1,3 +1,4 @@
import { scopedElementKey } from "../../hooks/gsapKeyframeCacheHelpers";
import { memo, useEffect, useMemo, useRef, useState } from "react";
import { Move } from "../../icons/SystemIcons";
import { InspectorHeaderActions } from "./InspectorHeaderActions";
@@ -101,6 +102,7 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
onUpdateArcSegment,
onUnroll,
onUpdateKeyframeEase,
onUpdateSegmentEase,
onSetAllKeyframeEases,
onAddKeyframe,
onRemoveKeyframe,
@@ -556,6 +558,7 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
onAddGsapProperty &&
onAddGsapAnimation && (
<GsapAnimationSection
elementId={scopedElementKey(element)}
animations={gsapAnimations}
multipleTimelines={gsapMultipleTimelines}
unsupportedTimelinePattern={gsapUnsupportedTimelinePattern}
@@ -573,6 +576,7 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
onUnroll={onUnroll}
onUpdateKeyframeEase={onUpdateKeyframeEase}
onSetAllKeyframeEases={onSetAllKeyframeEases}
onUpdateSegmentEase={onUpdateSegmentEase}
/>
)}
@@ -1,3 +1,4 @@
import { scopedElementKey } from "../../hooks/gsapKeyframeCacheHelpers";
import { type ReactNode, useEffect, useRef, useState } from "react";
import { resolveEditingSections } from "@hyperframes/core/editing";
import { DesignPanelInputProvider } from "../../contexts/DesignPanelInputContext";
@@ -135,6 +136,7 @@ export function PropertyPanelFlat({
onUpdateArcSegment,
onUnroll,
onUpdateKeyframeEase,
onUpdateSegmentEase,
onSetAllKeyframeEases,
}: Pick<
PropertyPanelProps,
@@ -180,6 +182,7 @@ export function PropertyPanelFlat({
| "onUnroll"
| "onUpdateKeyframeEase"
| "onSetAllKeyframeEases"
| "onUpdateSegmentEase"
| "recordingState"
| "recordingDuration"
| "onToggleRecording"
@@ -253,7 +256,7 @@ export function PropertyPanelFlat({
// flips synchronously while the panel still renders its predecessor, so a
// stale panel would consume a request meant for its successor whenever the
// two share a class-selector animation id.
const renderedElementId = `${element.sourceFile}#${element.id}`;
const renderedElementId = scopedElementKey(element);
// Adjusted during render (not an effect) so the card mounts on the same
// commit the request lands on. Keyed on request identity: a group the user
// closes afterwards stays closed.
@@ -330,6 +333,7 @@ export function PropertyPanelFlat({
onUpdateArcSegment,
onUnroll,
onUpdateKeyframeEase,
onUpdateSegmentEase,
onSetAllKeyframeEases,
}
: null;
@@ -26,7 +26,6 @@ describe("withTrackedGsapAnimationCallbacks", () => {
const onLivePreviewEnd = vi.fn();
callbacks.onLivePreview = onLivePreview;
callbacks.onLivePreviewEnd = onLivePreviewEnd;
const tracked = withTrackedGsapAnimationCallbacks(callbacks, vi.fn());
expect(tracked.onUpdateFromProperty).toBeUndefined();
@@ -37,6 +36,7 @@ describe("withTrackedGsapAnimationCallbacks", () => {
expect(tracked.onUpdateKeyframeEase).toBeUndefined();
expect(tracked.onSetAllKeyframeEases).toBeUndefined();
expect(tracked.onUnroll).toBeUndefined();
expect(tracked.onUpdateSegmentEase).toBeUndefined();
expect(tracked.onLivePreview).toBe(onLivePreview);
expect(tracked.onLivePreviewEnd).toBe(onLivePreviewEnd);
});
@@ -57,6 +57,7 @@ describe("withTrackedGsapAnimationCallbacks", () => {
onUpdateArcSegment: mutation("arc-segment"),
onUpdateKeyframeEase: mutation("keyframe-ease"),
onSetAllKeyframeEases: mutation("all-eases"),
onUpdateSegmentEase: mutation("segment-ease"),
onUnroll: mutation("unroll"),
};
const tracked = withTrackedGsapAnimationCallbacks(callbacks, (control, name) => {
@@ -79,6 +80,10 @@ describe("withTrackedGsapAnimationCallbacks", () => {
requireCallback(tracked.onUpdateArcSegment)("a1", 1, { curviness: 0.5 });
requireCallback(tracked.onUpdateKeyframeEase)("a1", 50, "power2.out");
requireCallback(tracked.onSetAllKeyframeEases)("a1", "none");
requireCallback(tracked.onUpdateSegmentEase)(
[{ animationId: "a1", tweenPercentage: 50 }],
"none",
);
requireCallback(tracked.onUnroll)("a1");
expect(events).toEqual([
@@ -115,6 +120,8 @@ describe("withTrackedGsapAnimationCallbacks", () => {
"mutate:keyframe-ease",
"track:select:All keyframe eases",
"mutate:all-eases",
"track:select:Segment ease",
"mutate:segment-ease",
"track:button:Unroll animation",
"mutate:unroll",
]);
@@ -1,5 +1,6 @@
import type { ArcPathSegment } from "@hyperframes/parsers/gsap-parser";
import { usePlayerStore } from "../../player";
import type { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth";
/**
* Edit callbacks shared by GsapAnimationSection and each AnimationCard it
@@ -30,6 +31,7 @@ export interface GsapAnimationEditCallbacks {
update: Partial<ArcPathSegment>,
) => void;
onUpdateKeyframeEase?: (animationId: string, percentage: number, ease: string) => void;
onUpdateSegmentEase?: (targets: AnimationKeyframeTarget[], ease: string) => void;
/** Apply one ease to every keyframe segment at once (clears per-segment overrides). */
onSetAllKeyframeEases?: (animationId: string, ease: string) => void;
/** Unroll a computed (helper/loop) tween into literal tweens so it edits directly. */
@@ -122,6 +124,12 @@ export function withTrackedGsapAnimationCallbacks(
: undefined,
onLivePreview: callbacks.onLivePreview,
onLivePreviewEnd: callbacks.onLivePreviewEnd,
onUpdateSegmentEase: callbacks.onUpdateSegmentEase
? (targets, ease) => {
track("select", "Segment ease");
callbacks.onUpdateSegmentEase?.(targets, ease);
}
: undefined,
onSetArcPath: callbacks.onSetArcPath
? (animationId, config) => {
track("toggle", config.autoRotate !== undefined ? "Auto rotate" : "Arc motion");
@@ -5,11 +5,15 @@ import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import { FlatMotionSection, FlatTimingRow } from "./propertyPanelFlatMotionSection";
import type { DomEditSelection } from "./domEditing";
import { usePlayerStore } from "../../player";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
afterEach(() => {
document.body.innerHTML = "";
// The store is module-global, so a test that parks a focused ease segment
// would otherwise leak it into every test that runs after it.
usePlayerStore.getState().reset();
});
function baseElement(overrides: Partial<DomEditSelection> = {}): DomEditSelection {
@@ -270,3 +274,68 @@ describe("FlatMotionSection", () => {
act(() => root.unmount());
});
});
it("forwards focused bulk segment easing through the flat animation card", () => {
const onUpdateKeyframeEase = vi.fn();
const onUpdateSegmentEase = vi.fn();
usePlayerStore.setState({
focusedEaseSegment: {
elementId: "index.html#hero",
animationId: "a1",
tweenPercentage: 50,
collidingAnimationTargets: [
{ animationId: "a1", tweenPercentage: 50 },
{ animationId: "a2", tweenPercentage: 75 },
],
},
});
const { host, root } = renderInto(
<FlatMotionSection
element={baseElement()}
animations={[
{
id: "a1",
method: "to",
position: 0,
duration: 1,
properties: { x: 100 },
keyframes: {
format: "percentage",
keyframes: [
{ percentage: 0, properties: { x: 0 } },
{ percentage: 50, properties: { x: 100 } },
],
},
} as never,
]}
showTiming={false}
showEffects
onSetAttribute={vi.fn()}
onAddAnimation={vi.fn()}
onUpdateProperty={vi.fn()}
onUpdateMeta={vi.fn()}
onDeleteAnimation={vi.fn()}
onAddProperty={vi.fn()}
onRemoveProperty={vi.fn()}
onUpdateKeyframeEase={onUpdateKeyframeEase}
onUpdateSegmentEase={onUpdateSegmentEase}
/>,
);
const dropdown = host.querySelector<HTMLButtonElement>("[data-ease-type-dropdown]");
expect(dropdown).not.toBeNull();
act(() => dropdown?.click());
const preset = host.querySelector<HTMLButtonElement>('[data-ease-preset-id="quad-out"]');
expect(preset).not.toBeNull();
act(() => preset?.click());
expect(onUpdateSegmentEase).toHaveBeenCalledExactlyOnceWith(
[
{ animationId: "a1", tweenPercentage: 50 },
{ animationId: "a2", tweenPercentage: 75 },
],
"power2.out",
);
expect(onUpdateKeyframeEase).not.toHaveBeenCalled();
act(() => root.unmount());
});
@@ -1,3 +1,4 @@
import { scopedElementKey } from "../../hooks/gsapKeyframeCacheHelpers";
import { useState } from "react";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
@@ -143,7 +144,7 @@ export function FlatMotionSection({
// the store's selectedElementId, which flips synchronously during async
// selection resolution), so a shared class-selector animation id can't open
// the wrong element's editor.
const renderedElementId = `${element.sourceFile}#${element.id}`;
const renderedElementId = scopedElementKey(element);
const focusedHere =
focusedEaseSegment && focusedEaseSegment.elementId === renderedElementId
? focusedEaseSegment
@@ -2,6 +2,7 @@ import type { RefObject } from "react";
import type { ArcPathSegment, GsapAnimation } from "@hyperframes/parsers/gsap-parser";
import type { DomEditSelection } from "./domEditing";
import type { ImportedFontAsset } from "./fontAssets";
import type { GsapAnimationEditCallbacks } from "./gsapAnimationCallbacks";
export interface BackgroundRemovalProgress {
status: "processing" | "complete" | "failed";
@@ -123,6 +124,7 @@ export interface PropertyPanelProps {
onRemoveKeyframe?: (animationId: string, percentage: number) => void;
onUpdateKeyframeEase?: (animationId: string, percentage: number, ease: string) => void;
onSetAllKeyframeEases?: (animationId: string, ease: string) => void;
onUpdateSegmentEase?: NonNullable<GsapAnimationEditCallbacks["onUpdateSegmentEase"]>;
onConvertToKeyframes?: (animationId: string, duration?: number) => void;
onCommitAnimatedProperty?: (
selection: DomEditSelection,