Skip to content

Commit 2e02bcf

Browse files
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]).
1 parent 1eed7a9 commit 2e02bcf

7 files changed

Lines changed: 488 additions & 67 deletions

File tree

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { describe, it, expect } from "vitest";
2+
import {
3+
parseGsapScript,
4+
updateAnimationInScript,
5+
addAnimationToScript,
6+
removeAnimationFromScript,
7+
addKeyframeToScript,
8+
removeAllKeyframesFromScript,
9+
} from "./gsapParser.js";
10+
import { addLabelToScript, removeLabelFromScript } from "./gsapWriterAcorn.js";
11+
12+
// U4: recast parser/writer parity for the inline form
13+
// `window.__timelines["scene"] = gsap.timeline()` (the default server write path).
14+
15+
const inlineSrc = `window.__timelines = window.__timelines || {};
16+
window.__timelines["scene"] = gsap.timeline({ paused: true });
17+
window.__timelines["scene"].to("#a", { x: 100, duration: 1 }, 0);
18+
window.__timelines["scene"].to("#b", { y: 50, duration: 1 }, 0.5);`;
19+
20+
describe("recast — inline timeline read", () => {
21+
it("reads inline tweens (double quote)", () => {
22+
const p = parseGsapScript(inlineSrc);
23+
expect(p.unsupportedTimelinePattern).toBeFalsy();
24+
expect(p.animations).toHaveLength(2);
25+
expect(p.animations[0]!.targetSelector).toBe("#a");
26+
});
27+
28+
it("reads single-quote + dot access", () => {
29+
const sq = `window.__timelines['s'] = gsap.timeline();\nwindow.__timelines['s'].to('#a', { x: 1, duration: 1 }, 0);`;
30+
const dot = `window.__timelines.s = gsap.timeline();\nwindow.__timelines.s.to("#a", { x: 1, duration: 1 }, 0);`;
31+
expect(parseGsapScript(sq).animations).toHaveLength(1);
32+
expect(parseGsapScript(dot).animations).toHaveLength(1);
33+
});
34+
35+
it("flags computed key as unsupported", () => {
36+
const c = `const id = "s";\nwindow.__timelines[id] = gsap.timeline();\nwindow.__timelines[id].to("#a", { x: 1, duration: 1 }, 0);`;
37+
expect(parseGsapScript(c).unsupportedTimelinePattern).toBe(true);
38+
});
39+
40+
it("keeps the canonical const form unchanged", () => {
41+
const c = `const tl = gsap.timeline();\nwindow.__timelines["s"] = tl;\ntl.to("#a", { x: 5, duration: 1 }, 0);`;
42+
const p = parseGsapScript(c);
43+
expect(p.timelineVar).toBe("tl");
44+
expect(p.animations).toHaveLength(1);
45+
});
46+
});
47+
48+
describe("recast — inline timeline write", () => {
49+
it("edits an inline tween in place", () => {
50+
const id = parseGsapScript(inlineSrc).animations[0]!.id;
51+
const out = updateAnimationInScript(inlineSrc, id, { properties: { x: 200 } });
52+
expect(out).toContain('window.__timelines["scene"].to("#a"');
53+
expect(out).toContain("200");
54+
expect(parseGsapScript(out).animations).toHaveLength(2);
55+
});
56+
57+
it("adds a tween in member form", () => {
58+
const out = addAnimationToScript(inlineSrc, {
59+
method: "to",
60+
targetSelector: "#c",
61+
properties: { opacity: 1 },
62+
position: 1,
63+
duration: 1,
64+
});
65+
const script = typeof out === "string" ? out : out.script;
66+
expect(script).toContain('window.__timelines["scene"].to("#c"');
67+
expect(parseGsapScript(script).animations).toHaveLength(3);
68+
});
69+
70+
it("removes an inline tween", () => {
71+
const id = parseGsapScript(inlineSrc).animations[1]!.id;
72+
const out = removeAnimationFromScript(inlineSrc, id);
73+
expect(out).not.toContain('"#b"');
74+
expect(parseGsapScript(out).animations).toHaveLength(1);
75+
});
76+
77+
it("adds + removes keyframes on an inline tween", () => {
78+
const id = parseGsapScript(inlineSrc).animations[0]!.id;
79+
const withKf = addKeyframeToScript(inlineSrc, id, 50, { x: 150 });
80+
expect(withKf).toContain("keyframes");
81+
expect(parseGsapScript(withKf).unsupportedTimelinePattern).toBeFalsy();
82+
const kfId = parseGsapScript(withKf).animations[0]!.id;
83+
const cleared = removeAllKeyframesFromScript(withKf, kfId);
84+
expect(cleared).not.toContain("keyframes");
85+
});
86+
87+
it("preserves single-quote member form on write", () => {
88+
const sq = `window.__timelines['s'] = gsap.timeline();\nwindow.__timelines['s'].to('#a', { x: 1, duration: 1 }, 0);`;
89+
const id = parseGsapScript(sq).animations[0]!.id;
90+
const out = updateAnimationInScript(sq, id, { properties: { x: 9 } });
91+
expect(out).toContain("window.__timelines['s']");
92+
});
93+
});
94+
95+
// acorn writer: inline-form label add/remove must match member-rooted callees, not
96+
// just Identifier-rooted ones — else addLabel duplicates and removeLabel no-ops.
97+
describe("acorn — inline timeline labels", () => {
98+
const src = `window.__timelines["scene"] = gsap.timeline({ paused: true });
99+
window.__timelines["scene"].to("#a", { x: 100, duration: 1 }, 0);`;
100+
101+
it("dedups addLabel (moves, not duplicates) and removes it on an inline timeline", () => {
102+
let s = addLabelToScript(src, "intro", 0.5);
103+
s = addLabelToScript(s, "intro", 0.9);
104+
expect((s.match(/addLabel\(/g) ?? []).length).toBe(1);
105+
expect(s).toContain('addLabel("intro", 0.9)');
106+
expect((removeLabelFromScript(s, "intro").match(/addLabel\(/g) ?? []).length).toBe(0);
107+
});
108+
});

packages/parsers/src/gsapParser.ts

Lines changed: 79 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -376,12 +376,54 @@ interface TimelineDefaults {
376376
duration?: number;
377377
}
378378

379+
// `identifier` is the canonical `const tl = …` form; `member` is the inline
380+
// `window.__timelines["scene"] = …` form (the timeline IS the member expression).
381+
type TimelineRef = { kind: "identifier"; name: string } | { kind: "member"; node: AstNode };
382+
379383
interface TimelineDetection {
380384
timelineVar: string | null;
385+
ref: TimelineRef | null;
381386
timelineCount: number;
382387
defaults?: TimelineDefaults;
383388
}
384389

390+
/** The static string key of a member access (`window.__timelines["scene"]` → "scene"), else null. */
391+
function staticMemberKey(node: AstNode): string | null {
392+
if (!node || node.type !== "MemberExpression") return null;
393+
if (node.computed) {
394+
const p = node.property;
395+
if (p?.type === "StringLiteral") return p.value;
396+
if (p?.type === "Literal" && typeof p.value === "string") return p.value;
397+
return null;
398+
}
399+
return node.property?.type === "Identifier" ? node.property.name : null;
400+
}
401+
402+
function isStaticMemberRef(node: AstNode): boolean {
403+
return node?.type === "MemberExpression" && staticMemberKey(node) !== null;
404+
}
405+
406+
/** Structural equality of two member accesses (object chain + static key), quote-insensitive. */
407+
function sameMemberAccess(a: AstNode, b: AstNode): boolean {
408+
if (a?.type !== "MemberExpression" || b?.type !== "MemberExpression") return false;
409+
if (staticMemberKey(a) !== staticMemberKey(b) || staticMemberKey(a) === null) return false;
410+
const ao = a.object;
411+
const bo = b.object;
412+
if (ao?.type === "Identifier" && bo?.type === "Identifier") return ao.name === bo.name;
413+
if (ao?.type === "MemberExpression" && bo?.type === "MemberExpression")
414+
return sameMemberAccess(ao, bo);
415+
return false;
416+
}
417+
418+
/** The source string a tween call roots at: identifier name, or the member source as written. */
419+
function timelineRootSource(ref: TimelineRef): string {
420+
return ref.kind === "identifier" ? ref.name : recast.print(ref.node).code;
421+
}
422+
423+
function escapeRegExp(s: string): string {
424+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
425+
}
426+
385427
function extractTimelineDefaults(
386428
callNode: AstNode,
387429
scope: ScopeBindings,
@@ -401,15 +443,17 @@ function extractTimelineDefaults(
401443

402444
function findTimelineVar(ast: AstNode, scope?: ScopeBindings): TimelineDetection {
403445
let timelineVar: string | null = null;
446+
let ref: TimelineRef | null = null;
404447
let timelineCount = 0;
405448
let defaults: TimelineDefaults | undefined;
406449
const emptyScope: ScopeBindings = scope ?? new Map();
407450
recast.types.visit(ast, {
408451
visitVariableDeclarator(path: AstPath) {
409452
if (isGsapTimelineCall(path.node.init)) {
410453
timelineCount += 1;
411-
if (!timelineVar) {
412-
timelineVar = path.node.id?.name ?? null;
454+
if (!ref && path.node.id?.type === "Identifier") {
455+
timelineVar = path.node.id.name;
456+
ref = { kind: "identifier", name: path.node.id.name };
413457
defaults = extractTimelineDefaults(path.node.init, emptyScope);
414458
}
415459
}
@@ -418,16 +462,22 @@ function findTimelineVar(ast: AstNode, scope?: ScopeBindings): TimelineDetection
418462
visitAssignmentExpression(path: AstPath) {
419463
if (isGsapTimelineCall(path.node.right)) {
420464
timelineCount += 1;
421-
if (!timelineVar) {
465+
if (!ref) {
422466
const left = path.node.left;
423-
if (left?.type === "Identifier") timelineVar = left.name;
424-
defaults = extractTimelineDefaults(path.node.right, emptyScope);
467+
if (left?.type === "Identifier") {
468+
timelineVar = left.name;
469+
ref = { kind: "identifier", name: left.name };
470+
defaults = extractTimelineDefaults(path.node.right, emptyScope);
471+
} else if (isStaticMemberRef(left)) {
472+
ref = { kind: "member", node: left };
473+
defaults = extractTimelineDefaults(path.node.right, emptyScope);
474+
}
425475
}
426476
}
427477
this.traverse(path);
428478
},
429479
});
430-
return { timelineVar, timelineCount, defaults };
480+
return { timelineVar, ref, timelineCount, defaults };
431481
}
432482

433483
// ── Find All Tween Calls ────────────────────────────────────────────────────
@@ -448,17 +498,18 @@ interface TweenCallInfo {
448498
* True when the member chain of `callNode.callee` is rooted at the timeline
449499
* variable — `tl.to(...)` and every link of a chain `tl.to(...).to(...)`.
450500
*/
451-
function isTimelineRootedCall(callNode: AstNode, timelineVar: string): boolean {
501+
function isTimelineRootedCall(callNode: AstNode, ref: TimelineRef): boolean {
452502
let obj = callNode.callee?.object;
453503
while (obj?.type === "CallExpression") {
454504
obj = obj.callee?.object;
455505
}
456-
return obj?.type === "Identifier" && obj.name === timelineVar;
506+
if (ref.kind === "identifier") return obj?.type === "Identifier" && obj.name === ref.name;
507+
return sameMemberAccess(obj, ref.node);
457508
}
458509

459510
function findAllTweenCalls(
460511
ast: AstNode,
461-
timelineVar: string,
512+
ref: TimelineRef,
462513
scope: ScopeBindings,
463514
targetBindings: TargetBindings,
464515
): TweenCallInfo[] {
@@ -484,7 +535,7 @@ function findAllTweenCalls(
484535
if (
485536
callee?.type === "MemberExpression" &&
486537
callee.property?.type === "Identifier" &&
487-
(isTimelineRootedCall(node, timelineVar) || isGlobalSet)
538+
(isTimelineRootedCall(node, ref) || isGlobalSet)
488539
) {
489540
const method = callee.property.name;
490541
if (!GSAP_METHODS.has(method)) {
@@ -1131,8 +1182,9 @@ function parseGsapAst(script: string): ParsedGsapAst {
11311182
const scope = collectScopeBindings(ast);
11321183
const targetBindings = collectTargetBindings(ast, scope);
11331184
const detection = findTimelineVar(ast, scope);
1134-
const timelineVar = detection.timelineVar ?? "tl";
1135-
const calls = findAllTweenCalls(ast, timelineVar, scope, targetBindings);
1185+
const ref: TimelineRef = detection.ref ?? { kind: "identifier", name: "tl" };
1186+
const timelineVar = timelineRootSource(ref);
1187+
const calls = findAllTweenCalls(ast, ref, scope, targetBindings);
11361188
sortBySourcePosition(calls);
11371189
const rawAnims = calls.map((call) => tweenCallToAnimation(call, scope));
11381190
applyTimelineDefaults(rawAnims, detection.defaults);
@@ -1151,15 +1203,19 @@ function parseGsapAst(script: string): ParsedGsapAst {
11511203
export function parseGsapScript(script: string): ParsedGsap {
11521204
try {
11531205
const { detection, timelineVar, located } = parseGsapAst(script);
1206+
const ref: TimelineRef = detection.ref ?? { kind: "identifier", name: "tl" };
11541207
const animations = located.map((l) => l.animation);
11551208

1156-
const timelineMatch = script.match(
1157-
new RegExp(
1158-
`^[\\s\\S]*?(?:const|let|var)\\s+${timelineVar}\\s*=\\s*gsap\\.timeline\\s*\\([^)]*\\)\\s*;?`,
1159-
),
1160-
);
1161-
const preamble =
1162-
timelineMatch?.[0] ?? `const ${timelineVar} = gsap.timeline({ paused: true });`;
1209+
const declPattern =
1210+
ref.kind === "identifier"
1211+
? `(?:const|let|var)\\s+${timelineVar}\\s*=\\s*gsap\\.timeline\\s*\\([^)]*\\)\\s*;?`
1212+
: `${escapeRegExp(timelineVar)}\\s*=\\s*gsap\\.timeline\\s*\\([^)]*\\)\\s*;?`;
1213+
const timelineMatch = script.match(new RegExp(`^[\\s\\S]*?${declPattern}`));
1214+
const fallbackPreamble =
1215+
ref.kind === "identifier"
1216+
? `const ${timelineVar} = gsap.timeline({ paused: true });`
1217+
: `${timelineVar} = gsap.timeline({ paused: true });`;
1218+
const preamble = timelineMatch?.[0] ?? fallbackPreamble;
11631219

11641220
const lastCallIdx = script.lastIndexOf(`${timelineVar}.`);
11651221
let postamble = "";
@@ -1173,7 +1229,7 @@ export function parseGsapScript(script: string): ParsedGsap {
11731229

11741230
const result: ParsedGsap = { animations, timelineVar, preamble, postamble };
11751231
if (detection.timelineCount > 1) result.multipleTimelines = true;
1176-
if (detection.timelineCount > 0 && detection.timelineVar === null)
1232+
if (detection.timelineCount > 0 && detection.ref === null)
11771233
result.unsupportedTimelinePattern = true;
11781234
return result;
11791235
} catch {
@@ -1468,7 +1524,7 @@ export function addAnimationToScript(
14681524
return { script, id: "" };
14691525
}
14701526
// Nothing to anchor against and no timeline to target — treat as parse failure.
1471-
if (parsed.located.length === 0 && parsed.detection.timelineVar === null) {
1527+
if (parsed.located.length === 0 && parsed.detection.ref === null) {
14721528
return { script, id: "" };
14731529
}
14741530

@@ -1500,7 +1556,7 @@ export function addAnimationWithKeyframesToScript(
15001556
console.warn("[gsap-parser] addAnimationWithKeyframesToScript parse failed:", e);
15011557
return { script, id: "" };
15021558
}
1503-
if (parsed.located.length === 0 && parsed.detection.timelineVar === null) {
1559+
if (parsed.located.length === 0 && parsed.detection.ref === null) {
15041560
return { script, id: "" };
15051561
}
15061562

@@ -2796,7 +2852,7 @@ export function addMotionPathToScript(
27962852
console.warn("[gsap-parser] addMotionPathToScript parse failed:", e);
27972853
return { script, id: null };
27982854
}
2799-
if (parsed.located.length === 0 && parsed.detection.timelineVar === null) {
2855+
if (parsed.located.length === 0 && parsed.detection.ref === null) {
28002856
return { script, id: null };
28012857
}
28022858

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { describe, it, expect } from "vitest";
2+
import { parseGsapScriptAcorn } from "./gsapParserAcorn.js";
3+
4+
// U1+U2: the editor must read timelines authored inline as
5+
// `window.__timelines["id"] = gsap.timeline()` — not just the canonical
6+
// `const tl = gsap.timeline(); window.__timelines[id] = tl` form.
7+
8+
const wrap = (decl: string, tweens: string) =>
9+
`window.__timelines = window.__timelines || {};\n${decl}\n${tweens}`;
10+
11+
describe("inline timeline assignment — read", () => {
12+
it("reads tweens from a double-quoted inline timeline", () => {
13+
const src = wrap(
14+
`window.__timelines["scene"] = gsap.timeline({ paused: true });`,
15+
`window.__timelines["scene"].to("#a", { x: 100, duration: 1 }, 0);\n` +
16+
`window.__timelines["scene"].to("#b", { y: 50, duration: 1 }, 0.5);`,
17+
);
18+
const parsed = parseGsapScriptAcorn(src);
19+
expect(parsed.unsupportedTimelinePattern).toBeFalsy();
20+
expect(parsed.animations).toHaveLength(2);
21+
expect(parsed.animations[0]!.targetSelector).toBe("#a");
22+
expect(parsed.animations[1]!.targetSelector).toBe("#b");
23+
});
24+
25+
it("reads a single-quoted inline timeline", () => {
26+
const src = wrap(
27+
`window.__timelines['scene'] = gsap.timeline();`,
28+
`window.__timelines['scene'].to('#a', { x: 10, duration: 1 }, 0);`,
29+
);
30+
const parsed = parseGsapScriptAcorn(src);
31+
expect(parsed.unsupportedTimelinePattern).toBeFalsy();
32+
expect(parsed.animations).toHaveLength(1);
33+
expect(parsed.animations[0]!.targetSelector).toBe("#a");
34+
});
35+
36+
it("reads a static dot-access inline timeline", () => {
37+
const src = wrap(
38+
`window.__timelines.scene = gsap.timeline();`,
39+
`window.__timelines.scene.to("#a", { x: 10, duration: 1 }, 0);`,
40+
);
41+
const parsed = parseGsapScriptAcorn(src);
42+
expect(parsed.unsupportedTimelinePattern).toBeFalsy();
43+
expect(parsed.animations).toHaveLength(1);
44+
});
45+
46+
it("flags a computed-key timeline as unsupported (cannot statically resolve)", () => {
47+
const src = wrap(
48+
`const id = "scene";\nwindow.__timelines[id] = gsap.timeline();`,
49+
`window.__timelines[id].to("#a", { x: 10, duration: 1 }, 0);`,
50+
);
51+
const parsed = parseGsapScriptAcorn(src);
52+
expect(parsed.unsupportedTimelinePattern).toBe(true);
53+
});
54+
55+
it("does not cross-attribute tweens of a different member slot", () => {
56+
const src = wrap(
57+
`window.__timelines["a"] = gsap.timeline();\nwindow.__timelines["b"] = gsap.timeline();`,
58+
`window.__timelines["a"].to("#a", { x: 1, duration: 1 }, 0);\n` +
59+
`window.__timelines["b"].to("#b", { x: 2, duration: 1 }, 0);`,
60+
);
61+
const parsed = parseGsapScriptAcorn(src);
62+
// First detected timeline is "a"; only its tween should be attributed here.
63+
expect(parsed.multipleTimelines).toBe(true);
64+
expect(parsed.animations.some((a) => a.targetSelector === "#a")).toBe(true);
65+
expect(parsed.animations.every((a) => a.targetSelector !== "#b")).toBe(true);
66+
});
67+
68+
it("leaves the canonical const form working", () => {
69+
const src = `const tl = gsap.timeline();\nwindow.__timelines["scene"] = tl;\ntl.to("#a", { x: 5, duration: 1 }, 0);`;
70+
const parsed = parseGsapScriptAcorn(src);
71+
expect(parsed.unsupportedTimelinePattern).toBeFalsy();
72+
expect(parsed.animations).toHaveLength(1);
73+
expect(parsed.timelineVar).toBe("tl");
74+
});
75+
});

0 commit comments

Comments
 (0)