feat(gsap): read timelines authored inline (acorn read path) (#1760)

* feat(studio): element groups — source mutations

Wrap/unwrap source mutations (group geometry, the wrap-elements / unwrap-elements
routes) that the studio group feature is built on. Studio UI lands in the next PR.

* fix(studio): hoverable group interior + non-sticky drill-in

Two group selection bugs with animated members:

1) Empty space inside a group's overlay didn't hover/select the group. Members
   animated outside the wrapper's static box (110px box vs 340px member union),
   so elementsFromPoint hit only the full-bleed background there. Add a
   member-union hit-test fallback: a point inside a group's live member bounds
   resolves to that group (innermost wins).

2) After drilling into a group and selecting a child, nothing else was
   selectable — out-of-scope resolved to null. Make drill-in non-sticky:
   interacting outside the drilled group re-resolves normally and exits the
   drill-in, so a later click on the group selects it as a unit again.

* feat(studio): enable animation editing for static inline timelines

The unsupported-pattern banner now clears for static window.__timelines["id"] =
gsap.timeline() (the parser reports it editable), and the banner copy is retargeted
to the genuinely-unsupported case: computed/dynamic keys (window.__timelines[var]).
This commit is contained in:
Miguel Ángel
2026-06-27 11:27:53 -04:00
committed by GitHub
parent 1eed7a9b1f
commit 2e02bcf77a
7 changed files with 488 additions and 67 deletions
@@ -0,0 +1,108 @@
import { describe, it, expect } from "vitest";
import {
parseGsapScript,
updateAnimationInScript,
addAnimationToScript,
removeAnimationFromScript,
addKeyframeToScript,
removeAllKeyframesFromScript,
} from "./gsapParser.js";
import { addLabelToScript, removeLabelFromScript } from "./gsapWriterAcorn.js";
// U4: recast parser/writer parity for the inline form
// `window.__timelines["scene"] = gsap.timeline()` (the default server write path).
const inlineSrc = `window.__timelines = window.__timelines || {};
window.__timelines["scene"] = gsap.timeline({ paused: true });
window.__timelines["scene"].to("#a", { x: 100, duration: 1 }, 0);
window.__timelines["scene"].to("#b", { y: 50, duration: 1 }, 0.5);`;
describe("recast — inline timeline read", () => {
it("reads inline tweens (double quote)", () => {
const p = parseGsapScript(inlineSrc);
expect(p.unsupportedTimelinePattern).toBeFalsy();
expect(p.animations).toHaveLength(2);
expect(p.animations[0]!.targetSelector).toBe("#a");
});
it("reads single-quote + dot access", () => {
const sq = `window.__timelines['s'] = gsap.timeline();\nwindow.__timelines['s'].to('#a', { x: 1, duration: 1 }, 0);`;
const dot = `window.__timelines.s = gsap.timeline();\nwindow.__timelines.s.to("#a", { x: 1, duration: 1 }, 0);`;
expect(parseGsapScript(sq).animations).toHaveLength(1);
expect(parseGsapScript(dot).animations).toHaveLength(1);
});
it("flags computed key as unsupported", () => {
const c = `const id = "s";\nwindow.__timelines[id] = gsap.timeline();\nwindow.__timelines[id].to("#a", { x: 1, duration: 1 }, 0);`;
expect(parseGsapScript(c).unsupportedTimelinePattern).toBe(true);
});
it("keeps the canonical const form unchanged", () => {
const c = `const tl = gsap.timeline();\nwindow.__timelines["s"] = tl;\ntl.to("#a", { x: 5, duration: 1 }, 0);`;
const p = parseGsapScript(c);
expect(p.timelineVar).toBe("tl");
expect(p.animations).toHaveLength(1);
});
});
describe("recast — inline timeline write", () => {
it("edits an inline tween in place", () => {
const id = parseGsapScript(inlineSrc).animations[0]!.id;
const out = updateAnimationInScript(inlineSrc, id, { properties: { x: 200 } });
expect(out).toContain('window.__timelines["scene"].to("#a"');
expect(out).toContain("200");
expect(parseGsapScript(out).animations).toHaveLength(2);
});
it("adds a tween in member form", () => {
const out = addAnimationToScript(inlineSrc, {
method: "to",
targetSelector: "#c",
properties: { opacity: 1 },
position: 1,
duration: 1,
});
const script = typeof out === "string" ? out : out.script;
expect(script).toContain('window.__timelines["scene"].to("#c"');
expect(parseGsapScript(script).animations).toHaveLength(3);
});
it("removes an inline tween", () => {
const id = parseGsapScript(inlineSrc).animations[1]!.id;
const out = removeAnimationFromScript(inlineSrc, id);
expect(out).not.toContain('"#b"');
expect(parseGsapScript(out).animations).toHaveLength(1);
});
it("adds + removes keyframes on an inline tween", () => {
const id = parseGsapScript(inlineSrc).animations[0]!.id;
const withKf = addKeyframeToScript(inlineSrc, id, 50, { x: 150 });
expect(withKf).toContain("keyframes");
expect(parseGsapScript(withKf).unsupportedTimelinePattern).toBeFalsy();
const kfId = parseGsapScript(withKf).animations[0]!.id;
const cleared = removeAllKeyframesFromScript(withKf, kfId);
expect(cleared).not.toContain("keyframes");
});
it("preserves single-quote member form on write", () => {
const sq = `window.__timelines['s'] = gsap.timeline();\nwindow.__timelines['s'].to('#a', { x: 1, duration: 1 }, 0);`;
const id = parseGsapScript(sq).animations[0]!.id;
const out = updateAnimationInScript(sq, id, { properties: { x: 9 } });
expect(out).toContain("window.__timelines['s']");
});
});
// acorn writer: inline-form label add/remove must match member-rooted callees, not
// just Identifier-rooted ones — else addLabel duplicates and removeLabel no-ops.
describe("acorn — inline timeline labels", () => {
const src = `window.__timelines["scene"] = gsap.timeline({ paused: true });
window.__timelines["scene"].to("#a", { x: 100, duration: 1 }, 0);`;
it("dedups addLabel (moves, not duplicates) and removes it on an inline timeline", () => {
let s = addLabelToScript(src, "intro", 0.5);
s = addLabelToScript(s, "intro", 0.9);
expect((s.match(/addLabel\(/g) ?? []).length).toBe(1);
expect(s).toContain('addLabel("intro", 0.9)');
expect((removeLabelFromScript(s, "intro").match(/addLabel\(/g) ?? []).length).toBe(0);
});
});
+79 -23
View File
@@ -376,12 +376,54 @@ interface TimelineDefaults {
duration?: number;
}
// `identifier` is the canonical `const tl = …` form; `member` is the inline
// `window.__timelines["scene"] = …` form (the timeline IS the member expression).
type TimelineRef = { kind: "identifier"; name: string } | { kind: "member"; node: AstNode };
interface TimelineDetection {
timelineVar: string | null;
ref: TimelineRef | null;
timelineCount: number;
defaults?: TimelineDefaults;
}
/** The static string key of a member access (`window.__timelines["scene"]` → "scene"), else null. */
function staticMemberKey(node: AstNode): string | null {
if (!node || node.type !== "MemberExpression") return null;
if (node.computed) {
const p = node.property;
if (p?.type === "StringLiteral") return p.value;
if (p?.type === "Literal" && typeof p.value === "string") return p.value;
return null;
}
return node.property?.type === "Identifier" ? node.property.name : null;
}
function isStaticMemberRef(node: AstNode): boolean {
return node?.type === "MemberExpression" && staticMemberKey(node) !== null;
}
/** Structural equality of two member accesses (object chain + static key), quote-insensitive. */
function sameMemberAccess(a: AstNode, b: AstNode): boolean {
if (a?.type !== "MemberExpression" || b?.type !== "MemberExpression") return false;
if (staticMemberKey(a) !== staticMemberKey(b) || staticMemberKey(a) === null) return false;
const ao = a.object;
const bo = b.object;
if (ao?.type === "Identifier" && bo?.type === "Identifier") return ao.name === bo.name;
if (ao?.type === "MemberExpression" && bo?.type === "MemberExpression")
return sameMemberAccess(ao, bo);
return false;
}
/** The source string a tween call roots at: identifier name, or the member source as written. */
function timelineRootSource(ref: TimelineRef): string {
return ref.kind === "identifier" ? ref.name : recast.print(ref.node).code;
}
function escapeRegExp(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function extractTimelineDefaults(
callNode: AstNode,
scope: ScopeBindings,
@@ -401,6 +443,7 @@ function extractTimelineDefaults(
function findTimelineVar(ast: AstNode, scope?: ScopeBindings): TimelineDetection {
let timelineVar: string | null = null;
let ref: TimelineRef | null = null;
let timelineCount = 0;
let defaults: TimelineDefaults | undefined;
const emptyScope: ScopeBindings = scope ?? new Map();
@@ -408,8 +451,9 @@ function findTimelineVar(ast: AstNode, scope?: ScopeBindings): TimelineDetection
visitVariableDeclarator(path: AstPath) {
if (isGsapTimelineCall(path.node.init)) {
timelineCount += 1;
if (!timelineVar) {
timelineVar = path.node.id?.name ?? null;
if (!ref && path.node.id?.type === "Identifier") {
timelineVar = path.node.id.name;
ref = { kind: "identifier", name: path.node.id.name };
defaults = extractTimelineDefaults(path.node.init, emptyScope);
}
}
@@ -418,16 +462,22 @@ function findTimelineVar(ast: AstNode, scope?: ScopeBindings): TimelineDetection
visitAssignmentExpression(path: AstPath) {
if (isGsapTimelineCall(path.node.right)) {
timelineCount += 1;
if (!timelineVar) {
if (!ref) {
const left = path.node.left;
if (left?.type === "Identifier") timelineVar = left.name;
defaults = extractTimelineDefaults(path.node.right, emptyScope);
if (left?.type === "Identifier") {
timelineVar = left.name;
ref = { kind: "identifier", name: left.name };
defaults = extractTimelineDefaults(path.node.right, emptyScope);
} else if (isStaticMemberRef(left)) {
ref = { kind: "member", node: left };
defaults = extractTimelineDefaults(path.node.right, emptyScope);
}
}
}
this.traverse(path);
},
});
return { timelineVar, timelineCount, defaults };
return { timelineVar, ref, timelineCount, defaults };
}
// ── Find All Tween Calls ────────────────────────────────────────────────────
@@ -448,17 +498,18 @@ interface TweenCallInfo {
* 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: AstNode, timelineVar: string): boolean {
function isTimelineRootedCall(callNode: AstNode, ref: TimelineRef): boolean {
let obj = callNode.callee?.object;
while (obj?.type === "CallExpression") {
obj = obj.callee?.object;
}
return obj?.type === "Identifier" && obj.name === timelineVar;
if (ref.kind === "identifier") return obj?.type === "Identifier" && obj.name === ref.name;
return sameMemberAccess(obj, ref.node);
}
function findAllTweenCalls(
ast: AstNode,
timelineVar: string,
ref: TimelineRef,
scope: ScopeBindings,
targetBindings: TargetBindings,
): TweenCallInfo[] {
@@ -484,7 +535,7 @@ function findAllTweenCalls(
if (
callee?.type === "MemberExpression" &&
callee.property?.type === "Identifier" &&
(isTimelineRootedCall(node, timelineVar) || isGlobalSet)
(isTimelineRootedCall(node, ref) || isGlobalSet)
) {
const method = callee.property.name;
if (!GSAP_METHODS.has(method)) {
@@ -1131,8 +1182,9 @@ function parseGsapAst(script: string): ParsedGsapAst {
const scope = collectScopeBindings(ast);
const targetBindings = collectTargetBindings(ast, scope);
const detection = findTimelineVar(ast, scope);
const timelineVar = detection.timelineVar ?? "tl";
const calls = findAllTweenCalls(ast, timelineVar, scope, targetBindings);
const ref: TimelineRef = detection.ref ?? { kind: "identifier", name: "tl" };
const timelineVar = timelineRootSource(ref);
const calls = findAllTweenCalls(ast, ref, scope, targetBindings);
sortBySourcePosition(calls);
const rawAnims = calls.map((call) => tweenCallToAnimation(call, scope));
applyTimelineDefaults(rawAnims, detection.defaults);
@@ -1151,15 +1203,19 @@ function parseGsapAst(script: string): ParsedGsapAst {
export function parseGsapScript(script: string): ParsedGsap {
try {
const { detection, timelineVar, located } = parseGsapAst(script);
const ref: TimelineRef = detection.ref ?? { kind: "identifier", name: "tl" };
const animations = located.map((l) => l.animation);
const timelineMatch = script.match(
new RegExp(
`^[\\s\\S]*?(?:const|let|var)\\s+${timelineVar}\\s*=\\s*gsap\\.timeline\\s*\\([^)]*\\)\\s*;?`,
),
);
const preamble =
timelineMatch?.[0] ?? `const ${timelineVar} = gsap.timeline({ paused: true });`;
const declPattern =
ref.kind === "identifier"
? `(?:const|let|var)\\s+${timelineVar}\\s*=\\s*gsap\\.timeline\\s*\\([^)]*\\)\\s*;?`
: `${escapeRegExp(timelineVar)}\\s*=\\s*gsap\\.timeline\\s*\\([^)]*\\)\\s*;?`;
const timelineMatch = script.match(new RegExp(`^[\\s\\S]*?${declPattern}`));
const fallbackPreamble =
ref.kind === "identifier"
? `const ${timelineVar} = gsap.timeline({ paused: true });`
: `${timelineVar} = gsap.timeline({ paused: true });`;
const preamble = timelineMatch?.[0] ?? fallbackPreamble;
const lastCallIdx = script.lastIndexOf(`${timelineVar}.`);
let postamble = "";
@@ -1173,7 +1229,7 @@ export function parseGsapScript(script: string): ParsedGsap {
const result: ParsedGsap = { animations, timelineVar, preamble, postamble };
if (detection.timelineCount > 1) result.multipleTimelines = true;
if (detection.timelineCount > 0 && detection.timelineVar === null)
if (detection.timelineCount > 0 && detection.ref === null)
result.unsupportedTimelinePattern = true;
return result;
} catch {
@@ -1468,7 +1524,7 @@ export function addAnimationToScript(
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) {
if (parsed.located.length === 0 && parsed.detection.ref === null) {
return { script, id: "" };
}
@@ -1500,7 +1556,7 @@ export function addAnimationWithKeyframesToScript(
console.warn("[gsap-parser] addAnimationWithKeyframesToScript parse failed:", e);
return { script, id: "" };
}
if (parsed.located.length === 0 && parsed.detection.timelineVar === null) {
if (parsed.located.length === 0 && parsed.detection.ref === null) {
return { script, id: "" };
}
@@ -2796,7 +2852,7 @@ export function addMotionPathToScript(
console.warn("[gsap-parser] addMotionPathToScript parse failed:", e);
return { script, id: null };
}
if (parsed.located.length === 0 && parsed.detection.timelineVar === null) {
if (parsed.located.length === 0 && parsed.detection.ref === null) {
return { script, id: null };
}
@@ -0,0 +1,75 @@
import { describe, it, expect } from "vitest";
import { parseGsapScriptAcorn } from "./gsapParserAcorn.js";
// U1+U2: the editor must read timelines authored inline as
// `window.__timelines["id"] = gsap.timeline()` — not just the canonical
// `const tl = gsap.timeline(); window.__timelines[id] = tl` form.
const wrap = (decl: string, tweens: string) =>
`window.__timelines = window.__timelines || {};\n${decl}\n${tweens}`;
describe("inline timeline assignment — read", () => {
it("reads tweens from a double-quoted inline timeline", () => {
const src = wrap(
`window.__timelines["scene"] = gsap.timeline({ paused: true });`,
`window.__timelines["scene"].to("#a", { x: 100, duration: 1 }, 0);\n` +
`window.__timelines["scene"].to("#b", { y: 50, duration: 1 }, 0.5);`,
);
const parsed = parseGsapScriptAcorn(src);
expect(parsed.unsupportedTimelinePattern).toBeFalsy();
expect(parsed.animations).toHaveLength(2);
expect(parsed.animations[0]!.targetSelector).toBe("#a");
expect(parsed.animations[1]!.targetSelector).toBe("#b");
});
it("reads a single-quoted inline timeline", () => {
const src = wrap(
`window.__timelines['scene'] = gsap.timeline();`,
`window.__timelines['scene'].to('#a', { x: 10, duration: 1 }, 0);`,
);
const parsed = parseGsapScriptAcorn(src);
expect(parsed.unsupportedTimelinePattern).toBeFalsy();
expect(parsed.animations).toHaveLength(1);
expect(parsed.animations[0]!.targetSelector).toBe("#a");
});
it("reads a static dot-access inline timeline", () => {
const src = wrap(
`window.__timelines.scene = gsap.timeline();`,
`window.__timelines.scene.to("#a", { x: 10, duration: 1 }, 0);`,
);
const parsed = parseGsapScriptAcorn(src);
expect(parsed.unsupportedTimelinePattern).toBeFalsy();
expect(parsed.animations).toHaveLength(1);
});
it("flags a computed-key timeline as unsupported (cannot statically resolve)", () => {
const src = wrap(
`const id = "scene";\nwindow.__timelines[id] = gsap.timeline();`,
`window.__timelines[id].to("#a", { x: 10, duration: 1 }, 0);`,
);
const parsed = parseGsapScriptAcorn(src);
expect(parsed.unsupportedTimelinePattern).toBe(true);
});
it("does not cross-attribute tweens of a different member slot", () => {
const src = wrap(
`window.__timelines["a"] = gsap.timeline();\nwindow.__timelines["b"] = gsap.timeline();`,
`window.__timelines["a"].to("#a", { x: 1, duration: 1 }, 0);\n` +
`window.__timelines["b"].to("#b", { x: 2, duration: 1 }, 0);`,
);
const parsed = parseGsapScriptAcorn(src);
// First detected timeline is "a"; only its tween should be attributed here.
expect(parsed.multipleTimelines).toBe(true);
expect(parsed.animations.some((a) => a.targetSelector === "#a")).toBe(true);
expect(parsed.animations.every((a) => a.targetSelector !== "#b")).toBe(true);
});
it("leaves the canonical const form working", () => {
const src = `const tl = gsap.timeline();\nwindow.__timelines["scene"] = tl;\ntl.to("#a", { x: 5, duration: 1 }, 0);`;
const parsed = parseGsapScriptAcorn(src);
expect(parsed.unsupportedTimelinePattern).toBeFalsy();
expect(parsed.animations).toHaveLength(1);
expect(parsed.timelineVar).toBe("tl");
});
});
+102 -31
View File
@@ -358,12 +358,57 @@ interface TimelineDefaults {
duration?: number;
}
// How the timeline is referred to in source. `identifier` is the canonical
// `const tl = …` form; `member` is the inline `window.__timelines["scene"] = …`
// form, where the timeline IS the member expression (no variable name).
type TimelineRef = { kind: "identifier"; name: string } | { kind: "member"; node: any };
interface TimelineDetection {
/** Identifier name for the canonical form, else null (member or none). */
timelineVar: string | null;
/** Structural reference: identifier OR member expression. Null when none found. */
ref: TimelineRef | null;
timelineCount: number;
defaults?: TimelineDefaults;
}
/** The static string key of a member access (`window.__timelines["scene"]` → "scene"), else null. */
function staticMemberKey(node: any): string | null {
if (!node || node.type !== "MemberExpression") return null;
if (node.computed) {
const p = node.property;
if (p?.type === "Literal" && typeof p.value === "string") return p.value;
return null; // computed non-string-literal key → not statically resolvable
}
return node.property?.type === "Identifier" ? node.property.name : null;
}
/** True when a member expression refers to a statically-resolvable timeline slot. */
function isStaticMemberRef(node: any): boolean {
return node?.type === "MemberExpression" && staticMemberKey(node) !== null;
}
/** Structural equality of two member-access nodes (object chain + static key), quote-insensitive. */
function sameMemberAccess(a: any, b: any): boolean {
if (a?.type !== "MemberExpression" || b?.type !== "MemberExpression") return false;
if (staticMemberKey(a) !== staticMemberKey(b) || staticMemberKey(a) === null) return false;
const ao = a.object;
const bo = b.object;
if (ao?.type === "Identifier" && bo?.type === "Identifier") return ao.name === bo.name;
if (ao?.type === "MemberExpression" && bo?.type === "MemberExpression")
return sameMemberAccess(ao, bo);
return false;
}
/** The source string a tween call is rooted at: identifier name, or the member source as written. */
function timelineRootSource(ref: TimelineRef, script: string): string {
return ref.kind === "identifier" ? ref.name : script.slice(ref.node.start, ref.node.end);
}
function escapeRegExp(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
// fallow-ignore-next-line complexity
function extractTimelineDefaults(
callNode: any,
@@ -388,6 +433,7 @@ function extractTimelineDefaults(
function findTimelineVar(ast: any, scope?: ScopeBindings): TimelineDetection {
let timelineVar: string | null = null;
let ref: TimelineRef | null = null;
let timelineCount = 0;
let defaults: TimelineDefaults | undefined;
const emptyScope: ScopeBindings = scope ?? new Map();
@@ -396,8 +442,9 @@ function findTimelineVar(ast: any, scope?: ScopeBindings): TimelineDetection {
VariableDeclarator(node: any) {
if (isGsapTimelineCall(node.init)) {
timelineCount += 1;
if (!timelineVar) {
timelineVar = node.id?.name ?? null;
if (!ref && node.id?.type === "Identifier") {
timelineVar = node.id.name;
ref = { kind: "identifier", name: node.id.name };
defaults = extractTimelineDefaults(node.init, emptyScope);
}
}
@@ -405,16 +452,23 @@ function findTimelineVar(ast: any, scope?: ScopeBindings): TimelineDetection {
AssignmentExpression(node: any) {
if (isGsapTimelineCall(node.right)) {
timelineCount += 1;
if (!timelineVar) {
if (!ref) {
const left = node.left;
if (left?.type === "Identifier") timelineVar = left.name;
defaults = extractTimelineDefaults(node.right, emptyScope);
if (left?.type === "Identifier") {
timelineVar = left.name;
ref = { kind: "identifier", name: left.name };
defaults = extractTimelineDefaults(node.right, emptyScope);
} else if (isStaticMemberRef(left)) {
// Inline form: `window.__timelines["scene"] = gsap.timeline(...)`.
ref = { kind: "member", node: left };
defaults = extractTimelineDefaults(node.right, emptyScope);
}
}
}
},
});
return { timelineVar, timelineCount, defaults };
return { timelineVar, ref, timelineCount, defaults };
}
// ── Tween call collection ─────────────────────────────────────────────────────
@@ -447,13 +501,14 @@ export interface TweenCallInfo {
global?: boolean;
}
/** True when callee chain is rooted at the timeline variable. */
function isTimelineRootedCall(callNode: any, timelineVar: string): boolean {
/** True when the callee chain is rooted at the timeline reference (identifier or member). */
function isTimelineRootedCall(callNode: any, ref: TimelineRef): boolean {
let obj = callNode.callee?.object;
while (obj?.type === "CallExpression") {
obj = obj.callee?.object;
}
return obj?.type === "Identifier" && obj.name === timelineVar;
if (ref.kind === "identifier") return obj?.type === "Identifier" && obj.name === ref.name;
return sameMemberAccess(obj, ref.node);
}
/**
@@ -465,7 +520,7 @@ function isTimelineRootedCall(callNode: any, timelineVar: string): boolean {
*/
function findAllTweenCalls(
ast: any,
timelineVar: string,
ref: TimelineRef,
scope: ScopeBindings,
targetBindings: TargetBindings,
): TweenCallInfo[] {
@@ -494,7 +549,7 @@ function findAllTweenCalls(
if (
callee?.type === "MemberExpression" &&
callee.property?.type === "Identifier" &&
(isTimelineRootedCall(node, timelineVar) || isGlobalSet) &&
(isTimelineRootedCall(node, ref) || isGlobalSet) &&
GSAP_METHODS.has(callee.property.name)
) {
const method = callee.property.name;
@@ -1092,8 +1147,9 @@ export function parseGsapScriptAcornForWrite(script: string): ParsedGsapAcornFor
const scope = collectScopeBindings(ast);
const targetBindings = collectTargetBindings(ast, scope);
const detection = findTimelineVar(ast, scope);
const timelineVar = detection.timelineVar ?? "tl";
const calls = findAllTweenCalls(ast, timelineVar, scope, targetBindings);
const ref: TimelineRef = detection.ref ?? { kind: "identifier", name: "tl" };
const timelineVar = timelineRootSource(ref, script);
const calls = findAllTweenCalls(ast, ref, scope, targetBindings);
sortBySourcePosition(calls);
const rawAnims = calls.map((call) => tweenCallToAnimation(call, scope, script));
applyTimelineDefaults(rawAnims, detection.defaults);
@@ -1104,7 +1160,7 @@ export function parseGsapScriptAcornForWrite(script: string): ParsedGsapAcornFor
call,
animation: animations[i]!,
}));
return { ast, timelineVar, hasTimeline: detection.timelineVar !== null, located };
return { ast, timelineVar, hasTimeline: detection.ref !== null, located };
} catch {
return null;
}
@@ -1125,30 +1181,41 @@ export function parseGsapScriptAcorn(script: string): ParsedGsap {
});
const scope = collectScopeBindings(ast);
const detection = findTimelineVar(ast, scope);
const timelineVar = detection.timelineVar ?? "tl";
const ref: TimelineRef = detection.ref ?? { kind: "identifier", name: "tl" };
const timelineVar = timelineRootSource(ref, script);
// Expand helper-built / bounded-loop timelines before analysis so their
// tweens resolve at true positions (read path only — the write path keeps
// original source nodes). Degrades to the un-inlined AST on any failure.
try {
inlineComputedTimelines(ast, timelineVar, (node) => resolveNode(node, scope));
} catch {
/* fall back to current behavior */
// Only the identifier form uses the helper-built pattern; inline member
// timelines have nothing to inline, so skip (avoids mis-rooting on the member).
if (ref.kind === "identifier") {
try {
inlineComputedTimelines(ast, timelineVar, (node) => resolveNode(node, scope));
} catch {
/* fall back to current behavior */
}
}
const targetBindings = collectTargetBindings(ast, scope);
const calls = findAllTweenCalls(ast, timelineVar, scope, targetBindings);
const calls = findAllTweenCalls(ast, ref, scope, targetBindings);
sortBySourcePosition(calls);
const rawAnims = calls.map((call) => tweenCallToAnimation(call, scope, script));
applyTimelineDefaults(rawAnims, detection.defaults);
resolveTimelinePositions(rawAnims);
const animations = assignStableIds(rawAnims);
const timelineMatch = script.match(
new RegExp(
`^[\\s\\S]*?(?:const|let|var)\\s+${timelineVar}\\s*=\\s*gsap\\.timeline\\s*\\([^)]*\\)\\s*;?`,
),
);
const preamble =
timelineMatch?.[0] ?? `const ${timelineVar} = gsap.timeline({ paused: true });`;
// Preamble = source up to and including the timeline declaration/assignment.
// Identifier keeps the original `const|let|var <name> = …` regex (byte-stable);
// member matches `<member source> = …`.
const declPattern =
ref.kind === "identifier"
? `(?:const|let|var)\\s+${timelineVar}\\s*=\\s*gsap\\.timeline\\s*\\([^)]*\\)\\s*;?`
: `${escapeRegExp(timelineVar)}\\s*=\\s*gsap\\.timeline\\s*\\([^)]*\\)\\s*;?`;
const timelineMatch = script.match(new RegExp(`^[\\s\\S]*?${declPattern}`));
const fallbackPreamble =
ref.kind === "identifier"
? `const ${timelineVar} = gsap.timeline({ paused: true });`
: `${timelineVar} = gsap.timeline({ paused: true });`;
const preamble = timelineMatch?.[0] ?? fallbackPreamble;
const lastCallIdx = script.lastIndexOf(`${timelineVar}.`);
let postamble = "";
@@ -1162,7 +1229,7 @@ export function parseGsapScriptAcorn(script: string): ParsedGsap {
const result: ParsedGsap = { animations, timelineVar, preamble, postamble };
if (detection.timelineCount > 1) result.multipleTimelines = true;
if (detection.timelineCount > 0 && detection.timelineVar === null)
if (detection.timelineCount > 0 && detection.ref === null)
result.unsupportedTimelinePattern = true;
return result;
} catch {
@@ -1194,7 +1261,7 @@ export function extractGsapLabels(script: string): GsapLabelEntry[] {
});
const scope = collectScopeBindings(ast);
const detection = findTimelineVar(ast, scope);
const timelineVar = detection.timelineVar ?? "tl";
const ref: TimelineRef = detection.ref ?? { kind: "identifier", name: "tl" };
const labels: GsapLabelEntry[] = [];
@@ -1204,10 +1271,14 @@ export function extractGsapLabels(script: string): GsapLabelEntry[] {
const expr = node.expression;
if (!expr || expr.type !== "CallExpression") return;
const callee = expr.callee;
// Match tl.addLabel(...)
// Match <timeline>.addLabel(...) for identifier or member timeline refs.
const objMatches =
ref.kind === "identifier"
? callee.object?.type === "Identifier" && callee.object.name === ref.name
: sameMemberAccess(callee.object, ref.node);
if (
callee?.type !== "MemberExpression" ||
callee.object?.name !== timelineVar ||
!objMatches ||
callee.property?.name !== "addLabel"
)
return;
@@ -0,0 +1,98 @@
import { describe, it, expect } from "vitest";
import { parseGsapScriptAcorn } from "./gsapParserAcorn.js";
import {
updateAnimationInScript,
addAnimationToScript,
removeAnimationFromScript,
addKeyframeToScript,
removeAllKeyframesFromScript,
} from "./gsapWriterAcorn.js";
// U3: edit/add/delete tweens on a timeline authored inline as
// `window.__timelines["scene"] = gsap.timeline()`, emitting the member form.
const inlineSrc = `window.__timelines = window.__timelines || {};
window.__timelines["scene"] = gsap.timeline({ paused: true });
window.__timelines["scene"].to("#a", { x: 100, duration: 1 }, 0);
window.__timelines["scene"].to("#b", { y: 50, duration: 1 }, 0.5);`;
describe("inline timeline assignment — write", () => {
it("edits an existing inline tween's value in place", () => {
const id = parseGsapScriptAcorn(inlineSrc).animations[0]!.id;
const out = updateAnimationInScript(inlineSrc, id, { properties: { x: 200 } });
expect(out).toContain('window.__timelines["scene"].to("#a"');
expect(out).toContain("200");
const reread = parseGsapScriptAcorn(out);
expect(reread.animations).toHaveLength(2);
expect(reread.unsupportedTimelinePattern).toBeFalsy();
});
it("adds a new tween in the member form", () => {
const { script: out } = addAnimationToScript(inlineSrc, {
method: "to",
targetSelector: "#c",
properties: { opacity: 1 },
position: 1,
duration: 1,
});
expect(out).toContain('window.__timelines["scene"].to("#c"');
expect(parseGsapScriptAcorn(out).animations).toHaveLength(3);
});
it("removes an inline tween, leaving the rest", () => {
const id = parseGsapScriptAcorn(inlineSrc).animations[1]!.id;
const out = removeAnimationFromScript(inlineSrc, id);
expect(out).not.toContain('"#b"');
expect(parseGsapScriptAcorn(out).animations).toHaveLength(1);
});
it("preserves single-quote member form on write", () => {
const sq = `window.__timelines = window.__timelines || {};
window.__timelines['scene'] = gsap.timeline();
window.__timelines['scene'].to('#a', { x: 1, duration: 1 }, 0);`;
const id = parseGsapScriptAcorn(sq).animations[0]!.id;
const out = updateAnimationInScript(sq, id, { properties: { x: 9 } });
expect(out).toContain("window.__timelines['scene']");
expect(parseGsapScriptAcorn(out).animations).toHaveLength(1);
});
it("converts an inline tween to keyframes by adding one (the delete-all-keyframes bug area)", () => {
const id = parseGsapScriptAcorn(inlineSrc).animations[0]!.id;
const out = addKeyframeToScript(inlineSrc, id, 50, { x: 150 });
expect(out).toContain("keyframes");
expect(out).toContain('window.__timelines["scene"]');
expect(parseGsapScriptAcorn(out).unsupportedTimelinePattern).toBeFalsy();
});
it("removes all keyframes from an inline keyframed tween", () => {
const kf = `window.__timelines = window.__timelines || {};
window.__timelines["scene"] = gsap.timeline();
window.__timelines["scene"].to("#a", { keyframes: { "0%": { x: 0 }, "100%": { x: 100 } }, duration: 1 }, 0);`;
const id = parseGsapScriptAcorn(kf).animations[0]!.id;
const out = removeAllKeyframesFromScript(kf, id);
expect(out).not.toContain("keyframes");
});
it("adds the first tween to an empty inline timeline", () => {
const empty = `window.__timelines = window.__timelines || {};
window.__timelines["scene"] = gsap.timeline({ paused: true });`;
const { script: out } = addAnimationToScript(empty, {
method: "to",
targetSelector: "#a",
properties: { x: 10 },
position: 0,
duration: 1,
});
expect(out).toContain('window.__timelines["scene"].to("#a"');
expect(parseGsapScriptAcorn(out).animations).toHaveLength(1);
});
it("no-op write is stable (read → re-emit same → re-read equal count)", () => {
const parsed = parseGsapScriptAcorn(inlineSrc);
const id = parsed.animations[0]!.id;
const out = updateAnimationInScript(inlineSrc, id, {
properties: parsed.animations[0]!.properties,
});
expect(parseGsapScriptAcorn(out).animations).toHaveLength(2);
});
});
+23 -10
View File
@@ -274,9 +274,13 @@ function reconcileEditableProps(
// ── Insertion helpers ─────────────────────────────────────────────────────────
/** Traverse callee.object chain to check if a call ultimately roots at timelineVar. */
function isTimelineRooted(node: Node, timelineVar: string): boolean {
function isTimelineRooted(node: Node, timelineVar: string, script: string): boolean {
if (node?.type === "Identifier") return node.name === timelineVar;
if (node?.type === "CallExpression") return isTimelineRooted(node.callee?.object, timelineVar);
// Inline/member timelines: `timelineVar` is the source slice (e.g.
// `window.__timelines["scene"]`); match a MemberExpression callee by its source.
if (node?.type === "MemberExpression") return script.slice(node.start, node.end) === timelineVar;
if (node?.type === "CallExpression")
return isTimelineRooted(node.callee?.object, timelineVar, script);
return false;
}
@@ -1559,31 +1563,40 @@ export function splitIntoPropertyGroupsFromScript(
// ── Label write ops ───────────────────────────────────────────────────────────
/** True when `expr` is `tl.<method>(…)` rooted at the timeline var. */
function isTimelineMethodCall(expr: Node, timelineVar: string, method: string): boolean {
function isTimelineMethodCall(
expr: Node,
timelineVar: string,
method: string,
script: string,
): boolean {
return (
expr?.type === "CallExpression" &&
expr.callee?.type === "MemberExpression" &&
isTimelineRooted(expr.callee.object, timelineVar) &&
isTimelineRooted(expr.callee.object, timelineVar, script) &&
expr.callee.property?.name === method
);
}
/** True when `expr` is `tl.addLabel("<name>", …)` rooted at the timeline var. */
function isAddLabelCall(expr: Node, timelineVar: string, name: string): boolean {
function isAddLabelCall(expr: Node, timelineVar: string, name: string, script: string): boolean {
const firstArg = expr?.arguments?.[0];
return (
isTimelineMethodCall(expr, timelineVar, "addLabel") &&
isTimelineMethodCall(expr, timelineVar, "addLabel", script) &&
firstArg?.type === "Literal" &&
firstArg.value === name
);
}
/** Every `tl.addLabel("<name>", …)` ExpressionStatement in the script. */
function findLabelStatements(parsed: ParsedGsapAcornForWrite, name: string): Node[] {
function findLabelStatements(
parsed: ParsedGsapAcornForWrite,
name: string,
script: string,
): Node[] {
const targets: Node[] = [];
acornWalk.simple(parsed.ast, {
ExpressionStatement(node: Node) {
if (isAddLabelCall(node.expression, parsed.timelineVar, name)) targets.push(node);
if (isAddLabelCall(node.expression, parsed.timelineVar, name, script)) targets.push(node);
},
});
return targets;
@@ -1597,7 +1610,7 @@ export function addLabelToScript(script: string, name: string, position: number)
// appending a duplicate. Two same-named addLabel statements make removeLabel
// over-remove — it deletes every match, including a pre-existing label the
// user never touched.
const existing = findLabelStatements(parsed, name)[0];
const existing = findLabelStatements(parsed, name, script)[0];
if (existing) {
const ms = new MagicString(script);
const posArg = existing.expression.arguments?.[1];
@@ -1619,7 +1632,7 @@ export function removeLabelFromScript(script: string, name: string): string {
const parsed = parseGsapScriptAcornForWrite(script);
if (!parsed) return script;
const targets = findLabelStatements(parsed, name);
const targets = findLabelStatements(parsed, name, script);
if (!targets.length) return script;
const ms = new MagicString(script);
@@ -46,9 +46,9 @@ export const GsapAnimationSection = memo(function GsapAnimationSection({
)}
{unsupportedTimelinePattern && (
<p className="mb-2 rounded-lg bg-amber-500/10 px-3 py-2 text-[11px] leading-relaxed text-amber-400">
This composition uses a timeline assignment pattern (window.__timelines[...]) that the
editor doesn&apos;t support. Use a variable declaration (const tl = gsap.timeline()) to
enable editing.
This timeline uses a computed key (window.__timelines[variable]) the editor can&apos;t
resolve statically. Use a string-literal key (window.__timelines[&quot;id&quot;]) or a
variable declaration (const tl = gsap.timeline()) to enable editing.
</p>
)}
{multipleTimelines || unsupportedTimelinePattern ? null : (