mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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'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.
|
||||
</p>
|
||||
)}
|
||||
{multipleTimelines || unsupportedTimelinePattern ? null : (
|
||||
|
||||
Reference in New Issue
Block a user