Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/core/package-subpaths.json
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,12 @@
"types": "./dist/parsers/springEase.d.ts",
"environments": ["browser", "bun", "node"]
},
"./wiggle-ease": {
"source": "./src/runtime/wiggleEase.ts",
"runtime": "./dist/runtime/wiggleEase.js",
"types": "./dist/runtime/wiggleEase.d.ts",
"environments": ["browser", "bun", "node"]
},
"./fonts/aliases": {
"source": "./src/fonts/aliases.ts",
"runtime": "./dist/fonts/aliases.js",
Expand Down
10 changes: 10 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,12 @@
"import": "./src/parsers/springEase.ts",
"types": "./src/parsers/springEase.ts"
},
"./wiggle-ease": {
"bun": "./src/runtime/wiggleEase.ts",
"node": "./dist/runtime/wiggleEase.js",
"import": "./src/runtime/wiggleEase.ts",
"types": "./src/runtime/wiggleEase.ts"
},
"./fonts/aliases": {
"bun": "./src/fonts/aliases.ts",
"node": "./dist/fonts/aliases.js",
Expand Down Expand Up @@ -429,6 +435,10 @@
"import": "./dist/parsers/springEase.js",
"types": "./dist/parsers/springEase.d.ts"
},
"./wiggle-ease": {
"import": "./dist/runtime/wiggleEase.js",
"types": "./dist/runtime/wiggleEase.d.ts"
},
"./fonts/aliases": {
"import": "./dist/fonts/aliases.js",
"types": "./dist/fonts/aliases.d.ts"
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/figma/motionEase.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ describe("mapEase", () => {
ease: "power2.inOut",
});
expect(mapEase("backOut")).toEqual({ kind: "named", ease: "back.out" });
expect(mapEase("HOLD")).toEqual({ kind: "named", ease: "steps(1)" });
expect(mapEase("HOLD")).toEqual({ kind: "named", ease: "hold" });
});
it("falls back to none for unknown named eases", () => {
expect(mapEase("wobble")).toEqual({ kind: "named", ease: "none" });
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/figma/motionEase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ const NAMED_EASE: Record<string, string> = {
elasticinout: "elastic.inOut",
anticipate: "back.in",
spring: "elastic.out",
hold: "steps(1)",
hold: "hold",
};

function isBezier4(ease: unknown[]): ease is [number, number, number, number] {
Expand Down
1 change: 0 additions & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,6 @@ export {
keyframesToGsapAnimations,
gsapAnimationsToKeyframes,
} from "@hyperframes/parsers";

export type { ParsedHtml, CompositionMetadata } from "@hyperframes/parsers";

export {
Expand Down
29 changes: 29 additions & 0 deletions packages/core/src/parsers/springEase.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { evaluateSpringEase, parseSpringBounce } from "./springEase";

describe("single-parameter spring ease", () => {
it("parses and clamps the bounce parameter", () => {
expect(parseSpringBounce("spring(0.5)")).toBe(0.5);
expect(parseSpringBounce(" spring(2) ")).toBe(1);
expect(parseSpringBounce("spring(-1)")).toBe(0);
expect(parseSpringBounce("spring(nope)")).toBeNull();
});

it("starts at zero, overshoots, and settles exactly at one", () => {
const samples = Array.from({ length: 101 }, (_, index) => evaluateSpringEase(index / 100, 0.5));
expect(samples[0]).toBe(0);
expect(samples.at(-1)).toBe(1);
expect(Math.max(...samples)).toBeGreaterThan(1);
});

it("is deterministic and makes higher bounce values more oscillatory", () => {
const progress = Array.from({ length: 41 }, (_, index) => index / 40);
expect(progress.map((value) => evaluateSpringEase(value, 0.75))).toEqual(
progress.map((value) => evaluateSpringEase(value, 0.75)),
);

const lowBounce = progress.map((value) => evaluateSpringEase(value, 0.25));
const highBounce = progress.map((value) => evaluateSpringEase(value, 0.75));
expect(Math.max(...highBounce)).toBeGreaterThan(Math.max(...lowBounce));
});
});
30 changes: 29 additions & 1 deletion packages/core/src/parsers/springEase.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,30 @@
/** @deprecated Import from @hyperframes/parsers/spring-ease */
// Preserve the legacy symbols (SpringPreset, SPRING_PRESETS, generateSpringEaseData)
// on the still-published ./spring-ease subpath. Dropping them is a breaking change
// for external importers; the canonical source stays @hyperframes/parsers/spring-ease.
export * from "@hyperframes/parsers/spring-ease";

const SPRING_TOKEN = /^\s*spring\(\s*([+-]?(?:\d+(?:\.\d*)?|\.\d+))\s*\)\s*$/;

function clampBounce(bounce: number): number {
return Math.max(0, Math.min(1, bounce));
}

/** Parse Studio's single-parameter spring token into a normalized bounce value. */
export function parseSpringBounce(ease: string): number | null {
const match = SPRING_TOKEN.exec(ease);
if (!match) return null;
const bounce = Number(match[1]);
return Number.isFinite(bounce) ? clampBounce(bounce) : null;
}

/** Evaluate Studio's deterministic, endpoint-normalized damped-cosine spring. */
export function evaluateSpringEase(progress: number, bounce: number): number {
if (progress <= 0) return 0;
if (progress >= 1) return 1;

const normalizedBounce = clampBounce(bounce);
const decay = 12 - normalizedBounce * 6;
const angularFrequency = Math.PI * 2 * (1 + normalizedBounce * 1.5);
const endpoint = 1 - Math.exp(-decay) * Math.cos(angularFrequency);
return (1 - Math.exp(-decay * progress) * Math.cos(angularFrequency * progress)) / endpoint;
}
67 changes: 67 additions & 0 deletions packages/core/src/runtime/customEase.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { describe, expect, it, vi } from "vitest";
import { installStudioCustomEase } from "./customEase";

describe("installStudioCustomEase", () => {
it("resolves wiggle while preserving hold, spring, custom, and named eases", () => {
const namedEase = (progress: number) => progress * progress;
const originalParseEase = vi.fn(() => namedEase);
const gsap = { parseEase: originalParseEase };

expect(installStudioCustomEase(gsap)).toBe(true);

const wiggleEase = gsap.parseEase("wiggle(6,easeInOut)");
expect(wiggleEase).toBeTypeOf("function");
const samples = Array.from({ length: 401 }, (_, index) => wiggleEase(index / 400));
expect(samples[0]).toBe(0);
expect(samples.at(-1)).toBe(1);
expect(samples).toEqual(Array.from({ length: 401 }, (_, index) => wiggleEase(index / 400)));
const directions = samples
.slice(1)
.map((value, index) => Math.sign(value - samples[index]!))
.filter((direction) => direction !== 0);
expect(
directions.filter((direction, index) => index > 0 && direction !== directions[index - 1])
.length,
).toBeGreaterThanOrEqual(8);

expect(gsap.parseEase("hold")(0.5)).toBe(0);
expect(gsap.parseEase("spring(0.5)")(0)).toBe(0);
expect(gsap.parseEase("custom(M0,0 C0.25,0.1 0.25,1 1,1)")(1)).toBe(1);
expect(gsap.parseEase("power2.out")).toBe(namedEase);
expect(originalParseEase).toHaveBeenCalledTimes(1);
});

it("registers custom eases in GSAP's internal ease map for keyframe-segment resolution", () => {
// GSAP resolves keyframe SEGMENT eases via its internal _parseEase/_easeMap,
// not the public parseEase — so the eases must be registered there too, else
// a custom ease inside `keyframes:{...}` resolves to undefined and throws
// "_ease is not a function" on first render.
const easeMap = new Map<string, ((progress: number) => number) & { config?: unknown }>();
const gsap = {
parseEase: vi.fn((ease: unknown) => (typeof ease === "function" ? ease : null)),
registerEase: (name: string, ease: (progress: number) => number) => easeMap.set(name, ease),
};

expect(installStudioCustomEase(gsap)).toBe(true);

// Bare hold registers directly and holds at 0 until the end.
expect(easeMap.get("hold")?.(0.5)).toBe(0);
expect(easeMap.get("hold")?.(1)).toBe(1);

// GSAP calls a configurable ease's `.config` with the parenthesized params
// comma-split (custom's bezier path splits into several parts). The config
// must rejoin them and resolve a real ease.
const springConfig = easeMap.get("spring")?.config as (
...p: unknown[]
) => (n: number) => number;
expect(springConfig(0.5)(0)).toBe(0);

const customConfig = easeMap.get("custom")?.config as (
...p: unknown[]
) => (n: number) => number;
// "custom(M0,0 C0.25,0.1 0.25,1 1,1)" → params split on "," by GSAP:
const customEase = customConfig("M0", "0 C0.25", "0.1 0.25", "1 1", 1);
expect(customEase(0)).toBe(0);
expect(customEase(1)).toBe(1);
});
});
131 changes: 131 additions & 0 deletions packages/core/src/runtime/customEase.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { evaluateSpringEase, parseSpringBounce } from "../parsers/springEase";
import { resolveWiggleEase } from "./wiggleEase";

type RuntimeEase = (progress: number) => number;

type ConfigurableEase = RuntimeEase & { config?: (...params: unknown[]) => RuntimeEase };

type GsapEaseApi = {
parseEase?: (ease: string | RuntimeEase, ...args: unknown[]) => RuntimeEase | null;
registerEase?: (name: string, ease: RuntimeEase) => void;
};

const BISECTION_STEPS = 24;
const HOLD_EASE: RuntimeEase = (progress) => (progress >= 1 ? 1 : 0);
const NUMBER_SOURCE = String.raw`([+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?)`;
const CUSTOM_CUBIC_PATH = new RegExp(
String.raw`^\s*M\s*0\s*,\s*0\s+C\s*${NUMBER_SOURCE}\s*,\s*${NUMBER_SOURCE}\s+${NUMBER_SOURCE}\s*,\s*${NUMBER_SOURCE}\s+1\s*,\s*1\s*$`,
"i",
);

function cubicCoordinate(t: number, point1: number, point2: number): number {
const inverse = 1 - t;
return 3 * inverse * inverse * t * point1 + 3 * inverse * t * t * point2 + t * t * t;
}

function evaluateCubicBezier(
progress: number,
x1: number,
y1: number,
x2: number,
y2: number,
): number {
if (progress <= 0) return 0;
if (progress >= 1) return 1;

let low = 0;
let high = 1;
for (let step = 0; step < BISECTION_STEPS; step += 1) {
const t = (low + high) / 2;
if (cubicCoordinate(t, x1, x2) < progress) low = t;
else high = t;
}
return cubicCoordinate((low + high) / 2, y1, y2);
}

function createCubicBezierEase(path: string): RuntimeEase | null {
const match = CUSTOM_CUBIC_PATH.exec(path);
if (!match) return null;
const x1 = Number(match[1]);
const y1 = Number(match[2]);
const x2 = Number(match[3]);
const y2 = Number(match[4]);
if (Math.min(x1, x2) < 0 || Math.max(x1, x2) > 1) return null;
return (progress) => evaluateCubicBezier(progress, x1, y1, x2, y2);
}

function resolveSpringEase(
ease: string,
springEaseCache: Map<number, RuntimeEase>,
): RuntimeEase | null {
if (!ease.startsWith("spring(")) return null;
const bounce = parseSpringBounce(ease);
if (bounce === null) return null;
const cached = springEaseCache.get(bounce);
if (cached) return cached;
const springEase: RuntimeEase = (progress) => evaluateSpringEase(progress, bounce);
springEaseCache.set(bounce, springEase);
return springEase;
}

function resolveCustomEase(
ease: string,
customEaseCache: Map<string, RuntimeEase>,
): RuntimeEase | null {
if (!ease.startsWith("custom(") || !ease.endsWith(")")) return null;
const path = ease.slice(7, -1);
const cached = customEaseCache.get(path);
if (cached) return cached;
const customEase = createCubicBezierEase(path);
if (customEase) customEaseCache.set(path, customEase);
return customEase;
}

export function installStudioCustomEase(gsap: GsapEaseApi): boolean {
const originalParseEase = gsap.parseEase;
if (!originalParseEase) return false;

const customEaseCache = new Map<string, RuntimeEase>();
const springEaseCache = new Map<number, RuntimeEase>();

// Single source of truth for "hyperframes ease string -> function". Both the
// public parseEase override and the internal registerEase configs below route
// through this, so the resolution rules live in exactly one place.
const resolveHyperframesEase = (ease: string | RuntimeEase): RuntimeEase | null => {
if (typeof ease !== "string") return null;
if (ease === "hold") return HOLD_EASE;
return (
resolveWiggleEase(ease) ??
resolveSpringEase(ease, springEaseCache) ??
resolveCustomEase(ease, customEaseCache)
);
};

// Public parseEase: direct callers (studio ease UI, adapters) resolve
// synchronously; anything else falls through to GSAP's own parser.
gsap.parseEase = function parseHyperframesEase(ease, ...args) {
return resolveHyperframesEase(ease) ?? originalParseEase.call(this, ease, ...args);
};

// GSAP resolves a keyframe SEGMENT's ease through its INTERNAL _parseEase /
// _easeMap, never the public parseEase above — so a custom ease used inside
// `keyframes:{...}` resolves to undefined and throws "_ease is not a function"
// on the first render. Register the eases in the internal map too. GSAP calls
// a configurable ease's `.config(...params)` with the parenthesized string
// comma-split, so `params.join(",")` losslessly reconstructs the original
// (including custom() bezier paths, whose commas survive the round-trip).
const registerEase = gsap.registerEase;
if (typeof registerEase === "function") {
registerEase("hold", HOLD_EASE);
const registerConfigurable = (name: string): void => {
const base: ConfigurableEase = (progress) => progress;
base.config = (...params) =>
resolveHyperframesEase(`${name}(${params.join(",")})`) ?? ((progress) => progress);
registerEase(name, base);
};
registerConfigurable("spring");
registerConfigurable("wiggle");
registerConfigurable("custom");
}
return true;
}
Loading
Loading