feat(studio): add keyframe ease editor

This commit is contained in:
Miguel Angel Simon Sierra
2026-07-25 14:12:17 +02:00
parent d84e999f72
commit c253dec23b
13 changed files with 1599 additions and 252 deletions
@@ -5,11 +5,16 @@ import { createRoot } from "react-dom/client";
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;
afterEach(() => {
document.body.innerHTML = "";
trackStudioSegmentEaseEdit.mockClear();
});
function baseAnimation(overrides: Partial<GsapAnimation> = {}): GsapAnimation {
@@ -26,6 +31,101 @@ function baseAnimation(overrides: Partial<GsapAnimation> = {}): GsapAnimation {
const noop = () => {};
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());
const preset = host.querySelector<HTMLButtonElement>(`[data-ease-preset-id="${presetId}"]`);
expect(preset).not.toBeNull();
act(() => preset?.click());
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}
/>,
);
});
return { host, root };
}
describe("AnimationCard ease editing", () => {
it("commits one preset change to the selected keyframe segment", () => {
const onUpdateKeyframeEase = vi.fn();
const animation = baseAnimation({
keyframes: {
format: "percentage",
keyframes: [
{ percentage: 0, properties: { opacity: 0 } },
{ percentage: 50, properties: { opacity: 0.5 } },
{ percentage: 100, properties: { opacity: 1 } },
],
},
});
const view = renderExpandedCard({ animation, onUpdateKeyframeEase });
const segment = Array.from(view.host.querySelectorAll("button")).find((button) =>
button.textContent?.includes("0% → 50%"),
);
expect(segment).toBeDefined();
act(() => segment?.click());
const ease = selectPreset(view.host, "quad-out");
expect(onUpdateKeyframeEase).toHaveBeenCalledExactlyOnceWith(animation.id, 50, ease);
expect(trackStudioSegmentEaseEdit).toHaveBeenCalledExactlyOnceWith({
ease,
});
act(() => view.root.unmount());
});
it("commits one preset change through flat tween metadata", () => {
const onUpdateMeta = vi.fn();
const onUpdateKeyframeEase = vi.fn();
const animation = baseAnimation({ id: "flat-tween" });
const view = renderExpandedCard({
animation,
flat: true,
onUpdateMeta,
onUpdateKeyframeEase,
});
const ease = selectPreset(view.host, "quad-out");
expect(onUpdateMeta).toHaveBeenCalledExactlyOnceWith(animation.id, { ease });
expect(onUpdateKeyframeEase).not.toHaveBeenCalled();
expect(trackStudioSegmentEaseEdit).not.toHaveBeenCalled();
act(() => view.root.unmount());
});
});
describe("AnimationCard flat branch", () => {
it("renders a mint border-left and panel-token colors when flat", () => {
const host = document.createElement("div");
@@ -1,6 +1,7 @@
import { memo, useCallback, useMemo, useState } from "react";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import { SUPPORTED_EASES, SUPPORTED_PROPS } from "@hyperframes/core/gsap-constants";
import { trackStudioSegmentEaseEdit } from "../../telemetry/events";
import { RESPONSIVE_GRID } from "./propertyPanelHelpers";
import { MetricField, SelectField } from "./propertyPanelPrimitives";
import { controlPointsForGsapEase } from "./studioMotion";
@@ -264,7 +265,10 @@ export const AnimationCard = memo(function AnimationCard({
globalEase={animation.keyframes.easeEach ?? animation.ease ?? "none"}
expandedPct={expandedKfPct}
onToggle={setExpandedKfPct}
onEaseCommit={(pct, ease) => onUpdateKeyframeEase(animation.id, pct, ease)}
onEaseCommit={(pct, ease) => {
onUpdateKeyframeEase(animation.id, pct, ease);
trackStudioSegmentEaseEdit({ ease });
}}
onApplyAll={
onSetAllKeyframeEases
? (ease) => onSetAllKeyframeEases(animation.id, ease)
@@ -292,7 +296,6 @@ export const AnimationCard = memo(function AnimationCard({
/>
<EaseCurveSection
ease={easeName}
duration={animation.duration}
onCustomEaseCommit={(customEase) => {
const easeKey = animation.keyframes ? "easeEach" : "ease";
onUpdateMeta(animation.id, { [easeKey]: customEase });
@@ -0,0 +1,348 @@
// @vitest-environment happy-dom
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";
(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()) {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => {
root.render(<EaseCurveSection ease={ease} onCustomEaseCommit={onCustomEaseCommit} />);
});
return { host, root, onCustomEaseCommit };
}
// The preset grid now lives behind the Figma-style ease-type dropdown; open it
// before querying preset tiles.
function openPresetGrid(host: HTMLElement): void {
const dropdown = host.querySelector<HTMLButtonElement>("[data-ease-type-dropdown]");
act(() => dropdown!.click());
}
function presetPath(host: HTMLElement, id: string): string | null | undefined {
return host
.querySelector<HTMLButtonElement>(`[data-ease-preset-id="${id}"]`)
?.querySelector("path")
?.getAttribute("d");
}
function countPathExtrema(path: string): number {
const values = Array.from(path.matchAll(/[ML][^,]+,([^ ]+)/g), (match) => Number(match[1]));
const directions = values
.slice(1)
.map((value, index) => Math.sign(value - values[index]!))
.filter((direction) => direction !== 0);
return directions.filter((direction, index) => index > 0 && direction !== directions[index - 1])
.length;
}
function renderStatefulSection(initialEase = "none", onCustomEaseCommit = vi.fn()) {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
const Harness = () => {
const [ease, setEase] = useState(initialEase);
return (
<EaseCurveSection
ease={ease}
onCustomEaseCommit={(nextEase) => {
onCustomEaseCommit(nextEase);
setEase(nextEase);
}}
/>
);
};
act(() => root.render(<Harness />));
return { host, root, onCustomEaseCommit };
}
function clickMode(host: HTMLElement, mode: "curve" | "spring" | "wiggle"): void {
const toggle = host.querySelector<HTMLButtonElement>(`[data-ease-mode="${mode}"]`);
expect(toggle).not.toBeNull();
act(() => toggle!.click());
}
function presetIds(host: HTMLElement): string[] {
return Array.from(host.querySelectorAll<HTMLElement>("[data-ease-preset-id]"), (preset) =>
preset.getAttribute("data-ease-preset-id"),
).filter((id): id is string => id !== null);
}
function editorLabel(host: HTMLElement): string | null {
return host.querySelector("[data-ease-type-dropdown] span")?.textContent ?? null;
}
describe("EaseCurveSection preset grid", () => {
it.each([
["curve", "none", "linear", ["flow-7", "spring-bouncy"]],
["spring", "spring(0.42)", "spring-bouncy", ["linear", "flow-7"]],
["wiggle", "wiggle(3,easeInOut,0.12)", "flow-7", ["linear", "spring-bouncy"]],
] as const)("shows only %s presets", (_mode, ease, includedPreset, excludedPresets) => {
const { host, root } = renderSection(ease);
openPresetGrid(host);
const ids = presetIds(host);
expect(ids).toContain(includedPreset);
for (const id of excludedPresets) expect(ids).not.toContain(id);
act(() => root.unmount());
});
it("renders a wiggle graph and fields without curve handles or fallback copy", () => {
const { host, root } = renderSection("wiggle(3,easeInOut,0.12)");
const graph = host.querySelector<SVGElement>('svg[viewBox="0 0 216 288"]');
expect(graph?.querySelector("path")).not.toBeNull();
expect(graph?.querySelectorAll(".cursor-grab")).toHaveLength(0);
expect(host.querySelector('[aria-label="Wiggle count"]')).not.toBeNull();
expect(host.querySelector('[aria-label="Wiggle type"]')).not.toBeNull();
expect(host.querySelector('[aria-label="Wiggle amplitude"]')).not.toBeNull();
expect(host.textContent).not.toContain("switch to");
act(() => root.unmount());
});
it.each([
["spring(0.6)", "Bouncy"],
["spring(0.37)", "Custom spring"],
["wiggle(2,uniform,0.3)", "Custom wiggle"],
["custom(M0,0 C0.1,0.2 0.8,0.9 1,1)", "Custom bezier"],
])("labels %s as %s", (ease, expectedLabel) => {
const { host, root } = renderSection(ease);
expect(editorLabel(host)).toBe(expectedLabel);
act(() => root.unmount());
});
it("commits the selected preset through the existing custom-ease callback", () => {
const { host, root, onCustomEaseCommit } = renderSection("wiggle(3,easeInOut,0.12)");
openPresetGrid(host);
const tile = host.querySelector<HTMLButtonElement>('[data-ease-preset-id="flow-7"]');
expect(tile).not.toBeNull();
act(() => tile!.click());
expect(onCustomEaseCommit).toHaveBeenCalledTimes(1);
expect(onCustomEaseCommit).toHaveBeenCalledWith("wiggle(7,easeInOut,0.06)");
act(() => root.unmount());
});
it("commits Hold as a segment ease", () => {
const { host, root, onCustomEaseCommit } = renderSection();
openPresetGrid(host);
const tile = host.querySelector<HTMLButtonElement>('[data-ease-preset-id="hold"]');
expect(tile).not.toBeNull();
act(() => tile!.click());
expect(onCustomEaseCommit).toHaveBeenCalledWith("hold");
act(() => root.unmount());
});
it("draws Hold as a flat step with an end jump", () => {
const { host, root } = renderSection();
openPresetGrid(host);
const path = presetPath(host, "hold");
expect(path).toBe("M3,21 L21,21 L21,3");
act(() => root.unmount());
});
it("draws Hold as the same flat step in the big editor graph", () => {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => {
root.render(<EaseCurveSection ease="hold" onCustomEaseCommit={vi.fn()} />);
});
const path = host
.querySelector<SVGElement>('svg[viewBox="0 0 216 288"]')
?.querySelector("path")
?.getAttribute("d");
expect(path).toBe("M16,236 L200,236 L200,52");
expect(host.querySelectorAll('[role="slider"]')).toHaveLength(0);
act(() => root.unmount());
});
it("preserves negative custom-ease control points in both curve graphs", () => {
const ease = "custom(M0,0 C0.3,-0.5 0.7,1.5 1,1)";
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => {
root.render(
<>
<MiniCurveSvg ease={ease} active={false} />
<EaseCurveSection ease={ease} onCustomEaseCommit={vi.fn()} />
</>,
);
});
const miniPath = host
.querySelector<SVGElement>('svg[viewBox="0 0 24 24"]')
?.querySelector("path")
?.getAttribute("d");
const editorPath = host
.querySelector<SVGElement>('svg[viewBox="0 0 216 288"]')
?.querySelector("path")
?.getAttribute("d");
expect(miniPath).toBe("M3,21 C8.399999999999999,30 15.6,-6 21,3");
expect(editorPath).toBe("M16,236 C71.19999999999999,328 144.79999999999998,-40 200,52");
act(() => root.unmount());
});
it("draws wiggle presets as sampled oscillating glyphs", () => {
const { host, root } = renderSection("wiggle(3,easeInOut,0.12)");
openPresetGrid(host);
const flow = presetPath(host, "flow-7");
const bounce = presetPath(host, "bounce-3");
expect(flow).toMatch(/^M.* L/);
expect(bounce).toMatch(/^M.* L/);
expect(flow).not.toBe(bounce);
expect(countPathExtrema(flow!)).toBeGreaterThan(8);
expect(countPathExtrema(bounce!)).toBeGreaterThan(8);
act(() => root.unmount());
});
it("draws Flow with increasing-frequency sampled glyphs", () => {
const { host, root } = renderSection("wiggle(3,easeInOut,0.12)");
openPresetGrid(host);
const flow1 = presetPath(host, "flow-1");
const flow7 = presetPath(host, "flow-7");
expect(flow1).toMatch(/^M.* L/);
expect(flow7).toMatch(/^M.* L/);
expect(flow1).not.toBe(flow7);
act(() => root.unmount());
});
it("draws explicit wiggle amplitudes in sampled glyphs", () => {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() =>
root.render(
<>
<MiniCurveSvg ease="wiggle(3,easeInOut,0.1)" active={false} />
<MiniCurveSvg ease="wiggle(3,easeInOut,0.2)" active={false} />
</>,
),
);
const paths = Array.from(host.querySelectorAll("path"), (path) => path.getAttribute("d"));
expect(paths[0]).not.toBe(paths[1]);
act(() => root.unmount());
});
it("commits each mode default when switching modes", () => {
const { host, root, onCustomEaseCommit } = renderStatefulSection();
clickMode(host, "spring");
expect(onCustomEaseCommit).toHaveBeenLastCalledWith("spring(0.42)");
clickMode(host, "curve");
expect(onCustomEaseCommit).toHaveBeenLastCalledWith("custom(M0,0 C0.16,1 0.3,1 1,1)");
clickMode(host, "wiggle");
expect(onCustomEaseCommit).toHaveBeenLastCalledWith("wiggle(3,easeInOut,0.12)");
act(() => root.unmount());
});
it("keeps spring bounce editing wired to the custom-ease callback", () => {
const { host, root, onCustomEaseCommit } = renderStatefulSection("spring(0.37)");
const bounceInput = host.querySelector<HTMLInputElement>('[aria-label="Spring bounce"]');
expect(bounceInput).not.toBeNull();
act(() => {
bounceInput!.focus();
const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
valueSetter?.call(bounceInput, "0.7");
bounceInput!.dispatchEvent(new Event("input", { bubbles: true }));
});
expect(onCustomEaseCommit).not.toHaveBeenCalled();
act(() => bounceInput!.blur());
expect(onCustomEaseCommit).toHaveBeenLastCalledWith("spring(0.7)");
act(() => root.unmount());
});
it("keeps curve handles draggable and commits the edited custom curve", () => {
const { host, root, onCustomEaseCommit } = renderSection("power2.out");
const graph = host.querySelector<SVGSVGElement>('svg[viewBox="0 0 216 288"]');
const handle = graph?.querySelector<SVGCircleElement>(".cursor-grab");
expect(graph).not.toBeNull();
expect(handle).not.toBeNull();
vi.spyOn(graph!, "getBoundingClientRect").mockReturnValue(new DOMRect(0, 0, 216, 288));
handle!.setPointerCapture = vi.fn();
act(() => {
handle!.dispatchEvent(
new PointerEvent("pointerdown", { bubbles: true, pointerId: 1, clientX: 46, clientY: 52 }),
);
});
act(() => {
graph!.dispatchEvent(
new PointerEvent("pointermove", {
bubbles: true,
pointerId: 1,
clientX: 108,
clientY: 144,
}),
);
});
act(() => {
graph!.dispatchEvent(new PointerEvent("pointerup", { bubbles: true, pointerId: 1 }));
});
expect(onCustomEaseCommit).toHaveBeenLastCalledWith("custom(M0,0 C0.5,0.5 0.3,1 1,1)");
act(() => root.unmount());
});
it("nudges curve handles with the keyboard", () => {
const { host, root, onCustomEaseCommit } = renderSection("power2.out");
const firstHandle = host.querySelector<SVGCircleElement>('[role="slider"]');
expect(firstHandle).not.toBeNull();
act(() => {
firstHandle!.dispatchEvent(
new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }),
);
});
expect(onCustomEaseCommit).toHaveBeenLastCalledWith("custom(M0,0 C0.17,1 0.3,1 1,1)");
act(() => root.unmount());
});
it("closes the preset menu with Escape and returns focus to its trigger", () => {
const { host, root } = renderSection();
const trigger = host.querySelector<HTMLButtonElement>("[data-ease-type-dropdown]")!;
openPresetGrid(host);
expect(trigger.getAttribute("aria-expanded")).toBe("true");
act(() => document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })));
expect(trigger.getAttribute("aria-expanded")).toBe("false");
expect(document.activeElement).toBe(trigger);
act(() => root.unmount());
});
});
@@ -1,75 +1,55 @@
import { useCallback, useRef, useState } from "react";
import { EASE_CURVES, EASE_LABELS, parseCustomEaseFromString } from "./gsapAnimationConstants";
import { useEffect, useId, useRef, useState } from "react";
import { evaluateSpringEase, parseSpringBounce } from "@hyperframes/core/spring-ease";
import {
evaluateWiggleEase,
parseWiggleEase,
type WiggleEaseConfig,
} from "@hyperframes/core/wiggle-ease";
import { EASE_PRESETS, easePresetLabel } from "./easePresetLibrary";
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";
// Figma-canonical ordering: linear, the three core eases, then the expressive
// (back / snappy) family. Each maps to a GSAP ease so it round-trips cleanly.
const PRESET_GRID_EASES = [
"none",
"power2.in",
"power2.out",
"power2.inOut",
"back.in",
"back.out",
"back.inOut",
"expo.out",
] as const;
export { MiniCurveSvg } from "./easeCurveSvg";
function MiniCurveSvg({
curve,
active,
}: {
curve: [number, number, number, number];
active: boolean;
}) {
const [x1, y1, x2, y2] = curve;
const s = 24;
const p = 3;
const g = s - p * 2;
const sx = (px: number) => p + g * px;
const sy = (py: number) => s - p - g * py;
const d = `M${p},${s - p} C${sx(x1)},${sy(y1)} ${sx(x2)},${sy(y2)} ${s - p},${p}`;
return (
<svg width={s} height={s} viewBox={`0 0 ${s} ${s}`}>
<path
d={d}
fill="none"
stroke={active ? "#3CE6AC" : "#737373"}
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
);
}
const EASE_MODES = ["curve", "spring", "wiggle"] as const;
type EaseMode = (typeof EASE_MODES)[number];
const EasePresetGrid = function EasePresetGrid({
kind,
currentEase,
onSelect,
}: {
kind: EaseMode;
currentEase: string;
onSelect: (ease: string) => void;
}) {
return (
<div className="grid grid-cols-4 gap-1 mb-2">
{PRESET_GRID_EASES.map((name) => {
const curve = EASE_CURVES[name];
if (!curve) return null;
const isActive = currentEase === name;
<div className="mb-2 grid max-h-56 grid-cols-4 gap-1 overflow-y-auto pr-0.5">
{EASE_PRESETS.filter((preset) => preset.kind === kind).map((preset) => {
const isActive = currentEase === preset.ease;
return (
<button
key={name}
key={preset.id}
type="button"
onClick={() => onSelect(name)}
data-ease-preset-id={preset.id}
role="menuitemradio"
aria-checked={isActive}
tabIndex={isActive ? 0 : -1}
onClick={() => onSelect(preset.ease)}
className={`flex flex-col items-center gap-0.5 rounded-md p-1 transition-colors ${
isActive ? "bg-panel-accent/10 ring-1 ring-panel-accent/30" : "hover:bg-neutral-800"
}`}
title={EASE_LABELS[name] ?? name}
title={preset.label}
>
<MiniCurveSvg curve={curve} active={isActive} />
<MiniCurveSvg ease={preset.ease} active={isActive} />
<span
className={`text-[8px] leading-none ${isActive ? "text-panel-accent" : "text-neutral-500"}`}
className={`text-center text-[8px] leading-none ${
isActive ? "text-panel-accent" : "text-neutral-500"
}`}
>
{(EASE_LABELS[name] ?? name).split(" ").slice(0, 2).join(" ")}
{preset.label}
</span>
</button>
);
@@ -101,56 +81,276 @@ const DRAG_VMIN = -1;
const ACCENT = "#3CE6AC";
type Pts = [number, number, number, number];
const DEFAULT_CURVE: Pts = EASE_CURVES["power2.out"];
const MODE_LABELS = { curve: "Curve", spring: "Spring", wiggle: "Wiggle" } satisfies Record<
EaseMode,
string
>;
const DEFAULT_EASE_BY_MODE = {
curve: `custom(M0,0 C${DEFAULT_CURVE[0]},${DEFAULT_CURVE[1]} ${DEFAULT_CURVE[2]},${DEFAULT_CURVE[3]} 1,1)`,
spring: "spring(0.42)",
wiggle: "wiggle(3,easeInOut,0.12)",
} satisfies Record<EaseMode, string>;
function EaseModeToggle({ mode, onCommit }: { mode: EaseMode; onCommit: (ease: string) => void }) {
return (
<div
className="mb-2 grid grid-cols-3 rounded-md bg-black/20 p-0.5"
role="radiogroup"
aria-label="Ease editor mode"
>
{EASE_MODES.map((candidateMode) => {
const active = candidateMode === mode;
return (
<button
key={candidateMode}
type="button"
data-ease-mode={candidateMode}
role="radio"
aria-checked={active}
onClick={() => {
if (active) return;
onCommit(DEFAULT_EASE_BY_MODE[candidateMode]);
}}
className={`rounded px-2 py-1 text-[10px] font-medium transition-colors ${
active ? "bg-neutral-700 text-neutral-100" : "text-neutral-500 hover:text-neutral-300"
}`}
>
{MODE_LABELS[candidateMode]}
</button>
);
})}
</div>
);
}
// Figma-style ease-type dropdown: the current ease (glyph + name) as a button
// that opens the preset grid in a popover. This is where a preset is selected —
// the grid is no longer shown inline.
function EaseTypeDropdown({
kind,
ease,
label,
onSelect,
}: {
kind: EaseMode;
ease: string;
label: string;
onSelect: (ease: string) => void;
}) {
const [open, setOpen] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const menuId = useId();
useEffect(() => {
if (!open) return;
const menu = menuRef.current;
const selected =
menu?.querySelector<HTMLButtonElement>('[role="menuitemradio"][aria-checked="true"]') ??
menu?.querySelector<HTMLButtonElement>('[role="menuitemradio"]');
selected?.focus();
const closeOutside = (event: PointerEvent) => {
const target = event.target;
if (!(target instanceof Node)) return;
if (!menuRef.current?.contains(target) && !triggerRef.current?.contains(target)) {
setOpen(false);
}
};
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
event.preventDefault();
setOpen(false);
triggerRef.current?.focus();
};
document.addEventListener("pointerdown", closeOutside);
document.addEventListener("keydown", closeOnEscape);
return () => {
document.removeEventListener("pointerdown", closeOutside);
document.removeEventListener("keydown", closeOnEscape);
};
}, [open]);
const handleMenuKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "Tab") {
setOpen(false);
return;
}
const offsets: Record<string, number> = {
ArrowLeft: -1,
ArrowRight: 1,
ArrowUp: -4,
ArrowDown: 4,
};
const offset = offsets[event.key];
if (offset === undefined && event.key !== "Home" && event.key !== "End") return;
event.preventDefault();
const items = Array.from(
event.currentTarget.querySelectorAll<HTMLButtonElement>('[role="menuitemradio"]'),
);
if (items.length === 0) return;
const current = Math.max(0, items.indexOf(document.activeElement as HTMLButtonElement));
const next =
event.key === "Home"
? 0
: event.key === "End"
? items.length - 1
: (current + (offset ?? 0) + items.length) % items.length;
items[next]?.focus();
};
return (
<div className="relative mb-2">
<button
ref={triggerRef}
type="button"
data-ease-type-dropdown=""
aria-haspopup="menu"
aria-expanded={open}
aria-controls={open ? menuId : undefined}
onClick={() => setOpen((prev) => !prev)}
className="flex w-full items-center gap-2 rounded-md border border-white/10 bg-black/20 px-2 py-1.5 text-left transition-colors hover:border-white/20"
>
<MiniCurveSvg ease={ease} active size={16} />
<span className="text-[11px] text-neutral-200">{label}</span>
<svg
width="8"
height="8"
viewBox="0 0 10 10"
fill="currentColor"
className={`ml-auto text-neutral-500 transition-transform ${open ? "rotate-180" : ""}`}
>
<path d="M2 3l3 4 3-4z" />
</svg>
</button>
{open && (
<div
ref={menuRef}
id={menuId}
role="menu"
aria-label={`${MODE_LABELS[kind]} ease presets`}
onKeyDown={handleMenuKeyDown}
className="absolute inset-x-0 top-full z-20 mt-1 rounded-md border border-white/10 bg-neutral-900 p-2 shadow-xl"
>
<EasePresetGrid
kind={kind}
currentEase={ease}
onSelect={(next) => {
onSelect(next);
setOpen(false);
triggerRef.current?.focus();
}}
/>
</div>
)}
</div>
);
}
function resolveEditableCurve(ease: string, springBounce: number | null): Pts | null {
if (springBounce !== null) return DEFAULT_CURVE;
if (ease === "hold") return null;
if (ease.startsWith("custom(") || ease in EASE_CURVES) return resolveEaseCurveTuple(ease);
return null;
}
function resolveEditorLabel(ease: string, springBounce: number | null, isWiggle: boolean): string {
const presetLabel = easePresetLabel(ease);
if (presetLabel !== null) return presetLabel;
if (springBounce !== null) return "Custom spring";
if (isWiggle) return "Custom wiggle";
if (ease.startsWith("custom(")) return "Custom bezier";
return EASE_LABELS[ease] ?? ease;
}
const xToSvg = (px: number) => PADH + S * px;
const yToSvg = (py: number) => HR + S * (1 - py);
const clampView = (py: number) => Math.max(VMIN, Math.min(VMAX, py));
const clampDragY = (py: number) => Math.max(DRAG_VMIN, Math.min(DRAG_VMAX, py));
function cubicAt(t: number, c0: number, c1: number, c2: number, c3: number): number {
const mt = 1 - t;
return mt * mt * mt * c0 + 3 * mt * mt * t * c1 + 3 * mt * t * t * c2 + t * t * t * c3;
function nudgeCurve(tuple: Pts, handle: "p1" | "p2", key: string, step: number): Pts | null {
const deltaX = key === "ArrowLeft" ? -step : key === "ArrowRight" ? step : 0;
const deltaY = key === "ArrowDown" ? -step : key === "ArrowUp" ? step : 0;
if (deltaX === 0 && deltaY === 0) return null;
const next: Pts = [...tuple];
const xIndex = handle === "p1" ? 0 : 2;
const yIndex = handle === "p1" ? 1 : 3;
next[xIndex] = round2(Math.max(0, Math.min(1, next[xIndex] + deltaX)));
next[yIndex] = round2(clampDragY(next[yIndex] + deltaY));
return next;
}
function curvePathFor(
ease: string,
springBounce: number | null,
wiggleConfig: WiggleEaseConfig | null,
tuple: Pts,
): string {
if (ease === "hold") return holdCurvePath(xToSvg(0), yToSvg(0), xToSvg(1), yToSvg(1));
if (wiggleConfig !== null) {
return sampledPath(64, xToSvg, yToSvg, (progress) =>
evaluateWiggleEase(progress, wiggleConfig.wiggles, wiggleConfig.type, wiggleConfig.amplitude),
);
}
if (springBounce !== null) {
return sampledPath(64, xToSvg, yToSvg, (progress) =>
evaluateSpringEase(progress, springBounce),
);
}
const [x1, y1, x2, y2] = tuple;
return `M${xToSvg(0)},${yToSvg(0)} C${xToSvg(x1)},${yToSvg(y1)} ${xToSvg(x2)},${yToSvg(y2)} ${xToSvg(1)},${yToSvg(1)}`;
}
function EaseParameterField({
springBounce,
wiggleConfig,
tuple,
onCommit,
}: {
springBounce: number | null;
wiggleConfig: WiggleEaseConfig | null;
tuple: Pts;
onCommit: (ease: string) => void;
}) {
if (springBounce !== null) {
return <SpringBounceField springBounce={springBounce} onCommit={onCommit} />;
}
if (wiggleConfig !== null) return <WiggleField config={wiggleConfig} onCommit={onCommit} />;
return <EaseBezierField tuple={tuple} onCommit={onCommit} />;
}
export function EaseCurveSection({
ease,
duration,
onCustomEaseCommit,
}: {
ease: string;
duration?: number;
onCustomEaseCommit: (ease: string) => void;
}) {
const isCustom = ease.startsWith("custom(");
const curveFromPreset = EASE_CURVES[ease];
const customPoints = isCustom ? parseCustomEaseFromString(ease) : null;
const curve: Pts | null =
isCustom && customPoints
? [customPoints.x1, customPoints.y1, customPoints.x2, customPoints.y2]
: (curveFromPreset ?? null);
const springBounce = parseSpringBounce(ease);
const isSpring = springBounce !== null;
const wiggleConfig = parseWiggleEase(ease);
const isWiggle = wiggleConfig !== null;
const mode: EaseMode = isSpring ? "spring" : isWiggle ? "wiggle" : "curve";
const curve = resolveEditableCurve(ease, springBounce);
const [draft, setDraft] = useState<Pts | null>(null);
const [progress, setProgress] = useState<number | null>(null);
const [hover, setHover] = useState<"p1" | "p2" | null>(null);
const draggingRef = useRef<"p1" | "p2" | null>(null);
const svgRef = useRef<SVGSVGElement | null>(null);
const rafRef = useRef<number>(0);
const play = useCallback(() => {
const start = performance.now();
const dur = 1100;
const tick = (now: number) => {
const t = Math.min((now - start) / dur, 1);
setProgress(t);
if (t < 1) rafRef.current = requestAnimationFrame(tick);
else setTimeout(() => setProgress(null), 450);
};
cancelAnimationFrame(rafRef.current);
rafRef.current = requestAnimationFrame(tick);
}, []);
// Keep the local draft displayed until the committed `ease` prop round-trips
// back (write → reparse → re-render), then drop it. Clearing on pointer-up
// instead would fall back to the STALE prop for a frame — the curve snaps to
// the old value and jumps to the new one (the commit flicker). By the time
// `ease` changes, `curve` already equals the draft, so the handoff is seamless.
useEffect(() => {
setDraft(null);
}, [ease]);
const active = draft ?? curve;
if (!active) return null;
const [x1, y1, x2, y2] = active;
const activeTuple = draft ?? curve;
const displayTuple = activeTuple ?? DEFAULT_CURVE;
const [x1, y1, x2, y2] = displayTuple;
// Anchors + control handles. Handle *display* is clamped to the view so an
// extreme loaded overshoot rides the edge instead of disappearing.
@@ -158,18 +358,9 @@ export function EaseCurveSection({
const a1 = { x: xToSvg(1), y: yToSvg(1) };
const p1 = { x: xToSvg(x1), y: yToSvg(clampView(y1)) };
const p2 = { x: xToSvg(x2), y: yToSvg(clampView(y2)) };
// Curve drawn from the true control points (so its shape is exact).
const cp1 = { x: xToSvg(x1), y: yToSvg(y1) };
const cp2 = { x: xToSvg(x2), y: yToSvg(y2) };
const curvePath = `M${a0.x},${a0.y} C${cp1.x},${cp1.y} ${cp2.x},${cp2.y} ${a1.x},${a1.y}`;
let dot: { x: number; y: number } | null = null;
if (progress !== null) {
dot = {
x: xToSvg(cubicAt(progress, 0, x1, x2, 1)),
y: yToSvg(cubicAt(progress, 0, y1, y2, 1)),
};
}
const curvePath = curvePathFor(ease, springBounce, wiggleConfig, displayTuple);
const showGraph = activeTuple !== null || isWiggle || ease === "hold";
const showHandles = curve !== null && !isSpring && !isWiggle;
const handlePointerDown = (handle: "p1" | "p2", e: React.PointerEvent) => {
e.preventDefault();
@@ -190,7 +381,7 @@ export function EaseCurveSection({
const px = Math.max(0, Math.min(1, (sx - PADH) / S));
// py uses the WIDER drag bound (not clampView), so dragging keeps overshoot
// fidelity instead of pinning the committed value to the visible view edge.
const py = Math.max(DRAG_VMIN, Math.min(DRAG_VMAX, 1 - (sy - HR) / S));
const py = clampDragY(1 - (sy - HR) / S);
const prev = draft ?? [x1, y1, x2, y2];
const next: Pts =
draggingRef.current === "p1"
@@ -203,164 +394,180 @@ export function EaseCurveSection({
if (!draggingRef.current || !draft) return;
draggingRef.current = null;
const path = `M0,0 C${draft[0]},${draft[1]} ${draft[2]},${draft[3]} 1,1`;
// Clear after the synchronous parent commit settles. This also clears a
// same-string commit, where the `ease` dependency effect would not run.
onCustomEaseCommit(`custom(${path})`);
setDraft(null);
queueMicrotask(() => setDraft(null));
};
const handleKeyDown = (handle: "p1" | "p2", event: React.KeyboardEvent<SVGCircleElement>) => {
const next = nudgeCurve(displayTuple, handle, event.key, event.shiftKey ? 0.1 : 0.01);
if (!next) return;
event.preventDefault();
event.stopPropagation();
setDraft(next);
onCustomEaseCommit(`custom(M0,0 C${next[0]},${next[1]} ${next[2]},${next[3]} 1,1)`);
queueMicrotask(() => setDraft(null));
};
const top = yToSvg(1);
const bottom = yToSvg(0);
const left = xToSvg(0);
const right = xToSvg(1);
const label = isCustom ? "Custom curve" : (EASE_LABELS[ease] ?? ease);
const bezierText = `${x1} · ${y1} · ${x2} · ${y2}`;
const label = resolveEditorLabel(ease, springBounce, isWiggle);
return (
<div className="rounded-lg bg-neutral-900/50 p-2">
<EasePresetGrid currentEase={ease} onSelect={(name) => onCustomEaseCommit(name)} />
<div className="mb-1.5 flex items-center justify-between">
<span className="text-[10px] font-medium text-neutral-500">Speed curve</span>
<button
type="button"
onClick={play}
className="rounded px-1.5 py-0.5 text-[10px] font-medium text-panel-accent transition-colors hover:bg-panel-accent/10"
>
{progress !== null ? "Playing…" : "Preview"}
</button>
</div>
<div
className="mx-auto overflow-hidden rounded-md border border-white/5 bg-black/20"
style={{ aspectRatio: `${SVGW} / ${SVGH}`, width: "100%", maxWidth: 230 }}
>
<svg
ref={svgRef}
width="100%"
height="100%"
viewBox={`0 0 ${SVGW} ${SVGH}`}
preserveAspectRatio="none"
className="touch-none select-none"
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
>
{/* Grid — quarter lines inside the unit square */}
{[0.25, 0.5, 0.75].map((q) => (
<line
key={`v${q}`}
x1={xToSvg(q)}
y1={top}
x2={xToSvg(q)}
y2={bottom}
stroke="white"
strokeOpacity="0.05"
strokeWidth="1"
/>
))}
{[0.25, 0.5, 0.75].map((q) => (
<line
key={`h${q}`}
x1={left}
y1={yToSvg(q)}
x2={right}
y2={yToSvg(q)}
stroke="white"
strokeOpacity="0.05"
strokeWidth="1"
/>
))}
{/* Unit-square frame (progress 0 → 1) */}
<rect
x={left}
y={top}
width={S}
height={bottom - top}
fill="none"
stroke="white"
strokeOpacity="0.1"
strokeWidth="1"
/>
{/* Linear reference diagonal */}
<line
x1={a0.x}
y1={a0.y}
x2={a1.x}
y2={a1.y}
stroke="white"
strokeOpacity="0.08"
strokeWidth="1"
strokeDasharray="3 4"
/>
{/* Tangent handle lines */}
<line
x1={a0.x}
y1={a0.y}
x2={p1.x}
y2={p1.y}
stroke={ACCENT}
strokeOpacity="0.5"
strokeWidth="1.5"
/>
<line
x1={a1.x}
y1={a1.y}
x2={p2.x}
y2={p2.y}
stroke={ACCENT}
strokeOpacity="0.5"
strokeWidth="1.5"
/>
{/* The curve */}
<path d={curvePath} fill="none" stroke={ACCENT} strokeWidth="2.5" strokeLinecap="round" />
{/* Anchors at (0,0) and (1,1) */}
<circle cx={a0.x} cy={a0.y} r="3" fill={ACCENT} />
<circle cx={a1.x} cy={a1.y} r="3" fill={ACCENT} />
{/* Animated preview dot */}
{dot && (
<>
<circle cx={dot.x} cy={dot.y} r="9" fill={ACCENT} fillOpacity="0.18" />
<circle cx={dot.x} cy={dot.y} r="4.5" fill={ACCENT} />
</>
)}
{/* Draggable control handles (large transparent hit area + visible dot) */}
{[["p1", p1] as const, ["p2", p2] as const].map(([key, pt]) => (
<g key={key}>
<circle
cx={pt.x}
cy={pt.y}
r="14"
fill="transparent"
className="cursor-grab active:cursor-grabbing"
onPointerDown={(e) => handlePointerDown(key, e)}
onPointerEnter={() => setHover(key)}
onPointerLeave={() => setHover((h) => (h === key ? null : h))}
<EaseTypeDropdown kind={mode} ease={ease} label={label} onSelect={onCustomEaseCommit} />
<EaseModeToggle mode={mode} onCommit={onCustomEaseCommit} />
<span className="sr-only" aria-live="polite">
{MODE_LABELS[mode]} ease editor selected
</span>
{showGraph ? (
<>
<div
className="mx-auto overflow-hidden rounded-md border border-white/5 bg-black/20"
style={{ aspectRatio: `${SVGW} / ${SVGH}`, width: "100%", maxWidth: 230 }}
>
<svg
ref={svgRef}
width="100%"
height="100%"
viewBox={`0 0 ${SVGW} ${SVGH}`}
preserveAspectRatio="none"
className="touch-none select-none"
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
>
{/* Grid — quarter lines inside the unit square */}
{[0.25, 0.5, 0.75].map((q) => (
<line
key={`v${q}`}
x1={xToSvg(q)}
y1={top}
x2={xToSvg(q)}
y2={bottom}
stroke="white"
strokeOpacity="0.05"
strokeWidth="1"
/>
))}
{[0.25, 0.5, 0.75].map((q) => (
<line
key={`h${q}`}
x1={left}
y1={yToSvg(q)}
x2={right}
y2={yToSvg(q)}
stroke="white"
strokeOpacity="0.05"
strokeWidth="1"
/>
))}
{/* Unit-square frame (progress 0 → 1) */}
<rect
x={left}
y={top}
width={S}
height={bottom - top}
fill="none"
stroke="white"
strokeOpacity="0.1"
strokeWidth="1"
/>
<circle
cx={pt.x}
cy={pt.y}
r={hover === key || draggingRef.current === key ? 7 : 5.5}
fill="#0a0a1a"
{/* Linear reference diagonal */}
<line
x1={a0.x}
y1={a0.y}
x2={a1.x}
y2={a1.y}
stroke="white"
strokeOpacity="0.08"
strokeWidth="1"
strokeDasharray="3 4"
/>
{/* Tangent handle lines */}
{showHandles && (
<>
<line
x1={a0.x}
y1={a0.y}
x2={p1.x}
y2={p1.y}
stroke={ACCENT}
strokeOpacity="0.5"
strokeWidth="1.5"
/>
<line
x1={a1.x}
y1={a1.y}
x2={p2.x}
y2={p2.y}
stroke={ACCENT}
strokeOpacity="0.5"
strokeWidth="1.5"
/>
</>
)}
{/* The curve */}
<path
d={curvePath}
fill="none"
stroke={ACCENT}
strokeWidth="2.5"
className="pointer-events-none transition-[r]"
strokeLinecap="round"
/>
</g>
))}
</svg>
</div>
{/* Axis + value readout */}
<div className="mt-1.5 flex items-center justify-between px-0.5 text-[9px] text-neutral-600">
<span>{duration != null && duration > 0 ? "0s" : "start"}</span>
<span className="tracking-wide text-neutral-500">time </span>
<span>{duration != null && duration > 0 ? `${duration}s` : "end"}</span>
</div>
<div className="mt-1 flex items-center justify-between px-0.5">
<span className="text-[10px] text-neutral-400">{label}</span>
<span
className="font-mono text-[9px] tracking-tight text-neutral-600"
title="cubic-bezier control points"
>
{bezierText}
</span>
</div>
{/* Anchors at (0,0) and (1,1) */}
<circle cx={a0.x} cy={a0.y} r="3" fill={ACCENT} />
<circle cx={a1.x} cy={a1.y} r="3" fill={ACCENT} />
{/* Draggable control handles (large transparent hit area + visible dot) */}
{showHandles &&
[["p1", p1] as const, ["p2", p2] as const].map(([key, pt]) => (
<g key={key}>
<circle
cx={pt.x}
cy={pt.y}
r="14"
fill="transparent"
role="slider"
tabIndex={0}
aria-label={`${key === "p1" ? "First" : "Second"} bezier control point`}
aria-valuemin={0}
aria-valuemax={1}
aria-valuenow={key === "p1" ? x1 : x2}
aria-valuetext={`x ${key === "p1" ? x1 : x2}, y ${key === "p1" ? y1 : y2}`}
className="cursor-grab stroke-transparent outline-none active:cursor-grabbing focus-visible:stroke-white focus-visible:stroke-[2px]"
onPointerDown={(e) => handlePointerDown(key, e)}
onKeyDown={(event) => handleKeyDown(key, event)}
onPointerEnter={() => setHover(key)}
onPointerLeave={() => setHover((h) => (h === key ? null : h))}
/>
<circle
cx={pt.x}
cy={pt.y}
r={hover === key || draggingRef.current === key ? 7 : 5.5}
fill="#0a0a1a"
stroke={ACCENT}
strokeWidth="2.5"
className="pointer-events-none transition-[r]"
/>
</g>
))}
</svg>
</div>
<EaseParameterField
springBounce={springBounce}
wiggleConfig={wiggleConfig}
tuple={displayTuple}
onCommit={onCustomEaseCommit}
/>
</>
) : (
<p className="px-0.5 py-1.5 text-[10px] leading-relaxed text-neutral-500">
{label} preset: switch to Curve, Spring, or Wiggle above to shape it by hand.
</p>
)}
</div>
);
}
@@ -0,0 +1,188 @@
// @vitest-environment happy-dom
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { parseWiggleEase } from "@hyperframes/core/wiggle-ease";
import { afterEach, describe, expect, it, vi } from "vitest";
import { EaseBezierField, SpringBounceField, WiggleField } from "./EaseParamFields";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const roots: Root[] = [];
afterEach(() => {
act(() => roots.splice(0).forEach((root) => root.unmount()));
document.body.innerHTML = "";
});
function renderField(field: React.ReactNode): HTMLElement {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
roots.push(root);
act(() => root.render(field));
return host;
}
function parsedWiggle(ease: string) {
const config = parseWiggleEase(ease);
expect(config).not.toBeNull();
if (!config) throw new Error(`Expected a valid wiggle ease: ${ease}`);
return config;
}
function expectWiggleCommit(onCommit: ReturnType<typeof vi.fn>, ease: string): void {
expect(onCommit).toHaveBeenLastCalledWith(ease);
const emitted = onCommit.mock.lastCall?.[0];
expect(typeof emitted === "string" ? parseWiggleEase(emitted) : null).not.toBeNull();
}
function inputValue(input: HTMLInputElement, value: string): void {
act(() => {
const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
valueSetter?.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
});
}
function commitInputValue(input: HTMLInputElement, value: string): void {
act(() => input.focus());
inputValue(input, value);
act(() => input.blur());
}
// Render a default wiggle field, type `value` into the input labelled `label`,
// and return the commit spy — the shared body of the input-edit cases.
function editWiggle(label: string, value: string): ReturnType<typeof vi.fn> {
const onCommit = vi.fn();
const host = renderField(
<WiggleField config={parsedWiggle("wiggle(6,easeOut,0.26)")} onCommit={onCommit} />,
);
const input = host.querySelector<HTMLInputElement>(`[aria-label="${label}"]`);
expect(input).not.toBeNull();
if (input) commitInputValue(input, value);
return onCommit;
}
describe("WiggleField", () => {
it("reflects a parsed wiggle config", () => {
const host = renderField(
<WiggleField config={parsedWiggle("wiggle(6,easeOut,0.26)")} onCommit={vi.fn()} />,
);
expect(host.querySelector<HTMLInputElement>('[aria-label="Wiggle count"]')?.value).toBe("6");
expect(host.querySelector<HTMLSelectElement>('[aria-label="Wiggle type"]')?.value).toBe(
"easeOut",
);
expect(host.querySelector<HTMLInputElement>('[aria-label="Wiggle amplitude"]')?.value).toBe(
"0.26",
);
});
it("commits an edited count", () => {
expectWiggleCommit(editWiggle("Wiggle count", "4"), "wiggle(4,easeOut,0.26)");
});
it("commits an edited type", () => {
const onCommit = vi.fn();
const host = renderField(
<WiggleField config={parsedWiggle("wiggle(6,easeOut,0.26)")} onCommit={onCommit} />,
);
const select = host.querySelector<HTMLSelectElement>('[aria-label="Wiggle type"]');
expect(select).not.toBeNull();
act(() => {
if (!select) return;
select.value = "anticipate";
select.dispatchEvent(new Event("change", { bubbles: true }));
});
expectWiggleCommit(onCommit, "wiggle(6,anticipate,0.26)");
});
it("commits an edited amplitude", () => {
expectWiggleCommit(editWiggle("Wiggle amplitude", "0.1"), "wiggle(6,easeOut,0.1)");
});
it("seeds and explicitly commits the per-type default amplitude", () => {
const onCommit = vi.fn();
const host = renderField(
<WiggleField config={parsedWiggle("wiggle(3,easeInOut)")} onCommit={onCommit} />,
);
expect(host.querySelector<HTMLInputElement>('[aria-label="Wiggle amplitude"]')?.value).toBe(
"0.08",
);
const count = host.querySelector<HTMLInputElement>('[aria-label="Wiggle count"]');
expect(count).not.toBeNull();
if (count) commitInputValue(count, "4");
expectWiggleCommit(onCommit, "wiggle(4,easeInOut,0.08)");
});
it("ignores an empty amplitude", () => {
expect(editWiggle("Wiggle amplitude", "")).not.toHaveBeenCalled();
});
it("rejects a count below one", () => {
expect(editWiggle("Wiggle count", "0")).not.toHaveBeenCalled();
});
});
describe("moved ease parameter fields", () => {
it("keeps the spring bounce commit behavior", () => {
const onCommit = vi.fn();
const host = renderField(<SpringBounceField springBounce={0.5} onCommit={onCommit} />);
const input = host.querySelector<HTMLInputElement>('[aria-label="Spring bounce"]');
expect(input).not.toBeNull();
if (input) commitInputValue(input, "0.333");
expect(onCommit).toHaveBeenLastCalledWith("spring(0.33)");
});
it("commits a numeric draft exactly once when Enter blurs the field", () => {
const onCommit = vi.fn();
const host = renderField(<SpringBounceField springBounce={0.5} onCommit={onCommit} />);
const input = host.querySelector<HTMLInputElement>('[aria-label="Spring bounce"]')!;
act(() => input.focus());
inputValue(input, "0.7");
act(() => input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })));
expect(onCommit).toHaveBeenCalledExactlyOnceWith("spring(0.7)");
});
it("keeps the cubic-bezier tuple commit behavior", () => {
const onCommit = vi.fn();
const host = renderField(<EaseBezierField tuple={[0.33, 0, 0.67, 1]} onCommit={onCommit} />);
const input = host.querySelector<HTMLInputElement>(
'[aria-label="Cubic bezier control points"]',
);
expect(input).not.toBeNull();
act(() => {
if (!input) return;
input.focus();
const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
valueSetter?.call(input, "0.111, 0.222, 0.777, 0.888");
input.dispatchEvent(new Event("input", { bubbles: true }));
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
});
expect(onCommit).toHaveBeenCalledExactlyOnceWith("custom(M0,0 C0.11,0.22 0.78,0.89 1,1)");
});
it("keeps the bezier input mounted and reports invalid values", () => {
const onCommit = vi.fn();
const host = renderField(<EaseBezierField tuple={[0.33, 0, 0.67, 1]} onCommit={onCommit} />);
const input = host.querySelector<HTMLInputElement>(
'[aria-label="Cubic bezier control points"]',
)!;
commitInputValue(input, "0.5, 1e9, 0.5, -1e9");
expect(input.isConnected).toBe(true);
expect(input.getAttribute("aria-invalid")).toBe("true");
expect(host.textContent).toContain("Y values must be between -1 and 2");
expect(onCommit).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,246 @@
import { useEffect, useId, useRef, useState } from "react";
import { parseSpringBounce } from "@hyperframes/core/spring-ease";
import {
parseWiggleEase,
type WiggleEaseConfig,
type WiggleType,
} from "@hyperframes/core/wiggle-ease";
import { roundToCenti } from "../../utils/rounding";
import { MiniCurveSvg } from "./easeCurveSvg";
type Pts = [number, number, number, number];
const round2 = roundToCenti;
const BEZIER_Y_MIN = -1;
const BEZIER_Y_MAX = 2;
const WIGGLE_TYPES = ["easeOut", "easeInOut", "anticipate", "uniform"] as const;
const WIGGLE_DEFAULT_AMPLITUDE = {
easeOut: 0.16,
easeInOut: 0.08,
anticipate: 0.12,
uniform: 0.14,
} satisfies Record<WiggleType, number>;
function isWiggleType(value: string): value is WiggleType {
return WIGGLE_TYPES.some((type) => type === value);
}
function commitWiggle(
onCommit: (ease: string) => void,
count: number,
type: WiggleType,
amplitude: number,
): void {
const ease = `wiggle(${count},${type},${roundToCenti(amplitude)})`;
if (parseWiggleEase(ease)) onCommit(ease);
}
// Editable cubic-bezier control points, Figma-style ("0.33, 0, 0, 1").
export function EaseBezierField({
tuple,
onCommit,
}: {
tuple: Pts;
onCommit: (ease: string) => void;
}) {
const text = tuple.join(", ");
const inputRef = useRef<HTMLInputElement>(null);
const errorId = useId();
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (inputRef.current) inputRef.current.value = text;
setError(null);
}, [text]);
const commit = (raw: string) => {
const tokens = raw.trim().split(/[\s,]+/);
const nums = tokens.map(Number);
if (tokens.length !== 4 || nums.some((value) => !Number.isFinite(value))) {
setError("Enter four finite numbers");
return;
}
const [x1, y1, x2, y2] = nums as [number, number, number, number];
if (y1 < BEZIER_Y_MIN || y1 > BEZIER_Y_MAX || y2 < BEZIER_Y_MIN || y2 > BEZIER_Y_MAX) {
setError(`Y values must be between ${BEZIER_Y_MIN} and ${BEZIER_Y_MAX}`);
return;
}
const cx = (v: number) => Math.max(0, Math.min(1, v));
setError(null);
onCommit(`custom(M0,0 C${round2(cx(x1))},${round2(y1)} ${round2(cx(x2))},${round2(y2)} 1,1)`);
};
return (
<div className="mt-1.5 px-0.5">
<div className="flex items-center gap-1.5">
<MiniCurveSvg
ease={`custom(M0,0 C${tuple[0]},${tuple[1]} ${tuple[2]},${tuple[3]} 1,1)`}
active
size={14}
/>
<input
ref={inputRef}
type="text"
defaultValue={text}
aria-label="Cubic bezier control points"
aria-invalid={error !== null}
aria-describedby={error ? errorId : undefined}
onInput={() => setError(null)}
onKeyDown={(event) => {
if (event.key === "Enter") event.currentTarget.blur();
if (event.key === "Escape") {
event.currentTarget.value = text;
setError(null);
}
}}
onBlur={(event) => commit(event.currentTarget.value)}
className={`w-full rounded border bg-black/20 px-1.5 py-1 font-mono text-[10px] text-neutral-300 outline-none ${
error
? "border-red-500/70 focus:border-red-400"
: "border-white/10 focus:border-panel-accent/50"
}`}
/>
</div>
<p
id={errorId}
aria-live="polite"
className={`mt-1 text-[9px] text-red-400 ${error ? "" : "sr-only"}`}
>
{error ?? "Valid bezier values"}
</p>
</div>
);
}
function NumericCommitInput({
label,
value,
min,
max,
step,
className,
onCommit,
}: {
label: string;
value: number;
min: number;
max?: number;
step: number;
className: string;
onCommit: (value: number) => void;
}) {
const [draft, setDraft] = useState(String(value));
useEffect(() => setDraft(String(value)), [value]);
const commit = () => {
if (draft.trim() === "") {
setDraft(String(value));
return;
}
const next = Number(draft);
if (!Number.isFinite(next) || next < min || (max !== undefined && next > max)) {
setDraft(String(value));
return;
}
onCommit(next);
};
return (
<input
type="number"
aria-label={label}
min={min}
max={max}
step={step}
value={draft}
onChange={(event) => setDraft(event.currentTarget.value)}
onBlur={commit}
onKeyDown={(event) => {
if (event.key === "Enter") event.currentTarget.blur();
if (event.key === "Escape") {
setDraft(String(value));
}
}}
className={className}
/>
);
}
export function SpringBounceField({
springBounce,
onCommit,
}: {
springBounce: number;
onCommit: (ease: string) => void;
}) {
return (
<div className="mt-1.5 flex items-center gap-2 px-0.5 text-[10px] text-neutral-400">
<span aria-hidden="true">Bounce</span>
<NumericCommitInput
label="Spring bounce"
value={springBounce}
min={0}
max={1}
step={0.01}
onCommit={(value) => {
const bounce = parseSpringBounce(`spring(${value})`);
if (bounce !== null) onCommit(`spring(${round2(bounce)})`);
}}
className="w-16 rounded border border-white/10 bg-black/20 px-1.5 py-1 font-mono text-[10px] text-neutral-300 outline-none focus:border-panel-accent/50"
/>
</div>
);
}
export function WiggleField({
config,
onCommit,
}: {
config: WiggleEaseConfig;
onCommit: (ease: string) => void;
}) {
const amplitude = config.amplitude ?? WIGGLE_DEFAULT_AMPLITUDE[config.type];
return (
<div className="mt-1.5 flex items-center gap-2 px-0.5 text-[10px] text-neutral-400">
<div className="flex items-center gap-1">
<span aria-hidden="true">Count</span>
<NumericCommitInput
label="Wiggle count"
value={config.wiggles}
min={1}
step={1}
onCommit={(value) => commitWiggle(onCommit, value, config.type, amplitude)}
className="w-14 rounded border border-white/10 bg-black/20 px-1.5 py-1 font-mono text-[10px] text-neutral-300 outline-none focus:border-panel-accent/50"
/>
</div>
<div className="flex items-center gap-1">
<span aria-hidden="true">Type</span>
<select
aria-label="Wiggle type"
value={config.type}
onChange={(event) => {
if (isWiggleType(event.currentTarget.value)) {
commitWiggle(onCommit, config.wiggles, event.currentTarget.value, amplitude);
}
}}
className="rounded border border-white/10 bg-black/20 px-1.5 py-1 text-[10px] text-neutral-300 outline-none focus:border-panel-accent/50"
>
{WIGGLE_TYPES.map((type) => (
<option key={type} value={type}>
{type}
</option>
))}
</select>
</div>
<div className="flex items-center gap-1">
<span aria-hidden="true">Amplitude</span>
<NumericCommitInput
label="Wiggle amplitude"
value={amplitude}
min={0}
max={1}
step={0.01}
onCommit={(value) => commitWiggle(onCommit, config.wiggles, config.type, value)}
className="w-16 rounded border border-white/10 bg-black/20 px-1.5 py-1 font-mono text-[10px] text-neutral-300 outline-none focus:border-panel-accent/50"
/>
</div>
</div>
);
}
@@ -0,0 +1,65 @@
import { evaluateWiggleEase, parseWiggleEase } from "@hyperframes/core/wiggle-ease";
import { evaluateSpringEase, parseSpringBounce } from "@hyperframes/core/spring-ease";
import { resolveEaseCurveTuple } from "./gsapAnimationConstants";
export function sampledPath(
samples: number,
x: (progress: number) => number,
y: (value: number) => number,
evaluate: (progress: number) => number,
): string {
return Array.from({ length: samples + 1 }, (_, index) => {
const progress = index / samples;
return `${index === 0 ? "M" : "L"}${x(progress)},${y(evaluate(progress))}`;
}).join(" ");
}
export function holdCurvePath(left: number, bottom: number, right: number, top: number): string {
return `M${left},${bottom} L${right},${bottom} L${right},${top}`;
}
export function MiniCurveSvg({
ease,
active,
size = 24,
}: {
ease: string;
active: boolean;
size?: number;
}) {
const springBounce = parseSpringBounce(ease);
const wiggle = parseWiggleEase(ease);
const curve = resolveEaseCurveTuple(ease);
const [x1, y1, x2, y2] = curve;
const s = size;
const p = size / 8;
const g = s - p * 2;
const sx = (px: number) => p + g * px;
const sy = (py: number) => s - p - g * py;
const d =
ease === "hold"
? holdCurvePath(p, s - p, s - p, p)
: wiggle
? sampledPath(64, sx, sy, (progress) =>
evaluateWiggleEase(progress, wiggle.wiggles, wiggle.type, wiggle.amplitude),
)
: springBounce !== null
? sampledPath(
24,
sx,
(value) => sy(value / 1.2),
(progress) => evaluateSpringEase(progress, springBounce),
)
: `M${p},${s - p} C${sx(x1)},${sy(y1)} ${sx(x2)},${sy(y2)} ${s - p},${p}`;
return (
<svg width={s} height={s} viewBox={`0 0 ${s} ${s}`}>
<path
d={d}
fill="none"
stroke={active ? "#3CE6AC" : "#737373"}
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
);
}
@@ -0,0 +1,114 @@
// Imports the parsers SOURCE (not the published subpath) so newly-added eases
// like circ.inOut resolve before the parsers dist is rebuilt.
import { SUPPORTED_EASES } from "../../../../parsers/src/gsapConstants";
import { parseSpringBounce } from "@hyperframes/core/spring-ease";
import { parseWiggleEase } from "@hyperframes/core/wiggle-ease";
import { describe, expect, it } from "vitest";
import { EASE_PRESETS, easePresetLabel } from "./easePresetLibrary";
import { resolveEaseCurveTuple } from "./gsapAnimationConstants";
const CUSTOM_EASE_PATTERN =
/^custom\(M0,0 C-?\d+(?:\.\d+)?,-?\d+(?:\.\d+)? -?\d+(?:\.\d+)?,-?\d+(?:\.\d+)? 1,1\)$/;
describe("EASE_PRESETS", () => {
it("contains the complete 32-preset Graphs library with unique fields", () => {
expect(EASE_PRESETS).toHaveLength(32);
expect(new Set(EASE_PRESETS.map(({ id }) => id))).toHaveLength(32);
expect(new Set(EASE_PRESETS.map(({ label }) => label))).toHaveLength(32);
expect(new Set(EASE_PRESETS.map(({ ease }) => ease))).toHaveLength(32);
});
it("preserves the required standard, Flow, and Bounce mappings", () => {
const easeByLabel = Object.fromEntries(EASE_PRESETS.map(({ label, ease }) => [label, ease]));
expect(easeByLabel).toMatchObject({
Linear: "none",
"Ease In": "power1.in",
"Quad In": "power2.in",
"Cubic In": "power3.in",
"Ease Out": "power1.out",
"Quad Out": "power2.out",
"Cubic Out": "power3.out",
"Ease In & Out": "power1.inOut",
"Quad Ease": "power2.inOut",
"Cubic Ease": "power3.inOut",
"Circular Ease": "circ.inOut",
"Ease In Back": "back.in",
"Ease Out Back": "back.out",
"Flow 1": "wiggle(1,easeInOut,0.20)",
"Flow 2": "wiggle(2,easeInOut,0.15)",
"Flow 3": "wiggle(3,easeInOut,0.12)",
"Flow 4": "wiggle(4,easeInOut,0.10)",
"Flow 5": "wiggle(5,easeInOut,0.08)",
"Flow 6": "wiggle(6,easeInOut,0.07)",
"Flow 7": "wiggle(7,easeInOut,0.06)",
"Bounce 1": "wiggle(4,easeOut,0.22)",
"Bounce 2": "wiggle(6,easeOut,0.26)",
"Bounce 3": "wiggle(9,uniform,0.32)",
"Bounce 4": "wiggle(5,anticipate,0.28)",
Hold: "hold",
});
const flows = EASE_PRESETS.filter(({ id }) => id.startsWith("flow-"));
expect(flows.map(({ label }) => label)).toEqual([
"Flow 1",
"Flow 2",
"Flow 3",
"Flow 4",
"Flow 5",
"Flow 6",
"Flow 7",
]);
expect(flows.every(({ ease }) => parseWiggleEase(ease) !== null)).toBe(true);
});
it("uses supported or valid custom eases that all resolve to finite geometry", () => {
for (const preset of EASE_PRESETS) {
expect(
SUPPORTED_EASES.includes(preset.ease as (typeof SUPPORTED_EASES)[number]) ||
CUSTOM_EASE_PATTERN.test(preset.ease) ||
parseSpringBounce(preset.ease) !== null ||
parseWiggleEase(preset.ease) !== null,
`${preset.label} has unsupported ease ${preset.ease}`,
).toBe(true);
expect(resolveEaseCurveTuple(preset.ease).every(Number.isFinite)).toBe(true);
}
});
it("assigns every preset a valid kind", () => {
const kinds = new Set(["curve", "spring", "wiggle"]);
for (const preset of EASE_PRESETS) {
expect(kinds.has(preset.kind)).toBe(true);
}
});
it("uses valid bounce values for spring presets", () => {
for (const preset of EASE_PRESETS.filter(({ kind }) => kind === "spring")) {
const bounce = parseSpringBounce(preset.ease);
expect(bounce).not.toBeNull();
expect(bounce).toBeGreaterThanOrEqual(0);
expect(bounce).toBeLessThanOrEqual(1);
}
});
it("uses parseable eases for wiggle presets", () => {
for (const preset of EASE_PRESETS.filter(({ kind }) => kind === "wiggle")) {
expect(parseWiggleEase(preset.ease)).not.toBeNull();
}
});
it("preserves stable preset ids", () => {
for (const id of ["flow-7", "hold", "rebound-in"]) {
expect(EASE_PRESETS.some((preset) => preset.id === id)).toBe(true);
}
});
it("resolves preset labels by exact ease", () => {
expect(easePresetLabel("spring(0.6)")).toBe("Bouncy");
expect(easePresetLabel("back.in")).toBe("Ease In Back");
expect(easePresetLabel("wiggle(9,uniform,0.32)")).toBe("Bounce 3");
expect(easePresetLabel("custom(x)")).toBeNull();
});
});
@@ -0,0 +1,49 @@
export const EASE_PRESETS = [
{ id: "linear", label: "Linear", ease: "none", kind: "curve" },
{ id: "ease-in", label: "Ease In", ease: "power1.in", kind: "curve" },
{ id: "quad-in", label: "Quad In", ease: "power2.in", kind: "curve" },
{ id: "cubic-in", label: "Cubic In", ease: "power3.in", kind: "curve" },
{ id: "ease-out", label: "Ease Out", ease: "power1.out", kind: "curve" },
{ id: "quad-out", label: "Quad Out", ease: "power2.out", kind: "curve" },
{ id: "cubic-out", label: "Cubic Out", ease: "power3.out", kind: "curve" },
{ id: "ease", label: "Ease In & Out", ease: "power1.inOut", kind: "curve" },
{ id: "quad-ease", label: "Quad Ease", ease: "power2.inOut", kind: "curve" },
{ id: "cubic-ease", label: "Cubic Ease", ease: "power3.inOut", kind: "curve" },
{ id: "circular-ease", label: "Circular Ease", ease: "circ.inOut", kind: "curve" },
{ id: "rebound-in", label: "Ease In Back", ease: "back.in", kind: "curve" },
{ id: "rebound-out", label: "Ease Out Back", ease: "back.out", kind: "curve" },
{ id: "flow-1", label: "Flow 1", ease: "wiggle(1,easeInOut,0.20)", kind: "wiggle" },
{ id: "flow-2", label: "Flow 2", ease: "wiggle(2,easeInOut,0.15)", kind: "wiggle" },
{ id: "flow-3", label: "Flow 3", ease: "wiggle(3,easeInOut,0.12)", kind: "wiggle" },
{ id: "flow-4", label: "Flow 4", ease: "wiggle(4,easeInOut,0.10)", kind: "wiggle" },
{ id: "flow-5", label: "Flow 5", ease: "wiggle(5,easeInOut,0.08)", kind: "wiggle" },
{ id: "flow-6", label: "Flow 6", ease: "wiggle(6,easeInOut,0.07)", kind: "wiggle" },
{ id: "flow-7", label: "Flow 7", ease: "wiggle(7,easeInOut,0.06)", kind: "wiggle" },
{ id: "bounce-1", label: "Bounce 1", ease: "wiggle(4,easeOut,0.22)", kind: "wiggle" },
{ id: "bounce-2", label: "Bounce 2", ease: "wiggle(6,easeOut,0.26)", kind: "wiggle" },
{ id: "bounce-3", label: "Bounce 3", ease: "wiggle(9,uniform,0.32)", kind: "wiggle" },
{ id: "bounce-4", label: "Bounce 4", ease: "wiggle(5,anticipate,0.28)", kind: "wiggle" },
{ id: "hold", label: "Hold", ease: "hold", kind: "curve" },
{
id: "rebound-ease",
label: "Ease In & Out Back",
ease: "back.inOut",
kind: "curve",
},
{ id: "expo-in", label: "Expo In", ease: "expo.in", kind: "curve" },
{ id: "expo-out", label: "Expo Out", ease: "expo.out", kind: "curve" },
// Runtime spring(bounce) approximates Figma stiffness and damping with bounce alone.
{ id: "spring-gentle", label: "Gentle", ease: "spring(0.15)", kind: "spring" },
{ id: "spring-quick", label: "Quick", ease: "spring(0.4)", kind: "spring" },
{ id: "spring-bouncy", label: "Bouncy", ease: "spring(0.6)", kind: "spring" },
{ id: "spring-slow", label: "Slow", ease: "spring(0.25)", kind: "spring" },
] as const satisfies ReadonlyArray<{
id: string;
label: string;
ease: string;
kind: "curve" | "spring" | "wiggle";
}>;
export function easePresetLabel(ease: string): string | null {
return EASE_PRESETS.find((preset) => preset.ease === ease)?.label ?? null;
}
@@ -1,4 +1,4 @@
import { controlPointsForGsapEase } from "./studioMotion";
import { controlPointsForGsapEase, parseStudioCustomEaseData } from "./studioMotion";
export const METHOD_LABELS: Record<string, string> = {
set: "Set",
@@ -116,9 +116,14 @@ export const EASE_CURVES: Record<string, [number, number, number, number]> = {
"back.out": [0.34, 1.56, 0.64, 1],
"back.in": [0.36, 0, 0.66, -0.56],
"back.inOut": [0.68, -0.55, 0.27, 1.55],
"circ.inOut": [0.785, 0.135, 0.15, 0.86],
"expo.out": [0.16, 1, 0.3, 1],
"expo.in": [0.7, 0, 0.84, 0],
"expo.inOut": [0.87, 0, 0.13, 1],
"bounce.out": [0.34, 1.56, 0.64, 0.74],
"bounce.in": [0.36, 0.26, 0.66, -0.56],
"elastic.out(1,0.3)": [0.16, 1.45, 0.28, 0.82],
"elastic.inOut(1,0.3)": [0.68, -0.55, 0.32, 1.55],
// After Effects polarity: "in" eases into the keyframe (slow END, CP2 y=1),
// "out" eases out of it (slow START, CP1 y=0). Matches the "(AE)" labels.
"ae-ease": [0.333, 0, 0.667, 1],
@@ -126,18 +131,15 @@ export const EASE_CURVES: Record<string, [number, number, number, number]> = {
"ae-ease-out": [0.333, 0, 0.667, 0.667],
};
export function parseCustomEaseFromString(ease: string): {
x1: number;
y1: number;
x2: number;
y2: number;
} {
const match = ease.match(/^custom\((.+)\)$/);
if (!match) return controlPointsForGsapEase("power2.out");
const data = match[1];
const nums = data.match(/[\d.]+/g)?.map(Number);
if (!nums || nums.length < 6) return controlPointsForGsapEase("power2.out");
return { x1: nums[2], y1: nums[3], x2: nums[4], y2: nums[5] };
export function resolveEaseCurveTuple(ease: string): [number, number, number, number] {
if (ease.startsWith("custom(")) {
const points = parseStudioCustomEaseData(ease.match(/^custom\((.+)\)$/)?.[1]);
if (points) return [points.x1, points.y1, points.x2, points.y2];
}
const curve = EASE_CURVES[ease];
if (curve) return curve;
const points = controlPointsForGsapEase(ease);
return [points.x1, points.y1, points.x2, points.y2];
}
export const PERCENT_PROPS = new Set(["opacity", "autoAlpha"]);