mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-09 12:00:26 +00:00
feat(studio): bulk-edit easing for merged keyframes
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user