mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-12 07:09:59 +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();
|
||||
|
||||
Reference in New Issue
Block a user