Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
74b421c
feat(studio): element groups
miguel-heygen Jun 26, 2026
e6be1bb
feat(gsap): read timelines authored inline (acorn read path)
miguel-heygen Jun 26, 2026
d1ef72e
test(gsap): inline-timeline acorn write coverage (edit/add/delete/key…
miguel-heygen Jun 26, 2026
941a47e
feat(gsap): edit inline timelines via the recast writer (default serv…
miguel-heygen Jun 26, 2026
0db1e20
feat(studio): enable animation editing for static inline timelines
miguel-heygen Jun 26, 2026
3fcc8d8
feat(studio): expand sub-composition groups + children in the timeline
miguel-heygen Jun 26, 2026
661645d
fix(studio): hoverable group interior + non-sticky drill-in
miguel-heygen Jun 26, 2026
5b690c5
fix(studio): batch 3D depth commit so perspective + z don't race
miguel-heygen Jun 26, 2026
fc4ca0d
fix(studio): Enable keyframes on a set at/before its start drops a 0%…
miguel-heygen Jun 26, 2026
5159e98
fix(studio): correct keyframes + expansion for sub-composition timeli…
miguel-heygen Jun 26, 2026
f7c5327
fix(studio): remove keyframe dragging from the timeline
miguel-heygen Jun 26, 2026
eeff082
fix(studio): strip a group's GSAP when ungrouping
miguel-heygen Jun 26, 2026
06ddd8b
fix(studio): make 3D transform usable on any element
miguel-heygen Jun 27, 2026
fcc6202
fix(studio): track overlay and motion path through perspective transf…
miguel-heygen Jun 27, 2026
e349db2
fix(studio): keyframe commit routing for 3D and cross-group edits
miguel-heygen Jun 27, 2026
2c01c7a
fix(studio): bake the group transform into members on ungroup
miguel-heygen Jun 27, 2026
803f84d
chore(studio): add anonymous usage events
miguel-heygen Jun 27, 2026
33c2d29
chore(studio): timeline keyframe button copy and shortcut hint
miguel-heygen Jun 27, 2026
c55a2a5
chore(studio): remove debug logging
miguel-heygen Jun 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions packages/core/src/parsers/gsapParser.inline.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { describe, it, expect } from "vitest";
import {
parseGsapScript,
updateAnimationInScript,
addAnimationToScript,
removeAnimationFromScript,
addKeyframeToScript,
removeAllKeyframesFromScript,
} from "./gsapParser.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']");
});
});
102 changes: 79 additions & 23 deletions packages/core/src/parsers/gsapParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -401,15 +443,17 @@ 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();
recast.types.visit(ast, {
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);
}
}
Expand All @@ -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 ────────────────────────────────────────────────────
Expand All @@ -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[] {
Expand All @@ -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)) {
Expand Down Expand Up @@ -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);
Expand All @@ -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 = "";
Expand All @@ -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 {
Expand Down Expand Up @@ -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: "" };
}

Expand Down Expand Up @@ -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: "" };
}

Expand Down Expand Up @@ -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 };
}

Expand Down
75 changes: 75 additions & 0 deletions packages/core/src/parsers/gsapParserAcorn.inline.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
Loading
Loading