mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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:
@@ -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("");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user