From 2e02bcf77af55db4b60e4504bcd2dd0033dc337a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sat, 27 Jun 2026 11:27:53 -0400 Subject: [PATCH] feat(gsap): read timelines authored inline (acorn read path) (#1760) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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]). --- .../parsers/src/gsapParser.inline.test.ts | 108 ++++++++++++++ packages/parsers/src/gsapParser.ts | 102 +++++++++++--- .../src/gsapParserAcorn.inline.test.ts | 75 ++++++++++ packages/parsers/src/gsapParserAcorn.ts | 133 ++++++++++++++---- .../src/gsapWriterAcorn.inline.test.ts | 98 +++++++++++++ packages/parsers/src/gsapWriterAcorn.ts | 33 +++-- .../editor/GsapAnimationSection.tsx | 6 +- 7 files changed, 488 insertions(+), 67 deletions(-) create mode 100644 packages/parsers/src/gsapParser.inline.test.ts create mode 100644 packages/parsers/src/gsapParserAcorn.inline.test.ts create mode 100644 packages/parsers/src/gsapWriterAcorn.inline.test.ts diff --git a/packages/parsers/src/gsapParser.inline.test.ts b/packages/parsers/src/gsapParser.inline.test.ts new file mode 100644 index 000000000..fff5521a7 --- /dev/null +++ b/packages/parsers/src/gsapParser.inline.test.ts @@ -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); + }); +}); diff --git a/packages/parsers/src/gsapParser.ts b/packages/parsers/src/gsapParser.ts index ac40a8adf..96ffd43e4 100644 --- a/packages/parsers/src/gsapParser.ts +++ b/packages/parsers/src/gsapParser.ts @@ -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 }; } diff --git a/packages/parsers/src/gsapParserAcorn.inline.test.ts b/packages/parsers/src/gsapParserAcorn.inline.test.ts new file mode 100644 index 000000000..11170a116 --- /dev/null +++ b/packages/parsers/src/gsapParserAcorn.inline.test.ts @@ -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"); + }); +}); diff --git a/packages/parsers/src/gsapParserAcorn.ts b/packages/parsers/src/gsapParserAcorn.ts index 38ca4cb8c..94c88e887 100644 --- a/packages/parsers/src/gsapParserAcorn.ts +++ b/packages/parsers/src/gsapParserAcorn.ts @@ -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 = …` regex (byte-stable); + // member matches ` = …`. + 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 .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; diff --git a/packages/parsers/src/gsapWriterAcorn.inline.test.ts b/packages/parsers/src/gsapWriterAcorn.inline.test.ts new file mode 100644 index 000000000..a193a23a0 --- /dev/null +++ b/packages/parsers/src/gsapWriterAcorn.inline.test.ts @@ -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); + }); +}); diff --git a/packages/parsers/src/gsapWriterAcorn.ts b/packages/parsers/src/gsapWriterAcorn.ts index 3d9bf7002..b29b77c32 100644 --- a/packages/parsers/src/gsapWriterAcorn.ts +++ b/packages/parsers/src/gsapWriterAcorn.ts @@ -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.(…)` 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("", …)` 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("", …)` 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); diff --git a/packages/studio/src/components/editor/GsapAnimationSection.tsx b/packages/studio/src/components/editor/GsapAnimationSection.tsx index 4aff3d3fb..a71f465df 100644 --- a/packages/studio/src/components/editor/GsapAnimationSection.tsx +++ b/packages/studio/src/components/editor/GsapAnimationSection.tsx @@ -46,9 +46,9 @@ export const GsapAnimationSection = memo(function GsapAnimationSection({ )} {unsupportedTimelinePattern && (

- This composition uses a timeline assignment pattern (window.__timelines[...]) that the - editor doesn'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't + resolve statically. Use a string-literal key (window.__timelines["id"]) or a + variable declaration (const tl = gsap.timeline()) to enable editing.

)} {multipleTimelines || unsupportedTimelinePattern ? null : (