feat(core): GSAP keyframe parsing, mutations, and API routes (#1167)

This commit is contained in:
Miguel Ángel
2026-06-05 11:46:45 -04:00
committed by GitHub
parent 4b51cc6468
commit e639a1638f
5 changed files with 968 additions and 11 deletions
+24 -2
View File
@@ -6,17 +6,34 @@
*/
export const SUPPORTED_PROPS = [
"opacity",
"visibility",
// Transforms
"x",
"y",
"scale",
"scaleX",
"scaleY",
"rotation",
"skewX",
"skewY",
// Visibility
"opacity",
"visibility",
"autoAlpha",
// Dimensions
"width",
"height",
// Colors
"color",
"backgroundColor",
"borderColor",
// Box model
"borderRadius",
// Typography
"fontSize",
"letterSpacing",
// Filter & Clipping
"filter",
"clipPath",
];
export const SUPPORTED_EASES = [
@@ -45,4 +62,9 @@ export const SUPPORTED_EASES = [
"expo.in",
"expo.out",
"expo.inOut",
"spring-gentle",
"spring-bouncy",
"spring-stiff",
"spring-wobbly",
"spring-heavy",
];
@@ -11,6 +11,11 @@ import {
addAnimationToScript,
removeAnimationFromScript,
updateAnimationInScript,
addKeyframeToScript,
removeKeyframeFromScript,
updateKeyframeInScript,
convertToKeyframesInScript,
removeAllKeyframesFromScript,
} from "./gsapParser.js";
import type { GsapAnimation } from "./gsapParser.js";
import type { Keyframe } from "../core.types";
@@ -1185,3 +1190,315 @@ describe("fromTo in-place mutation", () => {
expect(reparsed.animations[0].properties.scale).toBe(2.2);
});
});
// ── Native GSAP keyframes parsing ──────────────────────────────────────────
describe("native GSAP keyframes parsing", () => {
it("parses percentage keyframes format", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#hero", {
keyframes: { "0%": { x: 0, opacity: 1 }, "50%": { x: 100, ease: "power2.out" }, "100%": { x: 200 } },
duration: 5
}, 0);
`;
const result = parseGsapScript(script);
expect(result.animations).toHaveLength(1);
const anim = result.animations[0];
expect(anim.keyframes).toBeDefined();
expect(anim.keyframes!.format).toBe("percentage");
expect(anim.keyframes!.keyframes).toHaveLength(3);
expect(anim.keyframes!.keyframes[0].percentage).toBe(0);
expect(anim.keyframes!.keyframes[0].properties.x).toBe(0);
expect(anim.keyframes!.keyframes[0].properties.opacity).toBe(1);
expect(anim.keyframes!.keyframes[1].percentage).toBe(50);
expect(anim.keyframes!.keyframes[1].properties.x).toBe(100);
expect(anim.keyframes!.keyframes[1].ease).toBe("power2.out");
expect(anim.keyframes!.keyframes[2].percentage).toBe(100);
expect(anim.keyframes!.keyframes[2].properties.x).toBe(200);
});
it("parses object array keyframes format", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#hero", {
keyframes: [
{ x: 0, opacity: 1, duration: 0.5 },
{ x: 100, duration: 1, ease: "power2.out" },
{ x: 200, duration: 0.8 }
]
}, 0);
`;
const result = parseGsapScript(script);
expect(result.animations).toHaveLength(1);
const anim = result.animations[0];
expect(anim.keyframes).toBeDefined();
expect(anim.keyframes!.format).toBe("object-array");
expect(anim.keyframes!.keyframes).toHaveLength(3);
// Total duration = 0.5 + 1 + 0.8 = 2.3
expect(anim.keyframes!.keyframes[0].percentage).toBe(0);
expect(anim.keyframes!.keyframes[0].properties.x).toBe(0);
expect(anim.keyframes!.keyframes[0].properties.opacity).toBe(1);
// Second: cumulative = 0.5, pct = round(0.5/2.3 * 100) = 22
expect(anim.keyframes!.keyframes[1].percentage).toBe(22);
expect(anim.keyframes!.keyframes[1].properties.x).toBe(100);
expect(anim.keyframes!.keyframes[1].ease).toBe("power2.out");
// Third: cumulative = 1.5, pct = round(1.5/2.3 * 100) = 65
expect(anim.keyframes!.keyframes[2].percentage).toBe(65);
expect(anim.keyframes!.keyframes[2].properties.x).toBe(200);
});
it("parses simple array keyframes format", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#hero", {
keyframes: { x: [0, 100, 200, 0], opacity: [0, 1, 1, 0], easeEach: "power2.inOut" },
duration: 5
}, 0);
`;
const result = parseGsapScript(script);
expect(result.animations).toHaveLength(1);
const anim = result.animations[0];
expect(anim.keyframes).toBeDefined();
expect(anim.keyframes!.format).toBe("simple-array");
expect(anim.keyframes!.easeEach).toBe("power2.inOut");
expect(anim.keyframes!.keyframes).toHaveLength(4);
// Evenly spaced: 0%, 33%, 67%, 100%
expect(anim.keyframes!.keyframes[0].percentage).toBe(0);
expect(anim.keyframes!.keyframes[0].properties.x).toBe(0);
expect(anim.keyframes!.keyframes[0].properties.opacity).toBe(0);
expect(anim.keyframes!.keyframes[1].percentage).toBe(33);
expect(anim.keyframes!.keyframes[1].properties.x).toBe(100);
expect(anim.keyframes!.keyframes[1].properties.opacity).toBe(1);
expect(anim.keyframes!.keyframes[2].percentage).toBe(67);
expect(anim.keyframes!.keyframes[2].properties.x).toBe(200);
expect(anim.keyframes!.keyframes[2].properties.opacity).toBe(1);
expect(anim.keyframes!.keyframes[3].percentage).toBe(100);
expect(anim.keyframes!.keyframes[3].properties.x).toBe(0);
expect(anim.keyframes!.keyframes[3].properties.opacity).toBe(0);
});
it("parses three-level easing", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#hero", {
keyframes: { "0%": { x: 0 }, "50%": { x: 100, ease: "back.out(1.7)" }, "100%": { x: 200 } },
ease: "none",
easeEach: "power2.out",
duration: 5
}, 0);
`;
const result = parseGsapScript(script);
const anim = result.animations[0];
// Tween-level ease
expect(anim.ease).toBe("none");
// easeEach on keyframes data (set from tween-level)
expect(anim.keyframes!.easeEach).toBe("power2.out");
// Per-keyframe ease
expect(anim.keyframes!.keyframes[1].ease).toBe("back.out(1.7)");
});
it("flat tween without keyframes still works", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el", { x: 100, duration: 1 }, 0);
`;
const result = parseGsapScript(script);
expect(result.animations).toHaveLength(1);
expect(result.animations[0].keyframes).toBeUndefined();
expect(result.animations[0].properties.x).toBe(100);
});
it("keyframes tween has empty top-level properties", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#hero", {
keyframes: { "0%": { x: 0 }, "100%": { x: 200 } },
duration: 5
}, 0);
`;
const result = parseGsapScript(script);
const anim = result.animations[0];
expect(anim.keyframes).toBeDefined();
expect(Object.keys(anim.properties)).toHaveLength(0);
});
});
// ── Keyframe mutation functions ───────────────────────────────────────────
describe("keyframe mutations", () => {
const KF_SCRIPT = `
const tl = gsap.timeline({ paused: true });
tl.to("#hero", {
keyframes: { "0%": { x: 0, opacity: 0 }, "100%": { x: 200, opacity: 1 } },
duration: 2
}, 0);
`;
const KF_SCRIPT_3 = `
const tl = gsap.timeline({ paused: true });
tl.to("#hero", {
keyframes: { "0%": { x: 0 }, "50%": { x: 100 }, "100%": { x: 200 } },
duration: 2
}, 0);
`;
function getAnimId(script: string): string {
return parseGsapScript(script).animations[0].id;
}
// ── addKeyframeToScript ─────────────────────────────────────────────────
it("addKeyframeToScript — inserts at sorted position", () => {
const id = getAnimId(KF_SCRIPT);
const updated = addKeyframeToScript(KF_SCRIPT, id, 50, { x: 100 });
const reparsed = parseGsapScript(updated);
const kfs = reparsed.animations[0].keyframes!.keyframes;
expect(kfs).toHaveLength(3);
expect(kfs.map((k) => k.percentage)).toEqual([0, 50, 100]);
expect(kfs[1].properties.x).toBe(100);
});
it("addKeyframeToScript — updates existing percentage", () => {
const id = getAnimId(KF_SCRIPT_3);
const updated = addKeyframeToScript(KF_SCRIPT_3, id, 50, { x: 999 });
const reparsed = parseGsapScript(updated);
const kfs = reparsed.animations[0].keyframes!.keyframes;
expect(kfs).toHaveLength(3);
expect(kfs[1].percentage).toBe(50);
expect(kfs[1].properties.x).toBe(999);
});
// ── removeKeyframeFromScript ────────────────────────────────────────────
it("removeKeyframeFromScript — removes one keyframe", () => {
const id = getAnimId(KF_SCRIPT_3);
const updated = removeKeyframeFromScript(KF_SCRIPT_3, id, 50);
const reparsed = parseGsapScript(updated);
const kfs = reparsed.animations[0].keyframes!.keyframes;
expect(kfs).toHaveLength(2);
expect(kfs.map((k) => k.percentage)).toEqual([0, 100]);
});
it("removeKeyframeFromScript — collapses to flat when <2 remain", () => {
const id = getAnimId(KF_SCRIPT);
const updated = removeKeyframeFromScript(KF_SCRIPT, id, 100);
const reparsed = parseGsapScript(updated);
const anim = reparsed.animations[0];
expect(anim.keyframes).toBeUndefined();
expect(anim.properties.x).toBe(0);
expect(anim.properties.opacity).toBe(0);
});
// ── updateKeyframeInScript ──────────────────────────────────────────────
it("updateKeyframeInScript — replaces properties", () => {
const id = getAnimId(KF_SCRIPT);
const updated = updateKeyframeInScript(KF_SCRIPT, id, 100, { x: 300, y: 50 });
const reparsed = parseGsapScript(updated);
const kf100 = reparsed.animations[0].keyframes!.keyframes.find((k) => k.percentage === 100)!;
expect(kf100.properties.x).toBe(300);
expect(kf100.properties.y).toBe(50);
});
// ── convertToKeyframesInScript ──────────────────────────────────────────
it("convertToKeyframesInScript — converts flat to() tween", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#title", { x: 100, opacity: 1, duration: 0.8, ease: "power3.out" }, 0.3);
`;
const id = getAnimId(script);
const updated = convertToKeyframesInScript(script, id, { x: 0, opacity: 0 });
const reparsed = parseGsapScript(updated);
const anim = reparsed.animations[0];
expect(anim.keyframes).toBeDefined();
const kfs = anim.keyframes!.keyframes;
expect(kfs).toHaveLength(2);
expect(kfs[0].percentage).toBe(0);
expect(kfs[0].properties.x).toBe(0);
expect(kfs[0].properties.opacity).toBe(0);
expect(kfs[1].percentage).toBe(100);
expect(kfs[1].properties.x).toBe(100);
expect(kfs[1].properties.opacity).toBe(1);
expect(anim.keyframes!.easeEach).toBe("power3.out");
expect(anim.ease).toBe("none");
expect(anim.duration).toBe(0.8);
expect(anim.position).toBe(0.3);
});
it("convertToKeyframesInScript — converts from() to to() + keyframes", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.from("#title", { x: -200, opacity: 0, duration: 0.8 }, 0.3);
`;
const id = getAnimId(script);
const updated = convertToKeyframesInScript(script, id, { x: 0, opacity: 1 });
const reparsed = parseGsapScript(updated);
const anim = reparsed.animations[0];
expect(anim.method).toBe("to");
expect(anim.keyframes).toBeDefined();
const kfs = anim.keyframes!.keyframes;
expect(kfs[0].properties.x).toBe(-200);
expect(kfs[0].properties.opacity).toBe(0);
expect(kfs[1].properties.x).toBe(0);
expect(kfs[1].properties.opacity).toBe(1);
});
it("convertToKeyframesInScript — converts fromTo() to to() + keyframes", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.fromTo("#title", { x: -100 }, { x: 100, duration: 1 }, 0);
`;
const id = getAnimId(script);
const updated = convertToKeyframesInScript(script, id);
const reparsed = parseGsapScript(updated);
const anim = reparsed.animations[0];
expect(anim.method).toBe("to");
expect(anim.keyframes).toBeDefined();
const kfs = anim.keyframes!.keyframes;
expect(kfs[0].properties.x).toBe(-100);
expect(kfs[1].properties.x).toBe(100);
});
it("convertToKeyframesInScript — skips if already has keyframes", () => {
const updated = convertToKeyframesInScript(KF_SCRIPT, getAnimId(KF_SCRIPT));
expect(updated).toBe(KF_SCRIPT);
});
// ── removeAllKeyframesFromScript ────────────────────────────────────────
it("removeAllKeyframesFromScript — collapses to last keyframe's props", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#hero", {
keyframes: { "0%": { x: 0 }, "50%": { x: 100 }, "100%": { x: 200, opacity: 1 } },
duration: 2
}, 0);
`;
const id = getAnimId(script);
const updated = removeAllKeyframesFromScript(script, id);
const reparsed = parseGsapScript(updated);
const anim = reparsed.animations[0];
expect(anim.keyframes).toBeUndefined();
expect(anim.properties.x).toBe(200);
expect(anim.properties.opacity).toBe(1);
});
});
+547 -8
View File
@@ -10,9 +10,22 @@
*/
import * as recast from "recast";
import { parse as babelParse } from "@babel/parser";
import { type GsapAnimation, type GsapMethod, type ParsedGsap } from "./gsapSerialize";
import {
type GsapAnimation,
type GsapKeyframesData,
type GsapMethod,
type GsapPercentageKeyframe,
type ParsedGsap,
} from "./gsapSerialize";
export type { GsapAnimation, GsapMethod, ParsedGsap } from "./gsapSerialize";
export type {
GsapAnimation,
GsapMethod,
ParsedGsap,
GsapKeyframesData,
GsapPercentageKeyframe,
GsapKeyframeFormat,
} from "./gsapSerialize";
export {
serializeGsapAnimations,
getAnimationsForElementId,
@@ -441,7 +454,7 @@ function findAllTweenCalls(
const BUILTIN_VAR_KEYS = new Set(["duration", "ease", "delay"]);
/** Keys that are never preserved (callbacks / advanced patterns). */
const DROPPED_VAR_KEYS = new Set(["keyframes", "onComplete", "onStart", "onUpdate", "onRepeat"]);
const DROPPED_VAR_KEYS = new Set(["onComplete", "onStart", "onUpdate", "onRepeat"]);
/** Keys that belong in `extras` — non-editable GSAP config that must survive round-trips. */
const EXTRAS_KEYS = new Set([
@@ -459,17 +472,221 @@ const EXTRAS_KEYS = new Set([
* Returns the printed source of the value node, suitable for verbatim re-emission.
*/
function extractRawPropertySource(varsArgNode: any, key: string): string | undefined {
const node = findPropertyNode(varsArgNode, key);
return node ? recast.print(node).code : undefined;
}
/** Find the raw AST node for a named property inside an ObjectExpression. */
function findPropertyNode(varsArgNode: any, key: string): any | undefined {
if (varsArgNode?.type !== "ObjectExpression") return undefined;
for (const prop of varsArgNode.properties ?? []) {
if (prop.type !== "ObjectProperty" && prop.type !== "Property") continue;
const propKey = prop.key?.name ?? prop.key?.value;
if (propKey === key) {
return recast.print(prop.value).code;
}
if (!isObjectProperty(prop)) continue;
if (propKeyName(prop) === key) return prop.value;
}
return undefined;
}
// ── Native GSAP Keyframes Parsing ──────────────────────────────────────────
const PERCENTAGE_KEY_RE = /^(\d+(?:\.\d+)?)%$/;
/** Extract a string-valued ease or easeEach from an AST property node. */
function tryResolveStringProp(propValue: any, scope: ScopeBindings): string | undefined {
const val = resolveNode(propValue, scope);
return typeof val === "string" ? val : undefined;
}
/**
* Parse a `keyframes` property value from a tween vars AST node into a
* normalized `GsapKeyframesData` structure. Handles all three GSAP formats:
* percentage objects, object arrays, and simple (property-array) objects.
*/
// fallow-ignore-next-line complexity
function parseKeyframesNode(node: any, scope: ScopeBindings): GsapKeyframesData | undefined {
if (!node) return undefined;
// ── Object array format: keyframes: [ { x: 0, duration: 0.5 }, ... ] ──
if (node.type === "ArrayExpression") {
return parseObjectArrayKeyframes(node, scope);
}
if (node.type !== "ObjectExpression") return undefined;
// Distinguish percentage vs simple-array by inspecting property keys/values.
const props = node.properties ?? [];
let hasPercentageKey = false;
let hasArrayValue = false;
for (const prop of props) {
if (prop.type !== "ObjectProperty" && prop.type !== "Property") continue;
const key = prop.key?.value ?? prop.key?.name;
if (typeof key === "string" && PERCENTAGE_KEY_RE.test(key)) {
hasPercentageKey = true;
break;
}
if (prop.value?.type === "ArrayExpression") {
hasArrayValue = true;
}
}
if (hasPercentageKey) return parsePercentageKeyframes(node, scope);
if (hasArrayValue) return parseSimpleArrayKeyframes(node, scope);
return undefined;
}
// fallow-ignore-next-line complexity
function parsePercentageKeyframes(node: any, scope: ScopeBindings): GsapKeyframesData {
const keyframes: GsapPercentageKeyframe[] = [];
let ease: string | undefined;
let easeEach: string | undefined;
for (const prop of node.properties ?? []) {
if (prop.type !== "ObjectProperty" && prop.type !== "Property") continue;
const key = prop.key?.value ?? prop.key?.name;
if (typeof key !== "string") continue;
const pctMatch = PERCENTAGE_KEY_RE.exec(key);
if (pctMatch) {
const percentage = Number.parseFloat(pctMatch[1]!);
const record = objectExpressionToRecord(prop.value, scope);
const properties: Record<string, number | string> = {};
let kfEase: string | undefined;
for (const [k, v] of Object.entries(record)) {
if (k === "ease" && typeof v === "string") {
kfEase = v;
} else if (typeof v === "number" || typeof v === "string") {
properties[k] = v;
}
}
keyframes.push({ percentage, properties, ...(kfEase ? { ease: kfEase } : {}) });
} else if (key === "ease") {
ease = tryResolveStringProp(prop.value, scope) ?? ease;
} else if (key === "easeEach") {
easeEach = tryResolveStringProp(prop.value, scope) ?? easeEach;
}
}
keyframes.sort((a, b) => a.percentage - b.percentage);
return {
format: "percentage",
keyframes,
...(ease ? { ease } : {}),
...(easeEach ? { easeEach } : {}),
};
}
// fallow-ignore-next-line complexity
function parseObjectArrayKeyframes(node: any, scope: ScopeBindings): GsapKeyframesData {
const elements = node.elements ?? [];
const raw: Array<{
properties: Record<string, number | string>;
duration?: number;
ease?: string;
}> = [];
for (const el of elements) {
if (!el || (el.type !== "ObjectExpression" && el.type !== "ObjectProperty")) {
// Skip non-object elements
if (el?.type !== "ObjectExpression") continue;
}
const record = objectExpressionToRecord(el, scope);
const properties: Record<string, number | string> = {};
let duration: number | undefined;
let ease: string | undefined;
for (const [k, v] of Object.entries(record)) {
if (k === "duration" && typeof v === "number") {
duration = v;
} else if (k === "ease" && typeof v === "string") {
ease = v;
} else if (typeof v === "number" || typeof v === "string") {
properties[k] = v;
}
}
raw.push({ properties, duration, ease });
}
// Convert durations to percentage positions. If durations are present, use
// cumulative ratios; otherwise distribute evenly.
const totalDuration = raw.reduce((sum, r) => sum + (r.duration ?? 0), 0);
const keyframes: GsapPercentageKeyframe[] = [];
if (totalDuration > 0) {
let cumulative = 0;
for (const entry of raw) {
const percentage = Math.round((cumulative / totalDuration) * 100);
keyframes.push({
percentage,
properties: entry.properties,
...(entry.ease ? { ease: entry.ease } : {}),
});
cumulative += entry.duration ?? 0;
}
} else {
for (let i = 0; i < raw.length; i++) {
const entry = raw[i]!;
const percentage = raw.length > 1 ? Math.round((i / (raw.length - 1)) * 100) : 0;
keyframes.push({
percentage,
properties: entry.properties,
...(entry.ease ? { ease: entry.ease } : {}),
});
}
}
return { format: "object-array", keyframes };
}
// fallow-ignore-next-line complexity
function parseSimpleArrayKeyframes(node: any, scope: ScopeBindings): GsapKeyframesData {
const arrayProps: Map<string, (number | string)[]> = new Map();
let ease: string | undefined;
let easeEach: string | undefined;
for (const prop of node.properties ?? []) {
if (prop.type !== "ObjectProperty" && prop.type !== "Property") continue;
const key = prop.key?.name ?? prop.key?.value;
if (typeof key !== "string") continue;
if (prop.value?.type === "ArrayExpression") {
const values: (number | string)[] = [];
for (const el of prop.value.elements ?? []) {
const val = resolveNode(el, scope);
if (typeof val === "number" || typeof val === "string") {
values.push(val);
}
}
if (values.length > 0) arrayProps.set(key, values);
} else if (key === "ease") {
ease = tryResolveStringProp(prop.value, scope) ?? ease;
} else if (key === "easeEach") {
easeEach = tryResolveStringProp(prop.value, scope) ?? easeEach;
}
}
// Zip arrays into percentage keyframes (evenly spaced).
const maxLen = Math.max(...[...arrayProps.values()].map((a) => a.length), 0);
const keyframes: GsapPercentageKeyframe[] = [];
for (let i = 0; i < maxLen; i++) {
const percentage = maxLen > 1 ? Math.round((i / (maxLen - 1)) * 100) : 0;
const properties: Record<string, number | string> = {};
for (const [key, values] of arrayProps) {
if (i < values.length) properties[key] = values[i]!;
}
keyframes.push({ percentage, properties });
}
return {
format: "simple-array",
keyframes,
...(ease ? { ease } : {}),
...(easeEach ? { easeEach } : {}),
};
}
// fallow-ignore-next-line complexity
function tweenCallToAnimation(
call: TweenCallInfo,
scope: ScopeBindings,
@@ -477,11 +694,23 @@ function tweenCallToAnimation(
const vars = objectExpressionToRecord(call.varsArg, scope);
const properties: Record<string, number | string> = {};
const extras: Record<string, unknown> = {};
let keyframesData: GsapKeyframesData | undefined;
for (const [key, val] of Object.entries(vars)) {
if (BUILTIN_VAR_KEYS.has(key)) continue;
if (DROPPED_VAR_KEYS.has(key)) continue;
if (key === "keyframes") {
const kfNode = findPropertyNode(call.varsArg, "keyframes");
keyframesData = parseKeyframesNode(kfNode, scope);
continue;
}
if (key === "easeEach") {
// easeEach is only meaningful alongside keyframes — handled below.
continue;
}
if (EXTRAS_KEYS.has(key)) {
// For extras, prefer the raw AST source so complex objects like
// `stagger: { each: 0.15, from: "start" }` survive verbatim.
@@ -499,6 +728,11 @@ function tweenCallToAnimation(
}
}
// Apply tween-level easeEach to keyframes data.
if (keyframesData && typeof vars.easeEach === "string") {
keyframesData.easeEach = vars.easeEach as string;
}
let fromProperties: Record<string, number | string> | undefined;
if (call.method === "fromTo" && call.fromArg) {
fromProperties = {};
@@ -526,6 +760,7 @@ function tweenCallToAnimation(
ease,
};
if (Object.keys(extras).length > 0) anim.extras = extras;
if (keyframesData) anim.keyframes = keyframesData;
return anim;
}
@@ -859,3 +1094,307 @@ export function removeAnimationFromScript(script: string, animationId: string):
}
return recast.print(parsed.ast).code;
}
// ── Keyframe Mutation Functions ────────────────────────────────────────────
/** Remove a named property from an ObjectExpression's properties array. */
function removeVarsKey(varsArg: any, key: string): void {
if (varsArg?.type !== "ObjectExpression") return;
varsArg.properties = varsArg.properties.filter(
(p: any) => !(isObjectProperty(p) && propKeyName(p) === key),
);
}
/** Extract the numeric percentage from a key like "50%". Returns NaN for non-percentage keys. */
function percentageFromKey(key: string): number {
const m = PERCENTAGE_KEY_RE.exec(key);
return m ? Number.parseFloat(m[1]!) : Number.NaN;
}
/** Build a keyframe value AST node from properties and optional ease. */
function buildKeyframeValueNode(properties: Record<string, number | string>, ease?: string): any {
const entries = Object.entries(properties).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`);
if (ease) entries.push(`ease: ${JSON.stringify(ease)}`);
return parseExpr(`{ ${entries.join(", ")} }`);
}
/** Parse + locate a target animation, returning null on failure. */
function locateAnimation(
script: string,
animationId: string,
): { parsed: ParsedGsapAst; target: ParsedGsapAst["located"][number] } | null {
let parsed: ParsedGsapAst;
try {
parsed = parseGsapAst(script);
} catch {
return null;
}
const target = parsed.located.find((l) => l.id === animationId);
return target ? { parsed, target } : null;
}
/** Find the keyframes ObjectExpression node on a tween's varsArg, or null. */
function findKeyframesObjectNode(varsArg: any): any | null {
const node = findPropertyNode(varsArg, "keyframes");
return node?.type === "ObjectExpression" ? node : null;
}
/** Filter percentage-keyed properties from a keyframes ObjectExpression. */
function filterPercentageProps(kfNode: any): any[] {
return kfNode.properties.filter((p: any) => {
if (!isObjectProperty(p)) return false;
const key = propKeyName(p);
return typeof key === "string" && PERCENTAGE_KEY_RE.test(key);
});
}
/**
* Collapse a keyframes node to flat tween: apply `record` entries as vars keys,
* then remove `keyframes` and `easeEach` from varsArg. Skips the `ease` key
* from the record (per-keyframe ease, not a tween ease).
*/
function collapseKeyframesToFlat(varsArg: any, record: Record<string, unknown>): void {
for (const [k, v] of Object.entries(record)) {
if (k === "ease") continue;
if (typeof v === "number" || typeof v === "string") setVarsKey(varsArg, k, v);
}
removeVarsKey(varsArg, "keyframes");
removeVarsKey(varsArg, "easeEach");
}
/**
* Insert a keyframe at the given percentage in an existing percentage-keyframes
* object. If the percentage already exists, its value is replaced.
*/
export function addKeyframeToScript(
script: string,
animationId: string,
percentage: number,
properties: Record<string, number | string>,
ease?: string,
): string {
const loc = locateAnimation(script, animationId);
if (!loc) return script;
const kfNode = findKeyframesObjectNode(loc.target.call.varsArg);
if (!kfNode) return script;
const pctKey = `${percentage}%`;
const newValueNode = buildKeyframeValueNode(properties, ease);
// Replace if this percentage already exists
const existingIdx = kfNode.properties.findIndex(
(p: any) => isObjectProperty(p) && propKeyName(p) === pctKey,
);
if (existingIdx !== -1) {
kfNode.properties[existingIdx].value = newValueNode;
return recast.print(loc.parsed.ast).code;
}
// Build the new property node with a quoted percentage key
const newProp = parseExpr(`{ ${JSON.stringify(pctKey)}: {} }`).properties[0];
newProp.value = newValueNode;
// Insert in sorted order by percentage
let insertIdx = kfNode.properties.length;
for (let i = 0; i < kfNode.properties.length; i++) {
const key = isObjectProperty(kfNode.properties[i])
? propKeyName(kfNode.properties[i])
: undefined;
if (typeof key === "string" && percentageFromKey(key) > percentage) {
insertIdx = i;
break;
}
}
kfNode.properties.splice(insertIdx, 0, newProp);
return recast.print(loc.parsed.ast).code;
}
/**
* Remove a keyframe at the given percentage. If fewer than 2 keyframes remain
* after removal, collapse the keyframes object to a flat tween using the
* remaining keyframe's properties.
*/
export function removeKeyframeFromScript(
script: string,
animationId: string,
percentage: number,
): string {
const loc = locateAnimation(script, animationId);
if (!loc) return script;
const kfNode = findKeyframesObjectNode(loc.target.call.varsArg);
if (!kfNode) return script;
const pctKey = `${percentage}%`;
const removeIdx = kfNode.properties.findIndex(
(p: any) => isObjectProperty(p) && propKeyName(p) === pctKey,
);
if (removeIdx === -1) return script;
kfNode.properties.splice(removeIdx, 1);
const remainingKfs = filterPercentageProps(kfNode);
if (remainingKfs.length < 2) {
const record =
remainingKfs.length === 1
? objectExpressionToRecord(remainingKfs[0].value, loc.parsed.scope)
: {};
collapseKeyframesToFlat(loc.target.call.varsArg, record);
}
return recast.print(loc.parsed.ast).code;
}
/**
* Replace the properties (and optionally ease) at an existing keyframe percentage.
*/
export function updateKeyframeInScript(
script: string,
animationId: string,
percentage: number,
properties: Record<string, number | string>,
ease?: string,
): string {
const loc = locateAnimation(script, animationId);
if (!loc) return script;
const kfNode = findKeyframesObjectNode(loc.target.call.varsArg);
if (!kfNode) return script;
const pctKey = `${percentage}%`;
const existing = kfNode.properties.find(
(p: any) => isObjectProperty(p) && propKeyName(p) === pctKey,
);
if (!existing) return script;
existing.value = buildKeyframeValueNode(properties, ease);
return recast.print(loc.parsed.ast).code;
}
/** Resolve from/to property maps for a tween being converted to keyframes. */
const CSS_IDENTITY: Record<string, number> = {
opacity: 1,
autoAlpha: 1,
scale: 1,
scaleX: 1,
scaleY: 1,
};
function cssIdentityValue(prop: string): number {
return CSS_IDENTITY[prop] ?? 0;
}
function resolveConversionProps(
anim: GsapAnimation,
resolvedFromValues?: Record<string, number | string>,
): { fromProps: Record<string, number | string>; toProps: Record<string, number | string> } {
if (anim.method === "to") {
if (resolvedFromValues) {
return { fromProps: resolvedFromValues, toProps: { ...anim.properties } };
}
const identityFrom: Record<string, number | string> = {};
for (const [key, val] of Object.entries(anim.properties)) {
if (val != null) identityFrom[key] = typeof val === "number" ? cssIdentityValue(key) : val;
}
return { fromProps: identityFrom, toProps: { ...anim.properties } };
}
if (anim.method === "from") {
if (resolvedFromValues) {
return { fromProps: { ...anim.properties }, toProps: resolvedFromValues };
}
const identityTo: Record<string, number | string> = {};
for (const [key, val] of Object.entries(anim.properties)) {
if (val != null) identityTo[key] = typeof val === "number" ? cssIdentityValue(key) : val;
}
return { fromProps: { ...anim.properties }, toProps: identityTo };
}
// fromTo
return { fromProps: { ...(anim.fromProperties ?? {}) }, toProps: { ...anim.properties } };
}
/** Strip editable properties and ease/keyframes keys from a varsArg. */
function stripEditableAndEase(varsArg: any): void {
if (varsArg?.type !== "ObjectExpression") return;
varsArg.properties = varsArg.properties.filter((p: any) => {
if (!isObjectProperty(p)) return true;
const key = propKeyName(p);
if (typeof key !== "string") return true;
if (key === "ease" || key === "keyframes") return false;
return !isEditablePropertyKey(key);
});
}
/** Build and prepend a keyframes property node onto varsArg. */
function insertKeyframesProp(
varsArg: any,
fromProps: Record<string, number | string>,
toProps: Record<string, number | string>,
): void {
const fromEntries = Object.entries(fromProps).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`);
const toEntries = Object.entries(toProps).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`);
const kfCode = `{ "0%": { ${fromEntries.join(", ")} }, "100%": { ${toEntries.join(", ")} } }`;
const kfProp = parseExpr(`{ keyframes: {} }`).properties[0];
kfProp.value = parseExpr(kfCode);
if (varsArg?.type === "ObjectExpression") varsArg.properties.unshift(kfProp);
}
/**
* Convert a flat tween (to/from/fromTo) to percentage-keyframes format.
* `resolvedFromValues` supplies the "from" state for `to()` tweens or
* the "to" state for `from()` tweens (the values the DOM would resolve to).
*/
export function convertToKeyframesInScript(
script: string,
animationId: string,
resolvedFromValues?: Record<string, number | string>,
): string {
const loc = locateAnimation(script, animationId);
if (!loc) return script;
const anim = loc.target.animation;
if (anim.keyframes || anim.method === "set") return script;
const { fromProps, toProps } = resolveConversionProps(anim, resolvedFromValues);
const varsArg = loc.target.call.varsArg;
const originalEase = anim.ease;
stripEditableAndEase(varsArg);
insertKeyframesProp(varsArg, fromProps, toProps);
if (originalEase) {
setVarsKey(varsArg, "easeEach", originalEase);
setVarsKey(varsArg, "ease", "none");
}
// For from() or fromTo(), convert to to()
if (anim.method === "from" || anim.method === "fromTo") {
loc.target.call.node.callee.property.name = "to";
if (anim.method === "fromTo") loc.target.call.node.arguments.splice(1, 1);
}
return recast.print(loc.parsed.ast).code;
}
/**
* Remove all keyframes from a tween, collapsing to a flat tween with the
* last keyframe's properties.
*/
export function removeAllKeyframesFromScript(script: string, animationId: string): string {
const loc = locateAnimation(script, animationId);
if (!loc) return script;
const kfNode = findKeyframesObjectNode(loc.target.call.varsArg);
if (!kfNode) return script;
// Collect all percentage keyframe entries, sorted
const kfEntries = filterPercentageProps(kfNode)
.map((p: any) => ({ pct: percentageFromKey(propKeyName(p)!), prop: p }))
.filter((e) => !Number.isNaN(e.pct))
.sort((a, b) => a.pct - b.pct);
if (kfEntries.length === 0) return script;
const lastRecord = objectExpressionToRecord(
kfEntries[kfEntries.length - 1]!.prop.value,
loc.parsed.scope,
);
collapseKeyframesToFlat(loc.target.call.varsArg, lastRecord);
return recast.print(loc.parsed.ast).code;
}
@@ -21,6 +21,23 @@ export interface GsapAnimation {
ease?: string;
/** Non-editable GSAP config (stagger, yoyo, repeat, etc.) preserved for round-trips. */
extras?: Record<string, unknown>;
/** Native GSAP keyframes data — present when the tween uses keyframes: { ... }. */
keyframes?: GsapKeyframesData;
}
export interface GsapPercentageKeyframe {
percentage: number;
properties: Record<string, number | string>;
ease?: string;
}
export type GsapKeyframeFormat = "percentage" | "object-array" | "simple-array";
export interface GsapKeyframesData {
format: GsapKeyframeFormat;
keyframes: GsapPercentageKeyframe[];
ease?: string;
easeEach?: string;
}
export interface ParsedGsap {
+63 -1
View File
@@ -579,7 +579,28 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
defaultValue: number | string;
}
| { type: "remove-property"; animationId: string; property: string }
| { type: "remove-from-property"; animationId: string; property: string };
| { type: "remove-from-property"; animationId: string; property: string }
| {
type: "add-keyframe";
animationId: string;
percentage: number;
properties: Record<string, number | string>;
ease?: string;
}
| { type: "remove-keyframe"; animationId: string; percentage: number }
| {
type: "update-keyframe";
animationId: string;
percentage: number;
properties: Record<string, number | string>;
ease?: string;
}
| {
type: "convert-to-keyframes";
animationId: string;
resolvedFromValues?: Record<string, number | string>;
}
| { type: "remove-all-keyframes"; animationId: string };
api.post("/projects/:id/gsap-mutations/*", async (c) => {
const res = await resolveProjectPath(c, adapter, (id) => `/projects/${id}/gsap-mutations/`, {
@@ -706,6 +727,47 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
});
break;
}
case "add-keyframe": {
const { addKeyframeToScript } = await loadGsapParser();
newScript = addKeyframeToScript(
block.scriptText,
body.animationId,
body.percentage,
body.properties,
body.ease,
);
break;
}
case "remove-keyframe": {
const { removeKeyframeFromScript } = await loadGsapParser();
newScript = removeKeyframeFromScript(block.scriptText, body.animationId, body.percentage);
break;
}
case "update-keyframe": {
const { updateKeyframeInScript } = await loadGsapParser();
newScript = updateKeyframeInScript(
block.scriptText,
body.animationId,
body.percentage,
body.properties,
body.ease,
);
break;
}
case "convert-to-keyframes": {
const { convertToKeyframesInScript } = await loadGsapParser();
newScript = convertToKeyframesInScript(
block.scriptText,
body.animationId,
body.resolvedFromValues,
);
break;
}
case "remove-all-keyframes": {
const { removeAllKeyframesFromScript } = await loadGsapParser();
newScript = removeAllKeyframesFromScript(block.scriptText, body.animationId);
break;
}
default:
return c.json({ error: `unknown mutation type: ${(body as { type: string }).type}` }, 400);
}