fix(studio): cover GSAP editor target-resolution limitations (#1116)

Follow-up to #1115. Makes the Design-panel editor recognise every target
shape real compositions use. The panel stays behind STUDIO_GSAP_PANEL_ENABLED
(default off) — no flag change here.

- Array targets: tl.to([a, b], {...}) resolves to a CSS group selector
  (".a, .b"). The source array is never rewritten — the joined string is for
  display/matching only; edits still touch just the vars object.

- Chained calls: tl.to(a, ...).to(b, ...) — the matcher now walks the member
  chain to its timeline root, so every link is captured (previously only the
  first). Deletion is chain-aware: it splices out the single targeted link and
  re-points the chain instead of dropping the whole statement.

- gsap.utils.toArray("sel") resolves like querySelectorAll, inline or via a
  variable binding.

- Lexical scoping: element-variable resolution is now per-scope (walks the
  enclosing function/program chain) instead of a flat map. Fixes silent
  wrong-resolution when two IIFEs reuse a variable name, and unlocks
  multi-scene files. (Addresses review: flat-binding-scope.)

- forEach/map callback params (items.forEach(el => tl.to(el, …))) and items[i]
  indexing resolve to the collection's selector, so loop-generated tweens are
  editable.

- Panel matching: an element matches a tween when its id/selector is any member
  of a comma-group target, so either element of an array/toArray tween surfaces
  the shared animation.

- Review items: mutation parse failures now console.warn instead of swallowing
  silently; buildTweenStatementCode no longer emits duration on `set`; the
  id-only serialize-side filter is renamed getAnimationsForElementId to
  disambiguate from the panel's id-or-selector matcher; added fromTo round-trip
  and variable-target overlap-lint tests.

Genuinely runtime-only targets (template-literal selectors, unbounded loops)
still skip gracefully — they can't be resolved or matched statically.
This commit is contained in:
Miguel Ángel
2026-05-28 20:57:59 -04:00
committed by GitHub
parent 4de054e7d4
commit 789d1d4775
8 changed files with 413 additions and 49 deletions
+1 -1
View File
@@ -79,7 +79,7 @@ export type { GsapAnimation, GsapMethod, ParsedGsap } from "./parsers/gsapSerial
export {
serializeGsapAnimations,
getAnimationsForElement,
getAnimationsForElementId,
validateCompositionGsap,
keyframesToGsapAnimations,
gsapAnimationsToKeyframes,
+24
View File
@@ -629,6 +629,30 @@ describe("GSAP rules", () => {
expect(finding).toBeUndefined();
});
it("detects overlapping_gsap_tweens between variable-target tweens", async () => {
// Both tweens target the same element via a querySelector variable and their
// windows overlap on `opacity`. The structure-driven window builder must see
// through the variable target to flag the conflict.
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
<div id="hero" class="hero" data-start="0" data-duration="5" data-track-index="0"></div>
</div>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
const hero = document.querySelector("#hero");
tl.to(hero, { opacity: 1, duration: 1 }, 0);
tl.to(hero, { opacity: 0.5, duration: 1 }, 0.5);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "overlapping_gsap_tweens");
expect(finding).toBeDefined();
});
it("warns when an opacity exit ends at a clip start boundary without a hard kill", async () => {
const html = `
<html><body>
@@ -338,21 +338,20 @@ describe("5. Deeply nested objects", () => {
// ── 6. Chained Method Calls ────────────────────────────────────────────────
describe("6. Chained method calls", () => {
it("chained tl.to().to().from() — only top-level calls detected", () => {
// Chaining like tl.to(...).to(...) means the second .to() is called on the
// return value of the first .to(), which is the timeline itself. However,
// the parser checks `callee.object.name === timelineVar`, so chained calls
// where the callee.object is a CallExpression (not an Identifier) are skipped.
it("chained tl.to().to().from() — every link is detected", () => {
// Each link of a chain is called on the return value of the previous one
// (ultimately the timeline). The parser walks the member chain to its root,
// so every link is captured, not just the first.
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#a", { x: 100, duration: 0.5 }, 0).to("#b", { y: 200, duration: 0.5 }, 1).from("#c", { scale: 0, duration: 1 }, 2);
`;
const result = parseGsapScript(script);
// Only the first call in the chain has `tl` as the callee object directly
// The rest are chained on the return value — parser may or may not catch them
expect(result.animations.length).toBeGreaterThanOrEqual(1);
expect(result.animations[0].targetSelector).toBe("#a");
expect(result.animations[0].properties.x).toBe(100);
expect(result.animations).toHaveLength(3);
const bySelector = Object.fromEntries(result.animations.map((a) => [a.targetSelector, a]));
expect(bySelector["#a"]?.properties.x).toBe(100);
expect(bySelector["#b"]?.properties.y).toBe(200);
expect(bySelector["#c"]?.method).toBe("from");
});
it("separate statements all parse", () => {
+168 -4
View File
@@ -6,7 +6,7 @@ import {
SUPPORTED_EASES,
serializeGsapAnimations,
validateCompositionGsap,
getAnimationsForElement,
getAnimationsForElementId,
keyframesToGsapAnimations,
addAnimationToScript,
removeAnimationFromScript,
@@ -679,7 +679,7 @@ describe("validateCompositionGsap", () => {
});
});
describe("getAnimationsForElement", () => {
describe("getAnimationsForElementId", () => {
it("filters animations by element id", () => {
const animations: GsapAnimation[] = [
{ id: "a1", targetSelector: "#el1", method: "set", position: 0, properties: { opacity: 0 } },
@@ -701,7 +701,7 @@ describe("getAnimationsForElement", () => {
},
];
const result = getAnimationsForElement(animations, "el1");
const result = getAnimationsForElementId(animations, "el1");
expect(result).toHaveLength(2);
expect(result.every((a) => a.targetSelector === "#el1")).toBe(true);
});
@@ -711,7 +711,7 @@ describe("getAnimationsForElement", () => {
{ id: "a1", targetSelector: "#el1", method: "set", position: 0, properties: { opacity: 0 } },
];
const result = getAnimationsForElement(animations, "el99");
const result = getAnimationsForElementId(animations, "el99");
expect(result).toHaveLength(0);
});
});
@@ -1021,3 +1021,167 @@ describe("in-place AST mutation preserves surrounding code", () => {
expect(updated).toContain('tl.to("#el2", { x: 100, duration: 1 }, 1)');
});
});
// ── Advanced target resolution + chained calls (editor limitations) ─────────
describe("array targets", () => {
it("resolves an array of element variables to a CSS group selector", () => {
const script = `
const root = document.querySelector('#s');
const face = root.querySelector(".clock-face");
const hand = root.querySelector(".clock-hand");
const tl = gsap.timeline({ paused: true });
tl.to([face, hand], { opacity: 1, duration: 0.5 }, 0);
`;
const result = parseGsapScript(script);
expect(result.animations).toHaveLength(1);
expect(result.animations[0].targetSelector).toBe(".clock-face, .clock-hand");
});
it("does not rewrite the array argument when editing the tween", () => {
const script = `
const a = document.querySelector(".a");
const b = document.querySelector(".b");
const tl = gsap.timeline({ paused: true });
tl.to([a, b], { opacity: 1, duration: 0.5 }, 0);
`;
const parsed = parseGsapScript(script);
const updated = updateAnimationInScript(script, parsed.animations[0].id, {
properties: { opacity: 0.3 },
});
expect(updated).toContain("tl.to([a, b],");
expect(updated).toContain("opacity: 0.3");
});
});
describe("chained tween calls", () => {
const CHAIN = `
const tl = gsap.timeline({ paused: true });
const flash = document.querySelector(".flash");
tl.to(flash, { opacity: 0.5, duration: 0.16 }, 2.06)
.to(flash, { opacity: 0, duration: 0.5 }, 2.22);
`;
it("captures every link of a chained call", () => {
const result = parseGsapScript(CHAIN);
expect(result.animations).toHaveLength(2);
expect(result.animations.every((a) => a.targetSelector === ".flash")).toBe(true);
expect(result.animations.map((a) => a.position).sort()).toEqual([2.06, 2.22]);
});
it("edits one link of a chain in place, leaving the other intact", () => {
const parsed = parseGsapScript(CHAIN);
const second = parsed.animations.find((a) => a.position === 2.22)!;
const updated = updateAnimationInScript(CHAIN, second.id, { properties: { opacity: 0.9 } });
expect(updated).toContain("opacity: 0.9");
expect(updated).toContain("opacity: 0.5"); // first link untouched
});
it("deletes one link of a chain, keeping the other (chain-aware removal)", () => {
const parsed = parseGsapScript(CHAIN);
const first = parsed.animations.find((a) => a.position === 2.06)!;
const updated = removeAnimationFromScript(CHAIN, first.id);
const reparsed = parseGsapScript(updated);
expect(reparsed.animations).toHaveLength(1);
expect(reparsed.animations[0].position).toBe(2.22);
});
});
describe("gsap.utils.toArray targets", () => {
it("resolves an inline toArray selector", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to(gsap.utils.toArray(".item"), { opacity: 1, duration: 0.5, stagger: 0.1 }, 0);
`;
const result = parseGsapScript(script);
expect(result.animations).toHaveLength(1);
expect(result.animations[0].targetSelector).toBe(".item");
});
it("resolves a toArray result stored in a variable", () => {
const script = `
const items = gsap.utils.toArray(".item");
const tl = gsap.timeline({ paused: true });
tl.to(items, { opacity: 1, duration: 0.5 }, 0);
`;
const result = parseGsapScript(script);
expect(result.animations[0].targetSelector).toBe(".item");
});
});
describe("lexical scoping of element bindings", () => {
it("resolves the same variable name to different selectors per IIFE scope", () => {
const script = `
(function () {
const tl = gsap.timeline({ paused: true });
const kicker = document.querySelector(".scene-a-kicker");
tl.to(kicker, { opacity: 1, duration: 0.5 }, 0);
})();
(function () {
const tl = gsap.timeline({ paused: true });
const kicker = document.querySelector(".scene-b-kicker");
tl.to(kicker, { opacity: 1, duration: 0.5 }, 0);
})();
`;
const result = parseGsapScript(script);
const selectors = result.animations.map((a) => a.targetSelector);
expect(selectors).toContain(".scene-a-kicker");
expect(selectors).toContain(".scene-b-kicker");
});
});
describe("forEach / map callback targets", () => {
it("resolves a forEach callback param to the collection's selector", () => {
const script = `
const tl = gsap.timeline({ paused: true });
const items = document.querySelectorAll(".item");
items.forEach((el) => {
tl.to(el, { opacity: 1, duration: 0.4 }, 0);
});
`;
const result = parseGsapScript(script);
expect(result.animations).toHaveLength(1);
expect(result.animations[0].targetSelector).toBe(".item");
});
it("resolves an inline querySelectorAll().forEach callback param", () => {
const script = `
const tl = gsap.timeline({ paused: true });
document.querySelectorAll(".dot").forEach((dot) => {
tl.to(dot, { scale: 1, duration: 0.3 }, 0);
});
`;
const result = parseGsapScript(script);
expect(result.animations[0].targetSelector).toBe(".dot");
});
});
describe("fromTo in-place mutation", () => {
const FROMTO = `
const tl = gsap.timeline({ paused: true });
const ring = document.querySelector(".ring");
tl.fromTo(ring, { scale: 0.6, opacity: 0.65 }, { scale: 2.2, opacity: 0, duration: 0.8 }, 2.08);
`;
it("edits the to-vars of a fromTo in place", () => {
const parsed = parseGsapScript(FROMTO);
const updated = updateAnimationInScript(FROMTO, parsed.animations[0].id, {
properties: { scale: 3, opacity: 0 },
});
expect(updated).toContain("scale: 3");
// from-vars left intact, target not flattened
expect(updated).toContain("{ scale: 0.6, opacity: 0.65 }");
expect(updated).toContain("tl.fromTo(ring,");
});
it("edits the from-vars of a fromTo in place", () => {
const parsed = parseGsapScript(FROMTO);
const updated = updateAnimationInScript(FROMTO, parsed.animations[0].id, {
fromProperties: { scale: 0.2, opacity: 1 },
});
const reparsed = parseGsapScript(updated);
expect(reparsed.animations[0].fromProperties?.scale).toBe(0.2);
// to-vars untouched
expect(reparsed.animations[0].properties.scale).toBe(2.2);
});
});
+188 -29
View File
@@ -15,7 +15,7 @@ import { type GsapAnimation, type GsapMethod, type ParsedGsap } from "./gsapSeri
export type { GsapAnimation, GsapMethod, ParsedGsap } from "./gsapSerialize";
export {
serializeGsapAnimations,
getAnimationsForElement,
getAnimationsForElementId,
validateCompositionGsap,
keyframesToGsapAnimations,
gsapAnimationsToKeyframes,
@@ -107,17 +107,27 @@ function extractLiteralValue(node: any, scope: ScopeBindings): unknown {
// ── 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.
// DOM (`const kicker = root.querySelector(".kicker"); tl.to(kicker, …)`), arrays
// of them (`tl.to([a, b], …)`), `gsap.utils.toArray(".sel")`, and per-element
// loop variables (`items.forEach(el => tl.to(el, …))`) — not inline string
// selectors. To make those tweens editable we resolve each target back to the
// CSS selector(s) it addresses. Resolution is lexically scoped: the same
// variable name can mean different elements in different IIFEs.
const QUERY_METHODS = new Set(["querySelector", "querySelectorAll"]);
const ITERATION_METHODS = new Set(["forEach", "map"]);
const SCOPE_NODE_TYPES = new Set([
"Program",
"FunctionDeclaration",
"FunctionExpression",
"ArrowFunctionExpression",
]);
/**
* 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.
* If `node` is a DOM lookup call `x.querySelector(".sel")`,
* `document.querySelectorAll(".sel")`, `document.getElementById("id")`, or
* `gsap.utils.toArray(".sel")` 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;
@@ -126,56 +136,162 @@ function selectorFromQueryCall(node: any, scope: ScopeBindings): string | 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 (QUERY_METHODS.has(method) || method === "toArray") return argValue;
if (method === "getElementById") return `#${argValue}`;
return null;
}
type TargetBindings = ReadonlyMap<string, string>;
/** The nearest enclosing function/program node — the binding scope of `path`. */
function enclosingScopeNode(path: any): any {
let p = path?.parentPath;
while (p) {
if (SCOPE_NODE_TYPES.has(p.node?.type)) return p.node;
p = p.parentPath;
}
return null;
}
/** Map element variables (assigned from a DOM lookup) to their CSS selector. */
/** Scope nodes enclosing `path`, innermost first. */
function scopeChainOf(path: any): any[] {
const chain: any[] = [];
let p = path;
while (p) {
if (SCOPE_NODE_TYPES.has(p.node?.type)) chain.push(p.node);
p = p.parentPath;
}
return chain;
}
/** Per-scope element bindings: scopeNode → (variable name → selector). */
type TargetBindings = Map<any, Map<string, string>>;
function addBinding(
bindings: TargetBindings,
scopeNode: any,
name: string,
selector: string,
): void {
let scoped = bindings.get(scopeNode);
if (!scoped) {
scoped = new Map();
bindings.set(scopeNode, scoped);
}
if (!scoped.has(name)) scoped.set(name, selector);
}
/**
* Build a lexically-scoped index of element variables selector. Two passes:
* (1) direct DOM-lookup assignments (`const x = root.querySelector(...)`), then
* (2) iteration callback params (`coll.forEach(el => …)`), whose element type is
* the collection's selector resolved against the pass-1 bindings.
*/
function collectTargetBindings(ast: any, scope: ScopeBindings): TargetBindings {
const bindings = new Map<string, string>();
const bindings: TargetBindings = new Map();
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);
if (name && selector !== null) addBinding(bindings, enclosingScopeNode(path), 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);
if (left?.type === "Identifier" && selector !== null) {
addBinding(bindings, enclosingScopeNode(path), left.name, selector);
}
this.traverse(path);
},
});
// Pass 2: forEach/map callback params take the collection's selector.
recast.types.visit(ast, {
visitCallExpression(path: any) {
const node = path.node;
const callee = node.callee;
if (
callee?.type === "MemberExpression" &&
callee.property?.type === "Identifier" &&
ITERATION_METHODS.has(callee.property.name)
) {
const collectionSelector = resolveCollectionSelector(callee.object, path, scope, bindings);
const fn = node.arguments?.[0];
const param = fn?.params?.[0];
if (collectionSelector && param?.type === "Identifier" && isFunctionNode(fn)) {
addBinding(bindings, fn, param.name, collectionSelector);
}
}
this.traverse(path);
},
});
return bindings;
}
function isFunctionNode(node: any): boolean {
return (
node?.type === "ArrowFunctionExpression" ||
node?.type === "FunctionExpression" ||
node?.type === "FunctionDeclaration"
);
}
/** Resolve the selector a `.forEach`/`.map` is iterating over (variable or inline call). */
function resolveCollectionSelector(
node: any,
callPath: any,
scope: ScopeBindings,
bindings: TargetBindings,
): string | null {
if (node?.type === "Identifier") return lookupBinding(node.name, callPath, bindings);
if (node?.type === "CallExpression") return selectorFromQueryCall(node, scope);
return null;
}
/** Resolve a variable name to its selector using the lexical scope chain of `path`. */
function lookupBinding(name: string, path: any, bindings: TargetBindings): string | null {
for (const scopeNode of scopeChainOf(path)) {
const selector = bindings.get(scopeNode)?.get(name);
if (selector !== undefined) return selector;
}
return null;
}
/**
* 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 }, …)`).
* literals, element variables (lexically scoped), arrays of elements (joined
* into a CSS group selector), inline DOM lookup / `toArray` calls, and indexed
* access (`items[i]`). Returns null when the target can't be resolved
* statically (e.g. an object-target duration anchor `tl.to({ _: 0 }, …)`, or a
* runtime-computed selector).
*/
function resolveTargetSelector(
node: any,
path: any,
scope: ScopeBindings,
targetBindings: TargetBindings,
bindings: 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;
return lookupBinding(node.name, path, bindings);
}
if (node.type === "CallExpression") {
return selectorFromQueryCall(node, scope);
}
if (node.type === "ArrayExpression") {
const parts = node.elements
.map((el: any) => resolveTargetSelector(el, path, scope, bindings))
.filter((s: string | null): s is string => typeof s === "string" && s.length > 0);
return parts.length > 0 ? parts.join(", ") : null;
}
if (node.type === "MemberExpression" && node.object?.type === "Identifier") {
// `items[i]` — the element type is the collection's selector.
return lookupBinding(node.object.name, path, bindings);
}
return null;
}
@@ -250,6 +366,18 @@ interface TweenCallInfo {
positionArg?: any;
}
/**
* True when the member chain of `callNode.callee` is rooted at the timeline
* variable `tl.to(...)` and every link of a chain `tl.to(...).to(...)`.
*/
function isTimelineRootedCall(callNode: any, timelineVar: string): boolean {
let obj = callNode.callee?.object;
while (obj?.type === "CallExpression") {
obj = obj.callee?.object;
}
return obj?.type === "Identifier" && obj.name === timelineVar;
}
function findAllTweenCalls(
ast: any,
timelineVar: string,
@@ -263,9 +391,8 @@ function findAllTweenCalls(
const callee = node.callee;
if (
callee?.type === "MemberExpression" &&
callee.object?.type === "Identifier" &&
callee.object.name === timelineVar &&
callee.property?.type === "Identifier"
callee.property?.type === "Identifier" &&
isTimelineRootedCall(node, timelineVar)
) {
const method = callee.property.name;
if (!GSAP_METHODS.has(method)) {
@@ -277,7 +404,7 @@ function findAllTweenCalls(
this.traverse(path);
return;
}
const selectorValue = resolveTargetSelector(args[0], scope, targetBindings);
const selectorValue = resolveTargetSelector(args[0], path, scope, targetBindings);
if (!selectorValue) {
this.traverse(path);
return;
@@ -597,7 +724,8 @@ function findStatementPath(path: any): any {
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;
// `set` is instantaneous — GSAP ignores duration on it, so don't emit one.
if (anim.method !== "set" && 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) {
@@ -627,7 +755,8 @@ export function updateAnimationInScript(
let parsed: ParsedGsapAst;
try {
parsed = parseGsapAst(script);
} catch {
} catch (e) {
console.warn("[gsap-parser] updateAnimationInScript parse failed:", e);
return script;
}
const target = parsed.located.find((l) => l.id === animationId);
@@ -643,7 +772,8 @@ export function addAnimationToScript(
let parsed: ParsedGsapAst;
try {
parsed = parseGsapAst(script);
} catch {
} catch (e) {
console.warn("[gsap-parser] addAnimationToScript parse failed:", e);
return { script, id: "" };
}
// Nothing to anchor against and no timeline to target — treat as parse failure.
@@ -686,17 +816,46 @@ function findTimelineDeclarationPath(ast: any, timelineVar: string): any {
return found;
}
/** Find the call that chains off `targetNode` (i.e. whose callee object IS it). */
function findChainParentCall(stmtNode: any, targetNode: any): any {
let found: any = null;
recast.types.visit(stmtNode, {
visitCallExpression(p: any) {
if (found) return false;
if (p.node.callee?.type === "MemberExpression" && p.node.callee.object === targetNode) {
found = p.node;
return false;
}
this.traverse(p);
},
});
return found;
}
export function removeAnimationFromScript(script: string, animationId: string): string {
let parsed: ParsedGsapAst;
try {
parsed = parseGsapAst(script);
} catch {
} catch (e) {
console.warn("[gsap-parser] removeAnimationFromScript parse failed:", e);
return script;
}
const target = parsed.located.find((l) => l.id === animationId);
if (!target) return script;
const node = target.call.node;
const stmtPath = findStatementPath(target.call.path);
if (!stmtPath) return script;
stmtPath.prune();
const parentCall = findChainParentCall(stmtPath.node, node);
if (parentCall) {
// Inner link of a chain — splice it out by re-pointing the next link.
parentCall.callee.object = node.callee.object;
} else if (node.callee?.object?.type === "CallExpression") {
// Outermost link of a chain with earlier links — drop just this link.
stmtPath.node.expression = node.callee.object;
} else {
// Standalone tween — remove the whole statement.
stmtPath.prune();
}
return recast.print(parsed.ast).code;
}
+6 -1
View File
@@ -135,7 +135,12 @@ function serializeExtras(extras: Record<string, unknown>): string {
// ── Element filtering ─────────────────────────────────────────────────────────
export function getAnimationsForElement(
/**
* Filter animations to those targeting `#<elementId>` (id-only match). For the
* studio panel's id-OR-selector matching, see `getAnimationsForElement` in
* `useGsapTweenCache.ts` distinct on purpose, hence the distinct name.
*/
export function getAnimationsForElementId(
animations: GsapAnimation[],
elementId: string,
): GsapAnimation[] {