mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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:
@@ -79,7 +79,7 @@ export type { GsapAnimation, GsapMethod, ParsedGsap } from "./parsers/gsapSerial
|
||||
|
||||
export {
|
||||
serializeGsapAnimations,
|
||||
getAnimationsForElement,
|
||||
getAnimationsForElementId,
|
||||
validateCompositionGsap,
|
||||
keyframesToGsapAnimations,
|
||||
gsapAnimationsToKeyframes,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
Reference in New Issue
Block a user