fix(studio): make GSAP tween editing work on real compositions (#1115)

The Design-panel GSAP editor only recognized tweens written as
tl.to(".selector", {...}) with inline string-literal targets, in a
contiguous block, with no interleaved setup. Every scaffolded
composition instead targets tweens through element variables
(const kicker = root.querySelector(".kicker"); tl.to(kicker, {...})),
wraps the script in an IIFE, and interleaves gsap.set() calls — so the
parser returned zero animations and the panel was inert.

Three coordinated fixes make it work end to end:

- Parser read: resolve querySelector / querySelectorAll / getElementById
  variable targets (and inline lookup calls) back to their CSS selector,
  so variable-targeted tweens are recognized.

- Parser write: replace the full re-serialize (preamble + tweens +
  postamble) with in-place recast AST mutation. Edits now touch only the
  targeted tween's vars/position node and reprint, preserving every
  surrounding statement — gsap.set calls, element declarations, the IIFE
  wrapper, comments and formatting. Previously the first edit would
  discard all of that.

- Linter: build overlap/clip windows directly from the parser's
  structured animations instead of a regex walk paired positionally with
  the parsed list. The old pairing skipped variable targets and would
  drift once the parser started returning them. Removes the now-dead
  regex meta helpers.

- studio-api: extractGsapScriptBlock now searches inside <template>
  content (sub-compositions wrap markup + the GSAP script in a template,
  which linkedom's querySelectorAll doesn't descend into), and the
  frontend matches tweens to the selected element by id OR selector
  rather than id only (class-targeted elements have no id).

Verified end to end against a real 10-scene project: all compositions
now parse (previously 0), the panel populates editable tween cards, and
property/duration/ease edits round-trip while leaving the rest of the
script byte-for-byte intact.
This commit is contained in:
Miguel Ángel
2026-05-28 20:54:44 -04:00
committed by GitHub
parent 2f3ab9f4c9
commit 4de054e7d4
9 changed files with 753 additions and 186 deletions
@@ -856,7 +856,7 @@ describe("Additional edge cases", () => {
expect(result.animations[0].targetSelector).toBe("#el2");
});
it("non-string selector (variable reference) is skipped", () => {
it("resolves a variable reference selector to its queried CSS selector", () => {
const script = `
const el = document.querySelector("#el");
const tl = gsap.timeline({ paused: true });
@@ -864,7 +864,20 @@ describe("Additional edge cases", () => {
tl.to("#el2", { x: 100, duration: 0.5 }, 0);
`;
const result = parseGsapScript(script);
// First tween has a variable reference as selector, not a string literal — skipped
// `el` is bound to `document.querySelector("#el")`, so it resolves to "#el".
expect(result.animations).toHaveLength(2);
expect(result.animations[0].targetSelector).toBe("#el");
expect(result.animations[1].targetSelector).toBe("#el2");
});
it("skips a variable target that is not bound to a DOM lookup", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to(mysteryTarget, { opacity: 1, duration: 0.5 }, 0);
tl.to("#el2", { x: 100, duration: 0.5 }, 0);
`;
const result = parseGsapScript(script);
// mysteryTarget has no resolvable selector binding — only the literal survives.
expect(result.animations).toHaveLength(1);
expect(result.animations[0].targetSelector).toBe("#el2");
});
@@ -813,3 +813,211 @@ describe("SUPPORTED_EASES", () => {
expect(SUPPORTED_EASES).toContain("elastic.inOut");
});
});
// ── Variable-target resolution + in-place mutation ──────────────────────────
//
// Real compositions (and everything the hyperframes skill generates) target
// tweens via element variables resolved from querySelector, wrapped in an IIFE,
// with gsap.set() calls interleaved between tl.to() calls. The parser must
// resolve those variable targets to selectors (read) and edits must preserve
// every surrounding statement (write).
const REAL_WORLD_SCRIPT = `(function () {
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
const root = document.querySelector('#cold-open');
const kicker = root.querySelector(".co-kicker");
const glyph = root.querySelector(".co-new");
const items = root.querySelectorAll(".co-item");
gsap.set(kicker, { y: 16, opacity: 0 });
tl.to(kicker, { y: 0, opacity: 1, duration: 0.45, ease: "expo.out" }, 0.3);
gsap.set(glyph, { rotationX: 90, opacity: 0 });
tl.to(glyph, { rotationX: 0, opacity: 1, duration: 0.5, ease: "power3.inOut" }, 2.06);
tl.to(items, { opacity: 1, duration: 0.4, stagger: 0.1 }, 1.0);
window.__timelines["cold-open"] = tl;
})();`;
describe("variable-target resolution (querySelector pattern)", () => {
it("resolves a const element variable to its selector", () => {
const script = `
const root = document.querySelector('#scene');
const kicker = root.querySelector(".co-kicker");
const tl = gsap.timeline({ paused: true });
tl.to(kicker, { y: 0, opacity: 1, duration: 0.45, ease: "expo.out" }, 0.3);
`;
const result = parseGsapScript(script);
expect(result.animations).toHaveLength(1);
expect(result.animations[0].targetSelector).toBe(".co-kicker");
expect(result.animations[0].properties.opacity).toBe(1);
expect(result.animations[0].duration).toBe(0.45);
expect(result.animations[0].ease).toBe("expo.out");
});
it("resolves document.querySelector and querySelectorAll targets", () => {
const script = `
const tl = gsap.timeline({ paused: true });
const title = document.querySelector("#title");
const items = document.querySelectorAll(".item");
tl.to(title, { opacity: 1, duration: 0.5 }, 0);
tl.to(items, { y: 0, duration: 0.5, stagger: 0.1 }, 0.5);
`;
const result = parseGsapScript(script);
expect(result.animations).toHaveLength(2);
expect(result.animations[0].targetSelector).toBe("#title");
expect(result.animations[1].targetSelector).toBe(".item");
});
it("resolves getElementById targets to an id selector", () => {
const script = `
const tl = gsap.timeline({ paused: true });
const el = document.getElementById("hero");
tl.to(el, { opacity: 1, duration: 0.5 }, 0);
`;
const result = parseGsapScript(script);
expect(result.animations).toHaveLength(1);
expect(result.animations[0].targetSelector).toBe("#hero");
});
it("resolves an inline querySelector call passed directly as the target", () => {
const script = `
const root = document.querySelector('#scene');
const tl = gsap.timeline({ paused: true });
tl.to(root.querySelector(".inline"), { opacity: 1, duration: 0.5 }, 0);
`;
const result = parseGsapScript(script);
expect(result.animations).toHaveLength(1);
expect(result.animations[0].targetSelector).toBe(".inline");
});
it("parses mixed string-literal and variable targets in one timeline", () => {
const script = `
const tl = gsap.timeline({ paused: true });
const kicker = document.querySelector(".kicker");
tl.to(".literal", { opacity: 1, duration: 0.5 }, 0);
tl.to(kicker, { y: 0, duration: 0.5 }, 0.5);
`;
const result = parseGsapScript(script);
expect(result.animations.map((a) => a.targetSelector)).toEqual([".literal", ".kicker"]);
});
it("parses every tween in a real-world IIFE composition with interleaved gsap.set", () => {
const result = parseGsapScript(REAL_WORLD_SCRIPT);
expect(result.animations.map((a) => a.targetSelector)).toEqual([
".co-kicker",
".co-new",
".co-item",
]);
// stagger preserved as extras
expect(result.animations[2].extras?.stagger).toBe("__raw:0.1");
});
it("leaves unresolvable variable targets out of the animation list", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to(someUnknownThing, { opacity: 1, duration: 0.5 }, 0);
tl.to(".real", { opacity: 1, duration: 0.5 }, 1);
`;
const result = parseGsapScript(script);
expect(result.animations.map((a) => a.targetSelector)).toEqual([".real"]);
});
});
describe("in-place AST mutation preserves surrounding code", () => {
it("updateAnimationInScript edits one tween and preserves gsap.set + var decls + IIFE", () => {
const parsed = parseGsapScript(REAL_WORLD_SCRIPT);
const kickerAnim = parsed.animations.find((a) => a.targetSelector === ".co-kicker")!;
const updated = updateAnimationInScript(REAL_WORLD_SCRIPT, kickerAnim.id, {
properties: { y: 0, opacity: 0.5 },
});
// The edit landed
expect(updated).toContain("opacity: 0.5");
// Surrounding code survived verbatim
expect(updated).toContain('const kicker = root.querySelector(".co-kicker")');
expect(updated).toContain("gsap.set(kicker, { y: 16, opacity: 0 })");
expect(updated).toContain("gsap.set(glyph, { rotationX: 90, opacity: 0 })");
expect(updated).toContain('window.__timelines["cold-open"] = tl;');
expect(updated).toContain("(function () {");
// The variable target was NOT rewritten to a string literal
expect(updated).toContain("tl.to(kicker,");
expect(updated).not.toContain('tl.to(".co-kicker"');
// The other tweens are untouched
expect(updated).toContain("tl.to(glyph,");
expect(updated).toContain("tl.to(items,");
});
it("updateAnimationInScript re-parses to the edited value (round-trip)", () => {
const parsed = parseGsapScript(REAL_WORLD_SCRIPT);
const glyphAnim = parsed.animations.find((a) => a.targetSelector === ".co-new")!;
const updated = updateAnimationInScript(REAL_WORLD_SCRIPT, glyphAnim.id, {
properties: { rotationX: 0, opacity: 1, scale: 1.2 },
});
const reparsed = parseGsapScript(updated);
const reGlyph = reparsed.animations.find((a) => a.targetSelector === ".co-new")!;
expect(reGlyph.properties.scale).toBe(1.2);
// unrelated tweens still present
expect(reparsed.animations).toHaveLength(3);
});
it("update-meta edits duration/ease/position in place", () => {
const parsed = parseGsapScript(REAL_WORLD_SCRIPT);
const kickerAnim = parsed.animations.find((a) => a.targetSelector === ".co-kicker")!;
const updated = updateAnimationInScript(REAL_WORLD_SCRIPT, kickerAnim.id, {
duration: 0.9,
ease: "power1.in",
});
const reparsed = parseGsapScript(updated);
const reKicker = reparsed.animations.find((a) => a.targetSelector === ".co-kicker")!;
expect(reKicker.duration).toBe(0.9);
expect(reKicker.ease).toBe("power1.in");
// surrounding code intact
expect(updated).toContain("gsap.set(kicker, { y: 16, opacity: 0 })");
});
it("removeAnimationFromScript removes one tween and keeps the rest + setup", () => {
const parsed = parseGsapScript(REAL_WORLD_SCRIPT);
const glyphAnim = parsed.animations.find((a) => a.targetSelector === ".co-new")!;
const updated = removeAnimationFromScript(REAL_WORLD_SCRIPT, glyphAnim.id);
const reparsed = parseGsapScript(updated);
expect(reparsed.animations.map((a) => a.targetSelector)).toEqual([".co-kicker", ".co-item"]);
// the removed tween's gsap.set setup is left untouched (not the parser's job to remove)
expect(updated).toContain('const kicker = root.querySelector(".co-kicker")');
expect(updated).toContain('window.__timelines["cold-open"] = tl;');
});
it("addAnimationToScript inserts a tween and preserves the IIFE body", () => {
const { script: updated, id } = addAnimationToScript(REAL_WORLD_SCRIPT, {
targetSelector: "#new-el",
method: "to",
position: 3,
duration: 0.5,
ease: "power2.out",
properties: { opacity: 1 },
});
expect(id).not.toBe("");
expect(updated).toContain('window.__timelines["cold-open"] = tl;');
expect(updated).toContain('const kicker = root.querySelector(".co-kicker")');
const reparsed = parseGsapScript(updated);
expect(reparsed.animations.some((a) => a.targetSelector === "#new-el")).toBe(true);
expect(reparsed.animations).toHaveLength(4);
});
it("still edits classic string-literal timelines in place", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el1", { opacity: 1, duration: 0.5 }, 0);
tl.to("#el2", { x: 100, duration: 1 }, 1);
`;
const parsed = parseGsapScript(script);
const updated = updateAnimationInScript(script, parsed.animations[0].id, {
properties: { opacity: 0.25 },
});
expect(updated).toContain("opacity: 0.25");
// second tween untouched
expect(updated).toContain('tl.to("#el2", { x: 100, duration: 1 }, 1)');
});
});
+313 -48
View File
@@ -10,12 +10,7 @@
*/
import * as recast from "recast";
import { parse as babelParse } from "@babel/parser";
import {
type GsapAnimation,
type GsapMethod,
type ParsedGsap,
serializeGsapAnimations,
} from "./gsapSerialize";
import { type GsapAnimation, type GsapMethod, type ParsedGsap } from "./gsapSerialize";
export type { GsapAnimation, GsapMethod, ParsedGsap } from "./gsapSerialize";
export {
@@ -109,6 +104,81 @@ function extractLiteralValue(node: any, scope: ScopeBindings): unknown {
return resolveNode(node, scope);
}
// ── Element-target resolution ───────────────────────────────────────────────
//
// Real compositions target tweens through element variables resolved from the
// DOM (`const kicker = root.querySelector(".kicker"); tl.to(kicker, …)`) rather
// than inline string selectors. To make those tweens editable we map each such
// variable back to the CSS selector it was queried with.
const QUERY_METHODS = new Set(["querySelector", "querySelectorAll"]);
/**
* If `node` is a DOM lookup call (`x.querySelector(".sel")`,
* `document.querySelectorAll(".sel")`, `document.getElementById("id")`),
* return the CSS selector it resolves to. `getElementById("id")` maps to
* `#id`. Returns null for anything else.
*/
function selectorFromQueryCall(node: any, scope: ScopeBindings): string | null {
if (node?.type !== "CallExpression") return null;
const callee = node.callee;
if (callee?.type !== "MemberExpression" || callee.property?.type !== "Identifier") return null;
const method = callee.property.name;
const argValue = resolveNode(node.arguments?.[0], scope);
if (typeof argValue !== "string" || argValue.length === 0) return null;
if (QUERY_METHODS.has(method)) return argValue;
if (method === "getElementById") return `#${argValue}`;
return null;
}
type TargetBindings = ReadonlyMap<string, string>;
/** Map element variables (assigned from a DOM lookup) to their CSS selector. */
function collectTargetBindings(ast: any, scope: ScopeBindings): TargetBindings {
const bindings = new Map<string, string>();
recast.types.visit(ast, {
visitVariableDeclarator(path: any) {
const name = path.node.id?.name;
const selector = selectorFromQueryCall(path.node.init, scope);
if (name && selector !== null) bindings.set(name, selector);
this.traverse(path);
},
visitAssignmentExpression(path: any) {
const left = path.node.left;
const selector = selectorFromQueryCall(path.node.right, scope);
if (left?.type === "Identifier" && selector !== null && !bindings.has(left.name)) {
bindings.set(left.name, selector);
}
this.traverse(path);
},
});
return bindings;
}
/**
* Resolve a tween's first argument to a CSS selector. Handles inline string
* literals, element variables (via {@link collectTargetBindings}), and inline
* DOM lookup calls. Returns null when the target can't be resolved statically
* (e.g. an object-target duration anchor `tl.to({ _: 0 }, …)`).
*/
function resolveTargetSelector(
node: any,
scope: ScopeBindings,
targetBindings: TargetBindings,
): string | null {
if (!node) return null;
if (node.type === "StringLiteral" || node.type === "Literal") {
return typeof node.value === "string" ? node.value : null;
}
if (node.type === "Identifier") {
return targetBindings.get(node.name) ?? null;
}
if (node.type === "CallExpression") {
return selectorFromQueryCall(node, scope);
}
return null;
}
function objectExpressionToRecord(node: any, scope: ScopeBindings): Record<string, unknown> {
const result: Record<string, unknown> = {};
if (node?.type !== "ObjectExpression") return result;
@@ -180,7 +250,12 @@ interface TweenCallInfo {
positionArg?: any;
}
function findAllTweenCalls(ast: any, timelineVar: string): TweenCallInfo[] {
function findAllTweenCalls(
ast: any,
timelineVar: string,
scope: ScopeBindings,
targetBindings: TargetBindings,
): TweenCallInfo[] {
const results: TweenCallInfo[] = [];
recast.types.visit(ast, {
visitCallExpression(path: any) {
@@ -202,11 +277,7 @@ function findAllTweenCalls(ast: any, timelineVar: string): TweenCallInfo[] {
this.traverse(path);
return;
}
const selectorArg = args[0];
const selectorValue =
selectorArg.type === "StringLiteral" || selectorArg.type === "Literal"
? String(selectorArg.value)
: null;
const selectorValue = resolveTargetSelector(args[0], scope, targetBindings);
if (!selectorValue) {
this.traverse(path);
return;
@@ -348,16 +419,45 @@ function assignStableIds(anims: Omit<GsapAnimation, "id">[]): GsapAnimation[] {
});
}
// ── Shared parse (AST + located tween calls) ────────────────────────────────
interface ParsedGsapAst {
ast: any;
scope: ScopeBindings;
timelineVar: string;
detection: TimelineDetection;
/** Tween calls in document order, each paired with its stable animation id. */
located: Array<{ id: string; call: TweenCallInfo; animation: GsapAnimation }>;
}
/**
* Parse a script to its recast AST plus the located tween calls. The mutation
* functions reuse this so they can edit the exact call node in place (recast
* preserves all surrounding source — interleaved `gsap.set`, element variable
* declarations, the IIFE wrapper, comments and formatting).
*/
function parseGsapAst(script: string): ParsedGsapAst {
const ast = parseScript(script);
const scope = collectScopeBindings(ast);
const targetBindings = collectTargetBindings(ast, scope);
const detection = findTimelineVar(ast);
const timelineVar = detection.timelineVar ?? "tl";
const calls = findAllTweenCalls(ast, timelineVar, scope, targetBindings);
const animations = assignStableIds(calls.map((call) => tweenCallToAnimation(call, scope)));
const located = animations.map((animation, i) => ({
id: animation.id,
call: calls[i]!,
animation,
}));
return { ast, scope, timelineVar, detection, located };
}
// ── Public API ──────────────────────────────────────────────────────────────
export function parseGsapScript(script: string): ParsedGsap {
try {
const ast = parseScript(script);
const scope = collectScopeBindings(ast);
const detection = findTimelineVar(ast);
const timelineVar = detection.timelineVar ?? "tl";
const calls = findAllTweenCalls(ast, timelineVar);
const animations = assignStableIds(calls.map((call) => tweenCallToAnimation(call, scope)));
const { detection, timelineVar, located } = parseGsapAst(script);
const animations = located.map((l) => l.animation);
const timelineMatch = script.match(
new RegExp(
@@ -387,9 +487,136 @@ export function parseGsapScript(script: string): ParsedGsap {
}
}
/** Returns true when the parse result is a failure fallback (no animations, no preamble). */
function isParseFailure(parsed: ParsedGsap): boolean {
return parsed.animations.length === 0 && !parsed.preamble;
// ── In-place AST mutation helpers ───────────────────────────────────────────
//
// Edits operate directly on the located call's AST node and reprint via recast,
// which preserves every untouched statement. This is what lets us edit tweens
// in real compositions (variable targets, interleaved `gsap.set`, IIFE wrapper)
// without regenerating — and discarding — the surrounding code.
/** Render a model value to the JS source it should emit as. Mirrors gsapSerialize. */
function valueToCode(value: number | string): string {
if (typeof value === "string" && value.startsWith("__raw:")) return value.slice(6);
if (typeof value === "string") return JSON.stringify(value);
return String(value);
}
function safeKey(key: string): string {
return /^[A-Za-z_$][\w$]*$/.test(key) ? key : JSON.stringify(key);
}
/**
* Parse a value/expression snippet into a standalone AST expression node.
* Uses an assignment (`__hf__ = <code>`) rather than wrapping in parens so an
* object literal parses as an expression without recast re-emitting the
* surrounding parentheses.
*/
function parseExpr(code: string): any {
return parseScript(`__hf__ = ${code};`).program.body[0].expression.right;
}
function propKeyName(prop: any): string | undefined {
return prop?.key?.name ?? prop?.key?.value;
}
function isObjectProperty(prop: any): boolean {
return prop?.type === "ObjectProperty" || prop?.type === "Property";
}
/** A key the inspector treats as an editable transform/style property. */
function isEditablePropertyKey(key: string): boolean {
return !BUILTIN_VAR_KEYS.has(key) && !DROPPED_VAR_KEYS.has(key) && !EXTRAS_KEYS.has(key);
}
function makeObjectProperty(key: string, value: number | string): any {
const obj = parseExpr(`{ ${safeKey(key)}: ${valueToCode(value)} }`);
return obj.properties[0];
}
/** Set (or insert) a single key on an ObjectExpression, preserving sibling keys. */
function setVarsKey(varsArg: any, key: string, value: number | string): void {
if (varsArg?.type !== "ObjectExpression") return;
const existing = varsArg.properties.find(
(p: any) => isObjectProperty(p) && propKeyName(p) === key,
);
if (existing) {
existing.value = parseExpr(valueToCode(value));
} else {
varsArg.properties.push(makeObjectProperty(key, value));
}
}
/**
* Replace the editable-property keys on an ObjectExpression with `newProps`,
* leaving `duration`, `ease`, `stagger`, callbacks and other non-editable keys
* untouched.
*/
function reconcileEditableProperties(
varsArg: any,
newProps: Record<string, number | string>,
): void {
if (varsArg?.type !== "ObjectExpression") return;
// Drop editable props no longer present.
varsArg.properties = varsArg.properties.filter((p: any) => {
if (!isObjectProperty(p)) return true;
const key = propKeyName(p);
if (typeof key !== "string") return true;
if (!isEditablePropertyKey(key)) return true;
return key in newProps;
});
// Upsert each new prop, preserving the order keys first appeared.
for (const [key, value] of Object.entries(newProps)) {
setVarsKey(varsArg, key, value);
}
}
function applyUpdatesToCall(call: TweenCallInfo, updates: Partial<GsapAnimation>): void {
if (updates.properties) reconcileEditableProperties(call.varsArg, updates.properties);
if (updates.fromProperties && call.method === "fromTo") {
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.position !== undefined) {
const posIdx = call.method === "fromTo" ? 3 : 2;
call.node.arguments[posIdx] = parseExpr(valueToCode(updates.position));
}
}
/** Walk up to the enclosing ExpressionStatement path (for prune / insertAfter). */
function findStatementPath(path: any): any {
let p = path;
while (p) {
if (p.node?.type === "ExpressionStatement") return p;
p = p.parentPath;
}
return null;
}
/** Build the source for a single `tl.method(selector, vars, position)` call. */
function buildTweenStatementCode(timelineVar: string, anim: Omit<GsapAnimation, "id">): string {
const selector = JSON.stringify(anim.targetSelector);
const props: Record<string, number | string> = { ...anim.properties };
if (anim.duration !== undefined) props.duration = anim.duration;
if (anim.ease) props.ease = anim.ease;
const entries = Object.entries(props).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`);
if (anim.extras) {
for (const [k, v] of Object.entries(anim.extras)) {
entries.push(`${safeKey(k)}: ${valueToCode(v as number | string)}`);
}
}
const objCode = `{ ${entries.join(", ")} }`;
const posCode = valueToCode(
typeof anim.position === "number" ? anim.position : (anim.position ?? 0),
);
if (anim.method === "fromTo") {
const fromEntries = Object.entries(anim.fromProperties ?? {}).map(
([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`,
);
const fromCode = `{ ${fromEntries.join(", ")} }`;
return `${timelineVar}.fromTo(${selector}, ${fromCode}, ${objCode}, ${posCode});`;
}
return `${timelineVar}.${anim.method}(${selector}, ${objCode}, ${posCode});`;
}
export function updateAnimationInScript(
@@ -397,41 +624,79 @@ export function updateAnimationInScript(
animationId: string,
updates: Partial<GsapAnimation>,
): string {
const parsed = parseGsapScript(script);
if (isParseFailure(parsed)) return script;
const updated = parsed.animations.map((anim) =>
anim.id === animationId ? { ...anim, ...updates } : anim,
);
return serializeGsapAnimations(updated, parsed.timelineVar, {
preamble: parsed.preamble,
postamble: parsed.postamble,
});
let parsed: ParsedGsapAst;
try {
parsed = parseGsapAst(script);
} catch {
return script;
}
const target = parsed.located.find((l) => l.id === animationId);
if (!target) return script;
applyUpdatesToCall(target.call, updates);
return recast.print(parsed.ast).code;
}
export function addAnimationToScript(
script: string,
animation: Omit<GsapAnimation, "id">,
): { script: string; id: string } {
const parsed = parseGsapScript(script);
if (isParseFailure(parsed)) return { script, id: "" };
let parsed: ParsedGsapAst;
try {
parsed = parseGsapAst(script);
} catch {
return { script, id: "" };
}
// Nothing to anchor against and no timeline to target — treat as parse failure.
if (parsed.located.length === 0 && parsed.detection.timelineVar === null) {
return { script, id: "" };
}
const id = `anim-${Date.now()}`;
const newAnim: GsapAnimation = { ...animation, id };
const allAnimations = [...parsed.animations, newAnim];
return {
script: serializeGsapAnimations(allAnimations, parsed.timelineVar, {
preamble: parsed.preamble,
postamble: parsed.postamble,
}),
id,
};
const statementCode = buildTweenStatementCode(parsed.timelineVar, animation);
const newStatement = parseScript(statementCode).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);
}
return { script: recast.print(parsed.ast).code, id };
}
/** Find the statement path of `const <timelineVar> = gsap.timeline(...)`. */
function findTimelineDeclarationPath(ast: any, timelineVar: string): any {
let found: any = null;
recast.types.visit(ast, {
visitVariableDeclaration(path: any) {
if (found) return false;
for (const decl of path.node.declarations ?? []) {
if (decl.id?.name === timelineVar && isGsapTimelineCall(decl.init)) {
found = path;
return false;
}
}
this.traverse(path);
},
});
return found;
}
export function removeAnimationFromScript(script: string, animationId: string): string {
const parsed = parseGsapScript(script);
if (isParseFailure(parsed)) return script;
const filtered = parsed.animations.filter((a) => a.id !== animationId);
return serializeGsapAnimations(filtered, parsed.timelineVar, {
preamble: parsed.preamble,
postamble: parsed.postamble,
});
let parsed: ParsedGsapAst;
try {
parsed = parseGsapAst(script);
} catch {
return script;
}
const target = parsed.located.find((l) => l.id === animationId);
if (!target) return script;
const stmtPath = findStatementPath(target.call.path);
if (!stmtPath) return script;
stmtPath.prune();
return recast.print(parsed.ast).code;
}