feat(studio): GSAP parser — arc path mutations + keyframe CRUD (#1301)

Add parser-level mutations for arc paths, keyframe add/remove/update,
convert-to-keyframes, and _auto flag for 100% keyframes. Wire route
handlers for new mutation types.
This commit is contained in:
Miguel Ángel
2026-06-09 18:28:44 -04:00
committed by GitHub
parent 0923bc0787
commit 96b8d617d8
5 changed files with 718 additions and 6 deletions
@@ -76,4 +76,5 @@ export const SUPPORTED_EASES = [
"spring-stiff",
"spring-wobbly",
"spring-heavy",
"steps(1)",
];
@@ -16,6 +16,7 @@ import {
updateKeyframeInScript,
convertToKeyframesInScript,
removeAllKeyframesFromScript,
addAnimationWithKeyframesToScript,
} from "./gsapParser.js";
import type { GsapAnimation } from "./gsapParser.js";
import type { Keyframe } from "../core.types";
@@ -1504,3 +1505,202 @@ describe("keyframe mutations", () => {
expect(anim.properties.opacity).toBe(1);
});
});
describe("motionPath parsing", () => {
it("parses motionPath with waypoint array and curviness", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el", {
motionPath: {
path: [{x: 0, y: 0}, {x: 200, y: -100}, {x: 400, y: 50}],
curviness: 1.5
},
duration: 2
}, 0);
`;
const result = parseGsapScript(script);
expect(result.animations).toHaveLength(1);
const anim = result.animations[0];
expect(anim.arcPath).toBeDefined();
expect(anim.arcPath!.enabled).toBe(true);
expect(anim.arcPath!.segments).toHaveLength(2);
expect(anim.arcPath!.segments[0].curviness).toBe(1.5);
expect(anim.arcPath!.segments[1].curviness).toBe(1.5);
expect(anim.keyframes).toBeDefined();
expect(anim.keyframes!.keyframes).toHaveLength(3);
expect(anim.keyframes!.keyframes[0].properties.x).toBe(0);
expect(anim.keyframes!.keyframes[0].properties.y).toBe(0);
expect(anim.keyframes!.keyframes[1].properties.x).toBe(200);
expect(anim.keyframes!.keyframes[1].properties.y).toBe(-100);
expect(anim.keyframes!.keyframes[2].properties.x).toBe(400);
expect(anim.keyframes!.keyframes[2].properties.y).toBe(50);
});
it("parses motionPath with type cubic and explicit control points", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el", {
motionPath: {
path: [
{x: 0, y: 0},
{x: 50, y: -80}, {x: 150, y: -120},
{x: 200, y: -100},
{x: 250, y: -80}, {x: 350, y: 30},
{x: 400, y: 50}
],
type: "cubic"
},
duration: 2
}, 0);
`;
const result = parseGsapScript(script);
const anim = result.animations[0];
expect(anim.arcPath).toBeDefined();
expect(anim.arcPath!.segments).toHaveLength(2);
expect(anim.arcPath!.segments[0].cp1).toEqual({ x: 50, y: -80 });
expect(anim.arcPath!.segments[0].cp2).toEqual({ x: 150, y: -120 });
expect(anim.arcPath!.segments[1].cp1).toEqual({ x: 250, y: -80 });
expect(anim.arcPath!.segments[1].cp2).toEqual({ x: 350, y: 30 });
expect(anim.keyframes!.keyframes).toHaveLength(3);
expect(anim.keyframes!.keyframes[0].properties).toEqual({ x: 0, y: 0 });
expect(anim.keyframes!.keyframes[1].properties).toEqual({ x: 200, y: -100 });
expect(anim.keyframes!.keyframes[2].properties).toEqual({ x: 400, y: 50 });
});
it("parses motionPath with autoRotate", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el", {
motionPath: {
path: [{x: 0, y: 0}, {x: 200, y: 100}],
autoRotate: true
},
duration: 1
}, 0);
`;
const result = parseGsapScript(script);
const anim = result.animations[0];
expect(anim.arcPath!.autoRotate).toBe(true);
});
it("merges motionPath waypoints into existing keyframes", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el", {
motionPath: {
path: [{x: 0, y: 0}, {x: 200, y: 100}],
curviness: 2
},
keyframes: {
"0%": { opacity: 1 },
"100%": { opacity: 0 }
},
duration: 2
}, 0);
`;
const result = parseGsapScript(script);
const anim = result.animations[0];
expect(anim.arcPath).toBeDefined();
expect(anim.arcPath!.segments).toHaveLength(1);
expect(anim.arcPath!.segments[0].curviness).toBe(2);
expect(anim.keyframes!.keyframes).toHaveLength(2);
expect(anim.keyframes!.keyframes[0].properties).toEqual({ opacity: 1, x: 0, y: 0 });
expect(anim.keyframes!.keyframes[1].properties).toEqual({ opacity: 0, x: 200, y: 100 });
});
it("skips motionPath with fewer than 2 waypoints", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el", {
motionPath: { path: [{x: 0, y: 0}] },
duration: 1
}, 0);
`;
const result = parseGsapScript(script);
expect(result.animations[0].arcPath).toBeUndefined();
});
it("tween without motionPath parses identically to before", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el", { x: 100, y: 200, duration: 1 }, 0);
`;
const result = parseGsapScript(script);
const anim = result.animations[0];
expect(anim.arcPath).toBeUndefined();
expect(anim.properties.x).toBe(100);
expect(anim.properties.y).toBe(200);
});
});
// ── addAnimationWithKeyframesToScript ──────────────────────────────────────
describe("addAnimationWithKeyframesToScript", () => {
const BASE = `
const tl = gsap.timeline({ paused: true });
tl.to("#title", { x: 100, duration: 0.5 }, 0);
`.trim();
it("adds a new tween with keyframes after existing tweens", () => {
const { script, id } = addAnimationWithKeyframesToScript(BASE, "#box", 3, 0.5, [
{ percentage: 0, properties: { x: 0 } },
{ percentage: 100, properties: { x: 200 } },
]);
expect(script).toContain("#box");
expect(script).toContain("keyframes");
expect(script).toContain('"0%"');
expect(script).toContain('"100%"');
expect(id).toBeTruthy();
const parsed = parseGsapScript(script);
expect(parsed.animations.length).toBe(2);
const newAnim = parsed.animations[1];
expect(newAnim.targetSelector).toBe("#box");
expect(newAnim.keyframes).toBeDefined();
expect(newAnim.keyframes!.keyframes.length).toBe(2);
});
it("preserves existing tween code", () => {
const { script } = addAnimationWithKeyframesToScript(BASE, "#new", 2, 1, [
{ percentage: 0, properties: { opacity: 0 } },
{ percentage: 100, properties: { opacity: 1 } },
]);
expect(script).toContain("#title");
expect(script).toContain("x: 100");
});
it("produces a stable ID for the new animation", () => {
const { script, id } = addAnimationWithKeyframesToScript(BASE, "#el", 1, 1, [
{ percentage: 0, properties: { y: 0 } },
{ percentage: 100, properties: { y: 100 } },
]);
expect(id).toContain("#el");
const parsed = parseGsapScript(script);
const match = parsed.animations.find((a) => a.id === id);
expect(match).toBeDefined();
});
it("includes per-keyframe ease when provided", () => {
const { script } = addAnimationWithKeyframesToScript(BASE, "#el", 0, 1, [
{ percentage: 0, properties: { x: 0 }, ease: "power2.out" },
{ percentage: 100, properties: { x: 100 } },
]);
expect(script).toContain("power2.out");
});
it("returns original script on parse failure", () => {
const { script, id } = addAnimationWithKeyframesToScript("not valid js {{", "#el", 0, 1, [
{ percentage: 0, properties: { x: 0 } },
]);
expect(script).toBe("not valid js {{");
expect(id).toBe("");
});
});
+429 -4
View File
@@ -11,6 +11,8 @@
import * as recast from "recast";
import { parse as babelParse } from "@babel/parser";
import {
type ArcPathConfig,
type ArcPathSegment,
type GsapAnimation,
type GsapKeyframesData,
type GsapMethod,
@@ -19,6 +21,8 @@ import {
} from "./gsapSerialize";
export type {
ArcPathConfig,
ArcPathSegment,
GsapAnimation,
GsapMethod,
ParsedGsap,
@@ -685,6 +689,84 @@ function parseSimpleArrayKeyframes(node: any, scope: ScopeBindings): GsapKeyfram
};
}
// ── MotionPath Parsing ────────────────────────────────────────────────────
interface MotionPathParseResult {
arcPath: ArcPathConfig;
waypoints: Array<{ x: number; y: number }>;
}
function parseMotionPathNode(node: any, scope: ScopeBindings): MotionPathParseResult | undefined {
if (!node) return undefined;
let pathNode: any;
let autoRotate: boolean | number = false;
let curviness = 1;
let isCubic = false;
if (node.type === "ObjectExpression") {
for (const prop of node.properties ?? []) {
if (!isObjectProperty(prop)) continue;
const key = propKeyName(prop);
if (key === "path") pathNode = prop.value;
else if (key === "autoRotate") {
const val = resolveNode(prop.value, scope);
autoRotate = typeof val === "number" ? val : val === true;
} else if (key === "curviness") {
const val = resolveNode(prop.value, scope);
if (typeof val === "number") curviness = val;
} else if (key === "type") {
const val = resolveNode(prop.value, scope);
if (val === "cubic") isCubic = true;
}
}
} else if (node.type === "ArrayExpression") {
pathNode = node;
}
if (!pathNode || pathNode.type !== "ArrayExpression") return undefined;
const elements = pathNode.elements ?? [];
const coords: Array<{ x: number; y: number }> = [];
for (const elem of elements) {
if (!elem || elem.type !== "ObjectExpression") continue;
const rec = objectExpressionToRecord(elem, scope);
const x = typeof rec.x === "number" ? rec.x : undefined;
const y = typeof rec.y === "number" ? rec.y : undefined;
if (x !== undefined && y !== undefined) coords.push({ x, y });
}
if (coords.length < 2) return undefined;
let waypoints: Array<{ x: number; y: number }>;
const segments: ArcPathSegment[] = [];
if (isCubic && coords.length >= 4) {
// type: "cubic" — coords are [anchor, cp1, cp2, anchor, cp1, cp2, anchor, ...]
// Every 3rd coord starting from 0 is an anchor, the two between are control points.
waypoints = [];
waypoints.push(coords[0]!);
for (let i = 1; i + 2 < coords.length; i += 3) {
const cp1 = coords[i]!;
const cp2 = coords[i + 1]!;
const anchor = coords[i + 2]!;
waypoints.push(anchor);
segments.push({ curviness, cp1, cp2 });
}
} else {
// Waypoint array with global curviness
waypoints = coords;
for (let i = 0; i < waypoints.length - 1; i++) {
segments.push({ curviness });
}
}
return {
arcPath: { enabled: true, autoRotate, segments },
waypoints,
};
}
// fallow-ignore-next-line complexity
function tweenCallToAnimation(
call: TweenCallInfo,
@@ -695,6 +777,7 @@ function tweenCallToAnimation(
const extras: Record<string, unknown> = {};
let keyframesData: GsapKeyframesData | undefined;
let hasUnresolvedKeyframes = false;
let motionPathResult: MotionPathParseResult | undefined;
for (const [key, val] of Object.entries(vars)) {
if (BUILTIN_VAR_KEYS.has(key)) continue;
@@ -707,6 +790,12 @@ function tweenCallToAnimation(
continue;
}
if (key === "motionPath") {
const mpNode = findPropertyNode(call.varsArg, "motionPath");
motionPathResult = parseMotionPathNode(mpNode, scope);
continue;
}
if (key === "easeEach") {
// easeEach is only meaningful alongside keyframes — handled below.
continue;
@@ -734,6 +823,30 @@ function tweenCallToAnimation(
keyframesData.easeEach = vars.easeEach as string;
}
// When motionPath is present, reconstruct x/y as keyframe waypoints.
if (motionPathResult) {
const { waypoints } = motionPathResult;
if (!keyframesData) {
// No explicit keyframes — create synthetic percentage keyframes from waypoints.
const kf: GsapPercentageKeyframe[] = waypoints.map((wp, i) => ({
percentage: waypoints.length > 1 ? Math.round((i / (waypoints.length - 1)) * 100) : 0,
properties: { x: wp.x, y: wp.y },
}));
keyframesData = { format: "percentage", keyframes: kf };
} else {
// Merge waypoint positions into existing keyframes at matching percentages.
// If keyframe count matches waypoint count, assign positionally.
const kfs = keyframesData.keyframes;
if (kfs.length === waypoints.length) {
for (let i = 0; i < kfs.length; i++) {
kfs[i]!.properties.x = waypoints[i]!.x;
kfs[i]!.properties.y = waypoints[i]!.y;
}
}
}
// arcPath is attached below on the animation result.
}
let fromProperties: Record<string, number | string> | undefined;
if (call.method === "fromTo" && call.fromArg) {
fromProperties = {};
@@ -762,6 +875,7 @@ function tweenCallToAnimation(
};
if (Object.keys(extras).length > 0) anim.extras = extras;
if (keyframesData) anim.keyframes = keyframesData;
if (motionPathResult) anim.arcPath = motionPathResult.arcPath;
if (hasUnresolvedKeyframes) anim.hasUnresolvedKeyframes = true;
if (call.selector === "__unresolved__") anim.hasUnresolvedSelector = true;
return anim;
@@ -941,7 +1055,15 @@ function applyUpdatesToCall(call: TweenCallInfo, updates: Partial<GsapAnimation>
reconcileEditableProperties(call.fromArg, updates.fromProperties);
}
if (updates.duration !== undefined) setVarsKey(call.varsArg, "duration", updates.duration);
if (updates.ease !== undefined) setVarsKey(call.varsArg, "ease", updates.ease);
if (updates.ease !== undefined) {
const kfNode = findKeyframesObjectNode(call.varsArg);
if (kfNode) {
setVarsKey(kfNode, "easeEach", updates.ease);
removeVarsKey(call.varsArg, "ease");
} else {
setVarsKey(call.varsArg, "ease", updates.ease);
}
}
if (updates.position !== undefined) {
const posIdx = call.method === "fromTo" ? 3 : 2;
call.node.arguments[posIdx] = parseExpr(valueToCode(updates.position));
@@ -1036,6 +1158,63 @@ export function addAnimationToScript(
return { script: recast.print(parsed.ast).code, id };
}
export function addAnimationWithKeyframesToScript(
script: string,
targetSelector: string,
position: number,
duration: number,
keyframes: Array<{
percentage: number;
properties: Record<string, number | string>;
ease?: string;
auto?: boolean;
}>,
ease?: string,
): { script: string; id: string } {
let parsed: ParsedGsapAst;
try {
parsed = parseGsapAst(script);
} catch (e) {
console.warn("[gsap-parser] addAnimationWithKeyframesToScript parse failed:", e);
return { script, id: "" };
}
if (parsed.located.length === 0 && parsed.detection.timelineVar === null) {
return { script, id: "" };
}
const selector = JSON.stringify(targetSelector);
const kfEntries = keyframes.map((kf) => {
const propEntries = Object.entries(kf.properties).map(
([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`,
);
if (kf.ease) propEntries.push(`ease: ${JSON.stringify(kf.ease)}`);
if (kf.auto) propEntries.push(`_auto: 1`);
return `${JSON.stringify(`${kf.percentage}%`)}: { ${propEntries.join(", ")} }`;
});
const kfCode = `{ ${kfEntries.join(", ")} }`;
const varEntries = [`keyframes: ${kfCode}`, `duration: ${valueToCode(duration)}`];
if (ease) varEntries.push(`ease: ${JSON.stringify(ease)}`);
const posCode = valueToCode(position);
const stmtCode = `${parsed.timelineVar}.to(${selector}, { ${varEntries.join(", ")} }, ${posCode});`;
const newStatement = parseScript(stmtCode).program.body[0];
const lastCall = parsed.located[parsed.located.length - 1]?.call;
const anchorPath = lastCall
? findStatementPath(lastCall.path)
: findTimelineDeclarationPath(parsed.ast, parsed.timelineVar);
if (anchorPath) {
anchorPath.insertAfter(newStatement);
} else {
parsed.ast.program.body.push(newStatement);
}
const result = recast.print(parsed.ast).code;
const reParsed = parseGsapAst(result);
const newId = reParsed.located[reParsed.located.length - 1]?.id ?? "";
return { script: result, id: newId };
}
/** Find the statement path of `const <timelineVar> = gsap.timeline(...)`. */
function findTimelineDeclarationPath(ast: any, timelineVar: string): any {
let found: any = null;
@@ -1177,10 +1356,25 @@ export function addKeyframeToScript(
ease?: string,
backfillDefaults?: Record<string, number | string>,
): string {
const loc = locateAnimation(script, animationId);
let loc = locateAnimation(script, animationId);
if (!loc) {
const convertedId = animationId.replace(/-from-|-fromTo-/, "-to-");
loc = locateAnimation(script, convertedId);
}
if (!loc) return script;
const kfNode = findKeyframesObjectNode(loc.target.call.varsArg);
if (!kfNode) return script;
let kfNode = findKeyframesObjectNode(loc.target.call.varsArg);
if (!kfNode) {
script = convertToKeyframesInScript(script, animationId);
loc = locateAnimation(script, animationId);
if (!loc) {
const convertedId = animationId.replace(/-from-|-fromTo-/, "-to-");
loc = locateAnimation(script, convertedId);
}
if (!loc) return script;
kfNode = findKeyframesObjectNode(loc.target.call.varsArg);
if (!kfNode) return script;
}
const pctKey = `${percentage}%`;
const newValueNode = buildKeyframeValueNode(properties, ease);
@@ -1210,6 +1404,24 @@ export function addKeyframeToScript(
kfNode.properties.splice(insertIdx, 0, newProp);
}
// Auto-update 100%: if the 100% keyframe still has `_auto: 1` (never
// explicitly edited by the user), update it to match the new keyframe's
// values so the element holds its final position instead of snapping back.
// Once the user drags at 100%, `_auto` is gone and we stop touching it.
if (percentage < 100 && percentage !== 0) {
const pctProps = filterPercentageProps(kfNode);
const hundredProp = pctProps.find((p: any) => percentageFromKey(propKeyName(p) ?? "") === 100);
if (hundredProp?.value?.type === "ObjectExpression") {
const hasAuto = hundredProp.value.properties.some(
(p: any) => isObjectProperty(p) && propKeyName(p) === "_auto",
);
if (hasAuto) {
const updatedProps = { ...properties, _auto: 1 as number | string };
hundredProp.value = buildKeyframeValueNode(updatedProps, undefined);
}
}
}
// Backfill: when the new keyframe introduces properties absent from other
// keyframes, add default values so GSAP can interpolate them.
if (backfillDefaults) {
@@ -1481,6 +1693,219 @@ export function materializeKeyframesInScript(
return recast.print(loc.parsed.ast).code;
}
// ── Arc Path (motionPath) AST Mutations ──────────────────────────────────
function buildMotionPathObjectCode(config: {
waypoints: Array<{ x: number; y: number }>;
segments: ArcPathSegment[];
autoRotate: boolean | number;
}): string {
const { waypoints, segments, autoRotate } = config;
const hasExplicitControlPoints = segments.some((s) => s.cp1 && s.cp2);
let pathEntries: string[];
if (hasExplicitControlPoints && waypoints.length >= 2) {
// type: "cubic" — interleave control points: [anchor, cp1, cp2, anchor, ...]
pathEntries = [`{x: ${waypoints[0]!.x}, y: ${waypoints[0]!.y}}`];
for (let i = 0; i < segments.length; i++) {
const seg = segments[i]!;
const nextWp = waypoints[i + 1]!;
if (seg.cp1 && seg.cp2) {
pathEntries.push(`{x: ${seg.cp1.x}, y: ${seg.cp1.y}}`);
pathEntries.push(`{x: ${seg.cp2.x}, y: ${seg.cp2.y}}`);
} else {
// Auto-generate simple midpoint control points from curviness
const wp = waypoints[i]!;
const dx = nextWp.x - wp.x;
const dy = nextWp.y - wp.y;
const c = seg.curviness ?? 1;
pathEntries.push(
`{x: ${wp.x + dx * 0.33}, y: ${wp.y + dy * 0.33 - c * Math.abs(dx) * 0.25}}`,
);
pathEntries.push(
`{x: ${wp.x + dx * 0.66}, y: ${wp.y + dy * 0.66 - c * Math.abs(dx) * 0.25}}`,
);
}
pathEntries.push(`{x: ${nextWp.x}, y: ${nextWp.y}}`);
}
const pathStr = pathEntries.join(", ");
const parts = [`path: [${pathStr}]`, `type: "cubic"`];
if (autoRotate === true) parts.push("autoRotate: true");
else if (typeof autoRotate === "number") parts.push(`autoRotate: ${autoRotate}`);
return `{ ${parts.join(", ")} }`;
}
// Simple waypoint array with curviness
pathEntries = waypoints.map((wp) => `{x: ${wp.x}, y: ${wp.y}}`);
const curviness = segments[0]?.curviness ?? 1;
const parts = [`path: [${pathEntries.join(", ")}]`];
if (curviness !== 1) parts.push(`curviness: ${curviness}`);
if (autoRotate === true) parts.push("autoRotate: true");
else if (typeof autoRotate === "number") parts.push(`autoRotate: ${autoRotate}`);
return `{ ${parts.join(", ")} }`;
}
export function setArcPathInScript(
script: string,
animationId: string,
config: ArcPathConfig,
): string {
const loc = locateAnimation(script, animationId);
if (!loc) return script;
const varsArg = loc.target.call.varsArg;
const anim = loc.target.animation;
if (!config.enabled) {
// Disable arc: restore x/y from motionPath's last waypoint, then remove motionPath
const motionPathProp = varsArg.properties.find(
(p: any) => isObjectProperty(p) && propKeyName(p) === "motionPath",
);
if (motionPathProp) {
const mpVal = motionPathProp.value;
let pathArr: any[] | undefined;
if (mpVal?.type === "ObjectExpression") {
const pathProp = mpVal.properties.find(
(p: any) => isObjectProperty(p) && propKeyName(p) === "path",
);
if (pathProp?.value?.type === "ArrayExpression") pathArr = pathProp.value.elements;
}
if (pathArr && pathArr.length > 0) {
const last = pathArr[pathArr.length - 1];
if (last?.type === "ObjectExpression") {
for (const p of last.properties) {
const k = propKeyName(p);
if (k === "x" || k === "y") {
const v = p.value?.value;
if (typeof v === "number") setVarsKey(varsArg, k, v);
}
}
}
}
}
removeVarsKey(varsArg, "motionPath");
return recast.print(loc.parsed.ast).code;
}
// Extract x/y waypoints from keyframes or flat tween properties
const kfs = anim.keyframes?.keyframes ?? [];
const waypoints: Array<{ x: number; y: number }> = [];
for (const kf of kfs) {
const x = typeof kf.properties.x === "number" ? kf.properties.x : undefined;
const y = typeof kf.properties.y === "number" ? kf.properties.y : undefined;
if (x !== undefined && y !== undefined) waypoints.push({ x, y });
}
// For flat tweens with x/y in properties, synthesize start → end waypoints
if (waypoints.length < 2) {
const px = anim.properties.x;
const py = anim.properties.y;
if (typeof px === "number" || typeof py === "number") {
waypoints.length = 0;
waypoints.push({ x: 0, y: 0 });
waypoints.push({ x: typeof px === "number" ? px : 0, y: typeof py === "number" ? py : 0 });
}
}
if (waypoints.length < 2) return script;
// Build segments — use provided segments or create defaults
const segments: ArcPathSegment[] =
config.segments.length === waypoints.length - 1
? config.segments
: Array.from({ length: waypoints.length - 1 }, () => ({ curviness: 1 }));
const motionPathCode = buildMotionPathObjectCode({
waypoints,
segments,
autoRotate: config.autoRotate,
});
// Set motionPath on the vars
const motionPathNode = parseExpr(motionPathCode);
const existingProp = varsArg.properties.find(
(p: any) => isObjectProperty(p) && propKeyName(p) === "motionPath",
);
if (existingProp) {
existingProp.value = motionPathNode;
} else {
const prop = parseExpr(`{ motionPath: ${motionPathCode} }`).properties[0];
varsArg.properties.push(prop);
}
// Strip x/y from keyframes (they're now in motionPath)
const kfNode = findKeyframesObjectNode(varsArg);
if (kfNode) {
for (const pctProp of filterPercentageProps(kfNode)) {
if (pctProp.value?.type === "ObjectExpression") {
pctProp.value.properties = pctProp.value.properties.filter((p: any) => {
const k = propKeyName(p);
return k !== "x" && k !== "y";
});
}
}
}
// Strip flat x/y from vars (they're now in motionPath)
removeVarsKey(varsArg, "x");
removeVarsKey(varsArg, "y");
return recast.print(loc.parsed.ast).code;
}
export function updateArcSegmentInScript(
script: string,
animationId: string,
segmentIndex: number,
update: Partial<ArcPathSegment>,
): string {
const loc = locateAnimation(script, animationId);
if (!loc) return script;
const anim = loc.target.animation;
if (!anim.arcPath?.enabled) return script;
const segments = [...anim.arcPath.segments];
if (segmentIndex < 0 || segmentIndex >= segments.length) return script;
segments[segmentIndex] = { ...segments[segmentIndex]!, ...update };
// Rebuild the full motionPath with updated segments
const kfs = anim.keyframes?.keyframes ?? [];
const waypoints: Array<{ x: number; y: number }> = [];
for (const kf of kfs) {
const x = typeof kf.properties.x === "number" ? kf.properties.x : undefined;
const y = typeof kf.properties.y === "number" ? kf.properties.y : undefined;
if (x !== undefined && y !== undefined) waypoints.push({ x, y });
}
if (waypoints.length < 2) return script;
const motionPathCode = buildMotionPathObjectCode({
waypoints,
segments,
autoRotate: anim.arcPath.autoRotate,
});
const varsArg = loc.target.call.varsArg;
const existingProp = varsArg.properties.find(
(p: any) => isObjectProperty(p) && propKeyName(p) === "motionPath",
);
if (existingProp) {
existingProp.value = parseExpr(motionPathCode);
}
return recast.print(loc.parsed.ast).code;
}
export function removeArcPathFromScript(script: string, animationId: string): string {
return setArcPathInScript(script, animationId, {
enabled: false,
autoRotate: false,
segments: [],
});
}
/**
* Replace a dynamic loop that generates multiple tween calls with individual
* static `tl.to()` calls — one per element. Finds the loop containing the
+14 -2
View File
@@ -23,6 +23,8 @@ export interface GsapAnimation {
extras?: Record<string, unknown>;
/** Native GSAP keyframes data — present when the tween uses keyframes: { ... }. */
keyframes?: GsapKeyframesData;
/** Arc motion path config — present when the tween uses motionPath for curved position interpolation. */
arcPath?: ArcPathConfig;
/** True when the tween has a `keyframes` property that couldn't be statically resolved (dynamic). */
hasUnresolvedKeyframes?: boolean;
/** True when the tween's target selector couldn't be statically resolved (dynamic). */
@@ -44,6 +46,18 @@ export interface GsapKeyframesData {
easeEach?: string;
}
export interface ArcPathSegment {
curviness: number;
cp1?: { x: number; y: number };
cp2?: { x: number; y: number };
}
export interface ArcPathConfig {
enabled: boolean;
autoRotate: boolean | number;
segments: ArcPathSegment[];
}
export interface ParsedGsap {
animations: GsapAnimation[];
timelineVar: string;
@@ -176,10 +190,8 @@ const FORBIDDEN_GSAP_PATTERNS: Array<{ pattern: RegExp; message: string }> = [
{ pattern: /\.add\s*\(/, message: "add() method not allowed" },
{ pattern: /\.addLabel\s*\(/, message: "addLabel() method not allowed" },
{ pattern: /\.addPause\s*\(/, message: "addPause() method not allowed" },
{ pattern: /gsap\.registerPlugin\s*\(/, message: "registerPlugin() not allowed" },
{ pattern: /gsap\.registerEffect\s*\(/, message: "registerEffect() not allowed" },
{ pattern: /ScrollTrigger/, message: "ScrollTrigger not allowed" },
{ pattern: /MotionPathPlugin/, message: "MotionPathPlugin not allowed" },
{ pattern: /onComplete\s*:/, message: "onComplete callback not allowed" },
{ pattern: /onUpdate\s*:/, message: "onUpdate callback not allowed" },
{ pattern: /onStart\s*:/, message: "onStart callback not allowed" },
@@ -656,6 +656,39 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
keyframes: Array<{ percentage: number; properties: Record<string, number | string> }>;
easeEach?: string;
}>;
}
| {
type: "set-arc-path";
animationId: string;
enabled: boolean;
autoRotate?: boolean | number;
segments?: Array<{
curviness: number;
cp1?: { x: number; y: number };
cp2?: { x: number; y: number };
}>;
}
| {
type: "update-arc-segment";
animationId: string;
segmentIndex: number;
curviness?: number;
cp1?: { x: number; y: number };
cp2?: { x: number; y: number };
}
| { type: "remove-arc-path"; animationId: string }
| {
type: "add-with-keyframes";
targetSelector: string;
position: number;
duration: number;
keyframes: Array<{
percentage: number;
properties: Record<string, number | string>;
ease?: string;
auto?: boolean;
}>;
ease?: string;
};
api.post("/projects/:id/gsap-mutations/*", async (c) => {
@@ -840,6 +873,47 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
}
break;
}
case "set-arc-path": {
const { setArcPathInScript } = await loadGsapParser();
newScript = setArcPathInScript(block.scriptText, body.animationId, {
enabled: body.enabled,
autoRotate: body.autoRotate ?? false,
segments: body.segments ?? [],
});
break;
}
case "update-arc-segment": {
const { updateArcSegmentInScript } = await loadGsapParser();
newScript = updateArcSegmentInScript(
block.scriptText,
body.animationId,
body.segmentIndex,
{
...(body.curviness !== undefined ? { curviness: body.curviness } : {}),
...(body.cp1 ? { cp1: body.cp1 } : {}),
...(body.cp2 ? { cp2: body.cp2 } : {}),
},
);
break;
}
case "remove-arc-path": {
const { removeArcPathFromScript } = await loadGsapParser();
newScript = removeArcPathFromScript(block.scriptText, body.animationId);
break;
}
case "add-with-keyframes": {
const { addAnimationWithKeyframesToScript } = await loadGsapParser();
const result = addAnimationWithKeyframesToScript(
block.scriptText,
body.targetSelector,
body.position,
body.duration,
body.keyframes,
body.ease,
);
newScript = result.script;
break;
}
default:
return c.json({ error: `unknown mutation type: ${(body as { type: string }).type}` }, 400);
}