mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 07:19:52 +00:00
feat(studio): add keyframe ease editor
This commit is contained in:
@@ -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"]);
|
||||
|
||||
Reference in New Issue
Block a user