fix(studio): make GSAP tween editing work on real compositions (#1115)

The Design-panel GSAP editor only recognized tweens written as
tl.to(".selector", {...}) with inline string-literal targets, in a
contiguous block, with no interleaved setup. Every scaffolded
composition instead targets tweens through element variables
(const kicker = root.querySelector(".kicker"); tl.to(kicker, {...})),
wraps the script in an IIFE, and interleaves gsap.set() calls — so the
parser returned zero animations and the panel was inert.

Three coordinated fixes make it work end to end:

- Parser read: resolve querySelector / querySelectorAll / getElementById
  variable targets (and inline lookup calls) back to their CSS selector,
  so variable-targeted tweens are recognized.

- Parser write: replace the full re-serialize (preamble + tweens +
  postamble) with in-place recast AST mutation. Edits now touch only the
  targeted tween's vars/position node and reprint, preserving every
  surrounding statement — gsap.set calls, element declarations, the IIFE
  wrapper, comments and formatting. Previously the first edit would
  discard all of that.

- Linter: build overlap/clip windows directly from the parser's
  structured animations instead of a regex walk paired positionally with
  the parsed list. The old pairing skipped variable targets and would
  drift once the parser started returning them. Removes the now-dead
  regex meta helpers.

- studio-api: extractGsapScriptBlock now searches inside <template>
  content (sub-compositions wrap markup + the GSAP script in a template,
  which linkedom's querySelectorAll doesn't descend into), and the
  frontend matches tweens to the selected element by id OR selector
  rather than id only (class-targeted elements have no id).

Verified end to end against a real 10-scene project: all compositions
now parse (previously 0), the panel populates editable tween cards, and
property/duration/ease edits round-trip while leaving the rest of the
script byte-for-byte intact.
This commit is contained in:
Miguel Ángel
2026-05-28 20:54:44 -04:00
committed by GitHub
parent 2f3ab9f4c9
commit 4de054e7d4
9 changed files with 753 additions and 186 deletions
+48 -128
View File
@@ -1,8 +1,12 @@
interface LintParsedGsap {
animations: Array<{
targetSelector: string;
method: string;
position: number | string;
properties: Record<string, number | string>;
duration?: number;
ease?: string;
extras?: Record<string, unknown>;
}>;
timelineVar: string;
}
@@ -39,7 +43,6 @@ type CompositionRange = {
end: number;
};
const META_GSAP_KEYS = new Set(["duration", "ease", "repeat", "yoyo", "overwrite", "delay"]);
const SCENE_BOUNDARY_EPSILON_SECONDS = 0.05;
// ── GSAP parsing utilities ─────────────────────────────────────────────────
@@ -125,6 +128,39 @@ function readRegisteredTimelineCompositionId(script: string): string | null {
return match?.[1] || null;
}
/** Strip a `__raw:` prefix the parser adds to unresolvable values. */
function unwrapRaw(value: unknown): string | number | undefined {
if (typeof value === "number") return value;
if (typeof value !== "string") return undefined;
const code = value.startsWith("__raw:") ? value.slice(6) : value;
return code.replace(/^\s*["']|["']\s*$/g, "");
}
function extrasNumber(value: unknown): number {
const unwrapped = unwrapRaw(value);
const numeric = typeof unwrapped === "number" ? unwrapped : Number(unwrapped);
return Number.isFinite(numeric) ? numeric : 0;
}
/** A readable single-line snippet of a tween for finding messages. */
function synthesizeWindowRaw(
timelineVar: string,
anim: LintParsedGsap["animations"][number],
): string {
const entries = Object.entries(anim.properties).map(([k, v]) => {
if (typeof v === "string" && v.startsWith("__raw:")) return `${k}: ${v.slice(6)}`;
return `${k}: ${typeof v === "string" ? JSON.stringify(v) : v}`;
});
if (anim.duration !== undefined) entries.push(`duration: ${anim.duration}`);
if (anim.ease) entries.push(`ease: ${JSON.stringify(anim.ease)}`);
const pos = typeof anim.position === "number" ? anim.position : JSON.stringify(anim.position);
return `${timelineVar}.${anim.method}("${anim.targetSelector}", { ${entries.join(", ")} }, ${pos})`;
}
// Build lint windows straight from the parser's structured animations. The
// parser already resolves variable targets (`tl.to(kicker, …)`) to selectors
// and excludes non-DOM object-target anchors (`tl.to({ _: 0 }, …)`), so there's
// no fragile positional pairing between a regex walk and the parsed list.
async function extractGsapWindows(script: string): Promise<GsapWindow[]> {
if (!/gsap\.timeline/.test(script)) return [];
const parseGsapScript = await loadParseGsapScript();
@@ -132,144 +168,28 @@ async function extractGsapWindows(script: string): Promise<GsapWindow[]> {
if (parsed.animations.length === 0) return [];
const windows: GsapWindow[] = [];
const timelineVar = parsed.timelineVar;
const methodPattern = new RegExp(
`${timelineVar}\\.(set|to|from|fromTo)\\s*\\(([^)]+(?:\\{[^}]*\\}[^)]*)+)\\)`,
"g",
);
let match: RegExpExecArray | null;
let index = 0;
while ((match = methodPattern.exec(script)) !== null && index < parsed.animations.length) {
const raw = match[0];
const args = match[2] ?? "";
// Skip calls whose first argument is not a quoted selector (e.g. object
// targets like `tl.to({ _: 0 }, …)` used to anchor timeline duration).
// `parseGsapScript` ignores those, so we must too — otherwise the regex
// match index drifts ahead of `parsed.animations[index]` and every
// subsequent window picks up the wrong animation's selector/position.
if (!/^\s*["']/.test(args)) continue;
const meta = parseGsapWindowMeta(match[1] ?? "", args);
const animation = parsed.animations[index];
index += 1;
if (!animation) continue;
for (const animation of parsed.animations) {
// Skip animations with string positions (e.g. "+=1", "<") — their absolute
// timing depends on runtime evaluation and can't be statically linted.
if (typeof animation.position !== "number") continue;
const repeat = extrasNumber(animation.extras?.repeat);
const cycleCount = repeat > 0 ? repeat + 1 : 1;
const effectiveDuration =
animation.method === "set" ? 0 : (animation.duration ?? 0) * cycleCount;
windows.push({
targetSelector: animation.targetSelector,
position: animation.position,
end: animation.position + meta.effectiveDuration,
properties: meta.properties.length > 0 ? meta.properties : Object.keys(animation.properties),
propertyValues: meta.propertyValues,
overwriteAuto: meta.overwriteAuto,
method: match[1] ?? "to",
raw,
end: animation.position + effectiveDuration,
properties: Object.keys(animation.properties),
propertyValues: animation.properties,
overwriteAuto: unwrapRaw(animation.extras?.overwrite) === "auto",
method: animation.method,
raw: synthesizeWindowRaw(parsed.timelineVar, animation),
});
}
return windows;
}
function parseGsapWindowMeta(
method: string,
argsStr: string,
): {
effectiveDuration: number;
properties: string[];
propertyValues: Record<string, string | number>;
overwriteAuto: boolean;
} {
const emptyMeta = {
effectiveDuration: 0,
properties: [],
propertyValues: {},
overwriteAuto: false,
};
const selectorMatch = argsStr.match(/^\s*["']([^"']+)["']\s*,/);
if (!selectorMatch) return emptyMeta;
const afterSelector = argsStr.slice(selectorMatch[0].length);
let properties: Record<string, string | number> = {};
let fromProperties: Record<string, string | number> = {};
if (method === "fromTo") {
const firstBrace = afterSelector.indexOf("{");
const firstEnd = findMatchingBrace(afterSelector, firstBrace);
if (firstBrace !== -1 && firstEnd !== -1) {
fromProperties = parseLooseObjectLiteral(afterSelector.slice(firstBrace, firstEnd + 1));
const secondPart = afterSelector.slice(firstEnd + 1);
const secondBrace = secondPart.indexOf("{");
const secondEnd = findMatchingBrace(secondPart, secondBrace);
if (secondBrace !== -1 && secondEnd !== -1) {
properties = parseLooseObjectLiteral(secondPart.slice(secondBrace, secondEnd + 1));
}
}
} else {
const braceStart = afterSelector.indexOf("{");
const braceEnd = findMatchingBrace(afterSelector, braceStart);
if (braceStart !== -1 && braceEnd !== -1) {
properties = parseLooseObjectLiteral(afterSelector.slice(braceStart, braceEnd + 1));
}
}
const duration = numberValue(properties.duration) || 0;
const repeat = numberValue(properties.repeat) || 0;
const cycleCount = repeat > 0 ? repeat + 1 : 1;
const effectiveDuration = duration * cycleCount;
const overwriteAuto = stringValue(properties.overwrite) === "auto";
const propertyNames = new Set<string>();
for (const key of Object.keys(fromProperties)) {
if (!META_GSAP_KEYS.has(key)) propertyNames.add(key);
}
for (const key of Object.keys(properties)) {
if (!META_GSAP_KEYS.has(key)) propertyNames.add(key);
}
return {
effectiveDuration: method === "set" ? 0 : effectiveDuration,
properties: [...propertyNames],
propertyValues: properties,
overwriteAuto,
};
}
function parseLooseObjectLiteral(source: string): Record<string, string | number> {
const result: Record<string, string | number> = {};
const cleaned = source.replace(/^\{|\}$/g, "").trim();
if (!cleaned) return result;
const propertyPattern = /(\w+)\s*:\s*("[^"]*"|'[^']*'|true|false|-?[\d.]+|[a-zA-Z_][\w.]*)/g;
let match: RegExpExecArray | null;
while ((match = propertyPattern.exec(cleaned)) !== null) {
const key = match[1];
const rawValue = match[2];
if (!key || rawValue == null) continue;
if (
(rawValue.startsWith('"') && rawValue.endsWith('"')) ||
(rawValue.startsWith("'") && rawValue.endsWith("'"))
) {
result[key] = rawValue.slice(1, -1);
continue;
}
const numeric = Number(rawValue);
result[key] = Number.isFinite(numeric) ? numeric : rawValue;
}
return result;
}
function findMatchingBrace(source: string, startIndex: number): number {
if (startIndex < 0) return -1;
let depth = 0;
for (let i = startIndex; i < source.length; i++) {
if (source[i] === "{") depth += 1;
else if (source[i] === "}") {
depth -= 1;
if (depth === 0) return i;
}
}
return -1;
}
function numberValue(value: string | number | undefined): number | null {
if (typeof value === "number") return value;
if (typeof value === "string" && value.trim()) {
@@ -856,7 +856,7 @@ describe("Additional edge cases", () => {
expect(result.animations[0].targetSelector).toBe("#el2");
});
it("non-string selector (variable reference) is skipped", () => {
it("resolves a variable reference selector to its queried CSS selector", () => {
const script = `
const el = document.querySelector("#el");
const tl = gsap.timeline({ paused: true });
@@ -864,7 +864,20 @@ describe("Additional edge cases", () => {
tl.to("#el2", { x: 100, duration: 0.5 }, 0);
`;
const result = parseGsapScript(script);
// First tween has a variable reference as selector, not a string literal — skipped
// `el` is bound to `document.querySelector("#el")`, so it resolves to "#el".
expect(result.animations).toHaveLength(2);
expect(result.animations[0].targetSelector).toBe("#el");
expect(result.animations[1].targetSelector).toBe("#el2");
});
it("skips a variable target that is not bound to a DOM lookup", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to(mysteryTarget, { opacity: 1, duration: 0.5 }, 0);
tl.to("#el2", { x: 100, duration: 0.5 }, 0);
`;
const result = parseGsapScript(script);
// mysteryTarget has no resolvable selector binding — only the literal survives.
expect(result.animations).toHaveLength(1);
expect(result.animations[0].targetSelector).toBe("#el2");
});
@@ -813,3 +813,211 @@ describe("SUPPORTED_EASES", () => {
expect(SUPPORTED_EASES).toContain("elastic.inOut");
});
});
// ── Variable-target resolution + in-place mutation ──────────────────────────
//
// Real compositions (and everything the hyperframes skill generates) target
// tweens via element variables resolved from querySelector, wrapped in an IIFE,
// with gsap.set() calls interleaved between tl.to() calls. The parser must
// resolve those variable targets to selectors (read) and edits must preserve
// every surrounding statement (write).
const REAL_WORLD_SCRIPT = `(function () {
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
const root = document.querySelector('#cold-open');
const kicker = root.querySelector(".co-kicker");
const glyph = root.querySelector(".co-new");
const items = root.querySelectorAll(".co-item");
gsap.set(kicker, { y: 16, opacity: 0 });
tl.to(kicker, { y: 0, opacity: 1, duration: 0.45, ease: "expo.out" }, 0.3);
gsap.set(glyph, { rotationX: 90, opacity: 0 });
tl.to(glyph, { rotationX: 0, opacity: 1, duration: 0.5, ease: "power3.inOut" }, 2.06);
tl.to(items, { opacity: 1, duration: 0.4, stagger: 0.1 }, 1.0);
window.__timelines["cold-open"] = tl;
})();`;
describe("variable-target resolution (querySelector pattern)", () => {
it("resolves a const element variable to its selector", () => {
const script = `
const root = document.querySelector('#scene');
const kicker = root.querySelector(".co-kicker");
const tl = gsap.timeline({ paused: true });
tl.to(kicker, { y: 0, opacity: 1, duration: 0.45, ease: "expo.out" }, 0.3);
`;
const result = parseGsapScript(script);
expect(result.animations).toHaveLength(1);
expect(result.animations[0].targetSelector).toBe(".co-kicker");
expect(result.animations[0].properties.opacity).toBe(1);
expect(result.animations[0].duration).toBe(0.45);
expect(result.animations[0].ease).toBe("expo.out");
});
it("resolves document.querySelector and querySelectorAll targets", () => {
const script = `
const tl = gsap.timeline({ paused: true });
const title = document.querySelector("#title");
const items = document.querySelectorAll(".item");
tl.to(title, { opacity: 1, duration: 0.5 }, 0);
tl.to(items, { y: 0, duration: 0.5, stagger: 0.1 }, 0.5);
`;
const result = parseGsapScript(script);
expect(result.animations).toHaveLength(2);
expect(result.animations[0].targetSelector).toBe("#title");
expect(result.animations[1].targetSelector).toBe(".item");
});
it("resolves getElementById targets to an id selector", () => {
const script = `
const tl = gsap.timeline({ paused: true });
const el = document.getElementById("hero");
tl.to(el, { opacity: 1, duration: 0.5 }, 0);
`;
const result = parseGsapScript(script);
expect(result.animations).toHaveLength(1);
expect(result.animations[0].targetSelector).toBe("#hero");
});
it("resolves an inline querySelector call passed directly as the target", () => {
const script = `
const root = document.querySelector('#scene');
const tl = gsap.timeline({ paused: true });
tl.to(root.querySelector(".inline"), { opacity: 1, duration: 0.5 }, 0);
`;
const result = parseGsapScript(script);
expect(result.animations).toHaveLength(1);
expect(result.animations[0].targetSelector).toBe(".inline");
});
it("parses mixed string-literal and variable targets in one timeline", () => {
const script = `
const tl = gsap.timeline({ paused: true });
const kicker = document.querySelector(".kicker");
tl.to(".literal", { opacity: 1, duration: 0.5 }, 0);
tl.to(kicker, { y: 0, duration: 0.5 }, 0.5);
`;
const result = parseGsapScript(script);
expect(result.animations.map((a) => a.targetSelector)).toEqual([".literal", ".kicker"]);
});
it("parses every tween in a real-world IIFE composition with interleaved gsap.set", () => {
const result = parseGsapScript(REAL_WORLD_SCRIPT);
expect(result.animations.map((a) => a.targetSelector)).toEqual([
".co-kicker",
".co-new",
".co-item",
]);
// stagger preserved as extras
expect(result.animations[2].extras?.stagger).toBe("__raw:0.1");
});
it("leaves unresolvable variable targets out of the animation list", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to(someUnknownThing, { opacity: 1, duration: 0.5 }, 0);
tl.to(".real", { opacity: 1, duration: 0.5 }, 1);
`;
const result = parseGsapScript(script);
expect(result.animations.map((a) => a.targetSelector)).toEqual([".real"]);
});
});
describe("in-place AST mutation preserves surrounding code", () => {
it("updateAnimationInScript edits one tween and preserves gsap.set + var decls + IIFE", () => {
const parsed = parseGsapScript(REAL_WORLD_SCRIPT);
const kickerAnim = parsed.animations.find((a) => a.targetSelector === ".co-kicker")!;
const updated = updateAnimationInScript(REAL_WORLD_SCRIPT, kickerAnim.id, {
properties: { y: 0, opacity: 0.5 },
});
// The edit landed
expect(updated).toContain("opacity: 0.5");
// Surrounding code survived verbatim
expect(updated).toContain('const kicker = root.querySelector(".co-kicker")');
expect(updated).toContain("gsap.set(kicker, { y: 16, opacity: 0 })");
expect(updated).toContain("gsap.set(glyph, { rotationX: 90, opacity: 0 })");
expect(updated).toContain('window.__timelines["cold-open"] = tl;');
expect(updated).toContain("(function () {");
// The variable target was NOT rewritten to a string literal
expect(updated).toContain("tl.to(kicker,");
expect(updated).not.toContain('tl.to(".co-kicker"');
// The other tweens are untouched
expect(updated).toContain("tl.to(glyph,");
expect(updated).toContain("tl.to(items,");
});
it("updateAnimationInScript re-parses to the edited value (round-trip)", () => {
const parsed = parseGsapScript(REAL_WORLD_SCRIPT);
const glyphAnim = parsed.animations.find((a) => a.targetSelector === ".co-new")!;
const updated = updateAnimationInScript(REAL_WORLD_SCRIPT, glyphAnim.id, {
properties: { rotationX: 0, opacity: 1, scale: 1.2 },
});
const reparsed = parseGsapScript(updated);
const reGlyph = reparsed.animations.find((a) => a.targetSelector === ".co-new")!;
expect(reGlyph.properties.scale).toBe(1.2);
// unrelated tweens still present
expect(reparsed.animations).toHaveLength(3);
});
it("update-meta edits duration/ease/position in place", () => {
const parsed = parseGsapScript(REAL_WORLD_SCRIPT);
const kickerAnim = parsed.animations.find((a) => a.targetSelector === ".co-kicker")!;
const updated = updateAnimationInScript(REAL_WORLD_SCRIPT, kickerAnim.id, {
duration: 0.9,
ease: "power1.in",
});
const reparsed = parseGsapScript(updated);
const reKicker = reparsed.animations.find((a) => a.targetSelector === ".co-kicker")!;
expect(reKicker.duration).toBe(0.9);
expect(reKicker.ease).toBe("power1.in");
// surrounding code intact
expect(updated).toContain("gsap.set(kicker, { y: 16, opacity: 0 })");
});
it("removeAnimationFromScript removes one tween and keeps the rest + setup", () => {
const parsed = parseGsapScript(REAL_WORLD_SCRIPT);
const glyphAnim = parsed.animations.find((a) => a.targetSelector === ".co-new")!;
const updated = removeAnimationFromScript(REAL_WORLD_SCRIPT, glyphAnim.id);
const reparsed = parseGsapScript(updated);
expect(reparsed.animations.map((a) => a.targetSelector)).toEqual([".co-kicker", ".co-item"]);
// the removed tween's gsap.set setup is left untouched (not the parser's job to remove)
expect(updated).toContain('const kicker = root.querySelector(".co-kicker")');
expect(updated).toContain('window.__timelines["cold-open"] = tl;');
});
it("addAnimationToScript inserts a tween and preserves the IIFE body", () => {
const { script: updated, id } = addAnimationToScript(REAL_WORLD_SCRIPT, {
targetSelector: "#new-el",
method: "to",
position: 3,
duration: 0.5,
ease: "power2.out",
properties: { opacity: 1 },
});
expect(id).not.toBe("");
expect(updated).toContain('window.__timelines["cold-open"] = tl;');
expect(updated).toContain('const kicker = root.querySelector(".co-kicker")');
const reparsed = parseGsapScript(updated);
expect(reparsed.animations.some((a) => a.targetSelector === "#new-el")).toBe(true);
expect(reparsed.animations).toHaveLength(4);
});
it("still edits classic string-literal timelines in place", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el1", { opacity: 1, duration: 0.5 }, 0);
tl.to("#el2", { x: 100, duration: 1 }, 1);
`;
const parsed = parseGsapScript(script);
const updated = updateAnimationInScript(script, parsed.animations[0].id, {
properties: { opacity: 0.25 },
});
expect(updated).toContain("opacity: 0.25");
// second tween untouched
expect(updated).toContain('tl.to("#el2", { x: 100, duration: 1 }, 1)');
});
});
+313 -48
View File
@@ -10,12 +10,7 @@
*/
import * as recast from "recast";
import { parse as babelParse } from "@babel/parser";
import {
type GsapAnimation,
type GsapMethod,
type ParsedGsap,
serializeGsapAnimations,
} from "./gsapSerialize";
import { type GsapAnimation, type GsapMethod, type ParsedGsap } from "./gsapSerialize";
export type { GsapAnimation, GsapMethod, ParsedGsap } from "./gsapSerialize";
export {
@@ -109,6 +104,81 @@ function extractLiteralValue(node: any, scope: ScopeBindings): unknown {
return resolveNode(node, scope);
}
// ── Element-target resolution ───────────────────────────────────────────────
//
// Real compositions target tweens through element variables resolved from the
// DOM (`const kicker = root.querySelector(".kicker"); tl.to(kicker, …)`) rather
// than inline string selectors. To make those tweens editable we map each such
// variable back to the CSS selector it was queried with.
const QUERY_METHODS = new Set(["querySelector", "querySelectorAll"]);
/**
* If `node` is a DOM lookup call (`x.querySelector(".sel")`,
* `document.querySelectorAll(".sel")`, `document.getElementById("id")`),
* return the CSS selector it resolves to. `getElementById("id")` maps to
* `#id`. Returns null for anything else.
*/
function selectorFromQueryCall(node: any, scope: ScopeBindings): string | null {
if (node?.type !== "CallExpression") return null;
const callee = node.callee;
if (callee?.type !== "MemberExpression" || callee.property?.type !== "Identifier") return null;
const method = callee.property.name;
const argValue = resolveNode(node.arguments?.[0], scope);
if (typeof argValue !== "string" || argValue.length === 0) return null;
if (QUERY_METHODS.has(method)) return argValue;
if (method === "getElementById") return `#${argValue}`;
return null;
}
type TargetBindings = ReadonlyMap<string, string>;
/** Map element variables (assigned from a DOM lookup) to their CSS selector. */
function collectTargetBindings(ast: any, scope: ScopeBindings): TargetBindings {
const bindings = new Map<string, string>();
recast.types.visit(ast, {
visitVariableDeclarator(path: any) {
const name = path.node.id?.name;
const selector = selectorFromQueryCall(path.node.init, scope);
if (name && selector !== null) bindings.set(name, selector);
this.traverse(path);
},
visitAssignmentExpression(path: any) {
const left = path.node.left;
const selector = selectorFromQueryCall(path.node.right, scope);
if (left?.type === "Identifier" && selector !== null && !bindings.has(left.name)) {
bindings.set(left.name, selector);
}
this.traverse(path);
},
});
return bindings;
}
/**
* Resolve a tween's first argument to a CSS selector. Handles inline string
* literals, element variables (via {@link collectTargetBindings}), and inline
* DOM lookup calls. Returns null when the target can't be resolved statically
* (e.g. an object-target duration anchor `tl.to({ _: 0 }, …)`).
*/
function resolveTargetSelector(
node: any,
scope: ScopeBindings,
targetBindings: TargetBindings,
): string | null {
if (!node) return null;
if (node.type === "StringLiteral" || node.type === "Literal") {
return typeof node.value === "string" ? node.value : null;
}
if (node.type === "Identifier") {
return targetBindings.get(node.name) ?? null;
}
if (node.type === "CallExpression") {
return selectorFromQueryCall(node, scope);
}
return null;
}
function objectExpressionToRecord(node: any, scope: ScopeBindings): Record<string, unknown> {
const result: Record<string, unknown> = {};
if (node?.type !== "ObjectExpression") return result;
@@ -180,7 +250,12 @@ interface TweenCallInfo {
positionArg?: any;
}
function findAllTweenCalls(ast: any, timelineVar: string): TweenCallInfo[] {
function findAllTweenCalls(
ast: any,
timelineVar: string,
scope: ScopeBindings,
targetBindings: TargetBindings,
): TweenCallInfo[] {
const results: TweenCallInfo[] = [];
recast.types.visit(ast, {
visitCallExpression(path: any) {
@@ -202,11 +277,7 @@ function findAllTweenCalls(ast: any, timelineVar: string): TweenCallInfo[] {
this.traverse(path);
return;
}
const selectorArg = args[0];
const selectorValue =
selectorArg.type === "StringLiteral" || selectorArg.type === "Literal"
? String(selectorArg.value)
: null;
const selectorValue = resolveTargetSelector(args[0], scope, targetBindings);
if (!selectorValue) {
this.traverse(path);
return;
@@ -348,16 +419,45 @@ function assignStableIds(anims: Omit<GsapAnimation, "id">[]): GsapAnimation[] {
});
}
// ── Shared parse (AST + located tween calls) ────────────────────────────────
interface ParsedGsapAst {
ast: any;
scope: ScopeBindings;
timelineVar: string;
detection: TimelineDetection;
/** Tween calls in document order, each paired with its stable animation id. */
located: Array<{ id: string; call: TweenCallInfo; animation: GsapAnimation }>;
}
/**
* Parse a script to its recast AST plus the located tween calls. The mutation
* functions reuse this so they can edit the exact call node in place (recast
* preserves all surrounding source — interleaved `gsap.set`, element variable
* declarations, the IIFE wrapper, comments and formatting).
*/
function parseGsapAst(script: string): ParsedGsapAst {
const ast = parseScript(script);
const scope = collectScopeBindings(ast);
const targetBindings = collectTargetBindings(ast, scope);
const detection = findTimelineVar(ast);
const timelineVar = detection.timelineVar ?? "tl";
const calls = findAllTweenCalls(ast, timelineVar, scope, targetBindings);
const animations = assignStableIds(calls.map((call) => tweenCallToAnimation(call, scope)));
const located = animations.map((animation, i) => ({
id: animation.id,
call: calls[i]!,
animation,
}));
return { ast, scope, timelineVar, detection, located };
}
// ── Public API ──────────────────────────────────────────────────────────────
export function parseGsapScript(script: string): ParsedGsap {
try {
const ast = parseScript(script);
const scope = collectScopeBindings(ast);
const detection = findTimelineVar(ast);
const timelineVar = detection.timelineVar ?? "tl";
const calls = findAllTweenCalls(ast, timelineVar);
const animations = assignStableIds(calls.map((call) => tweenCallToAnimation(call, scope)));
const { detection, timelineVar, located } = parseGsapAst(script);
const animations = located.map((l) => l.animation);
const timelineMatch = script.match(
new RegExp(
@@ -387,9 +487,136 @@ export function parseGsapScript(script: string): ParsedGsap {
}
}
/** Returns true when the parse result is a failure fallback (no animations, no preamble). */
function isParseFailure(parsed: ParsedGsap): boolean {
return parsed.animations.length === 0 && !parsed.preamble;
// ── In-place AST mutation helpers ───────────────────────────────────────────
//
// Edits operate directly on the located call's AST node and reprint via recast,
// which preserves every untouched statement. This is what lets us edit tweens
// in real compositions (variable targets, interleaved `gsap.set`, IIFE wrapper)
// without regenerating — and discarding — the surrounding code.
/** Render a model value to the JS source it should emit as. Mirrors gsapSerialize. */
function valueToCode(value: number | string): string {
if (typeof value === "string" && value.startsWith("__raw:")) return value.slice(6);
if (typeof value === "string") return JSON.stringify(value);
return String(value);
}
function safeKey(key: string): string {
return /^[A-Za-z_$][\w$]*$/.test(key) ? key : JSON.stringify(key);
}
/**
* Parse a value/expression snippet into a standalone AST expression node.
* Uses an assignment (`__hf__ = <code>`) rather than wrapping in parens so an
* object literal parses as an expression without recast re-emitting the
* surrounding parentheses.
*/
function parseExpr(code: string): any {
return parseScript(`__hf__ = ${code};`).program.body[0].expression.right;
}
function propKeyName(prop: any): string | undefined {
return prop?.key?.name ?? prop?.key?.value;
}
function isObjectProperty(prop: any): boolean {
return prop?.type === "ObjectProperty" || prop?.type === "Property";
}
/** A key the inspector treats as an editable transform/style property. */
function isEditablePropertyKey(key: string): boolean {
return !BUILTIN_VAR_KEYS.has(key) && !DROPPED_VAR_KEYS.has(key) && !EXTRAS_KEYS.has(key);
}
function makeObjectProperty(key: string, value: number | string): any {
const obj = parseExpr(`{ ${safeKey(key)}: ${valueToCode(value)} }`);
return obj.properties[0];
}
/** Set (or insert) a single key on an ObjectExpression, preserving sibling keys. */
function setVarsKey(varsArg: any, key: string, value: number | string): void {
if (varsArg?.type !== "ObjectExpression") return;
const existing = varsArg.properties.find(
(p: any) => isObjectProperty(p) && propKeyName(p) === key,
);
if (existing) {
existing.value = parseExpr(valueToCode(value));
} else {
varsArg.properties.push(makeObjectProperty(key, value));
}
}
/**
* Replace the editable-property keys on an ObjectExpression with `newProps`,
* leaving `duration`, `ease`, `stagger`, callbacks and other non-editable keys
* untouched.
*/
function reconcileEditableProperties(
varsArg: any,
newProps: Record<string, number | string>,
): void {
if (varsArg?.type !== "ObjectExpression") return;
// Drop editable props no longer present.
varsArg.properties = varsArg.properties.filter((p: any) => {
if (!isObjectProperty(p)) return true;
const key = propKeyName(p);
if (typeof key !== "string") return true;
if (!isEditablePropertyKey(key)) return true;
return key in newProps;
});
// Upsert each new prop, preserving the order keys first appeared.
for (const [key, value] of Object.entries(newProps)) {
setVarsKey(varsArg, key, value);
}
}
function applyUpdatesToCall(call: TweenCallInfo, updates: Partial<GsapAnimation>): void {
if (updates.properties) reconcileEditableProperties(call.varsArg, updates.properties);
if (updates.fromProperties && call.method === "fromTo") {
reconcileEditableProperties(call.fromArg, updates.fromProperties);
}
if (updates.duration !== undefined) setVarsKey(call.varsArg, "duration", updates.duration);
if (updates.ease !== undefined) setVarsKey(call.varsArg, "ease", updates.ease);
if (updates.position !== undefined) {
const posIdx = call.method === "fromTo" ? 3 : 2;
call.node.arguments[posIdx] = parseExpr(valueToCode(updates.position));
}
}
/** Walk up to the enclosing ExpressionStatement path (for prune / insertAfter). */
function findStatementPath(path: any): any {
let p = path;
while (p) {
if (p.node?.type === "ExpressionStatement") return p;
p = p.parentPath;
}
return null;
}
/** Build the source for a single `tl.method(selector, vars, position)` call. */
function buildTweenStatementCode(timelineVar: string, anim: Omit<GsapAnimation, "id">): string {
const selector = JSON.stringify(anim.targetSelector);
const props: Record<string, number | string> = { ...anim.properties };
if (anim.duration !== undefined) props.duration = anim.duration;
if (anim.ease) props.ease = anim.ease;
const entries = Object.entries(props).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`);
if (anim.extras) {
for (const [k, v] of Object.entries(anim.extras)) {
entries.push(`${safeKey(k)}: ${valueToCode(v as number | string)}`);
}
}
const objCode = `{ ${entries.join(", ")} }`;
const posCode = valueToCode(
typeof anim.position === "number" ? anim.position : (anim.position ?? 0),
);
if (anim.method === "fromTo") {
const fromEntries = Object.entries(anim.fromProperties ?? {}).map(
([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`,
);
const fromCode = `{ ${fromEntries.join(", ")} }`;
return `${timelineVar}.fromTo(${selector}, ${fromCode}, ${objCode}, ${posCode});`;
}
return `${timelineVar}.${anim.method}(${selector}, ${objCode}, ${posCode});`;
}
export function updateAnimationInScript(
@@ -397,41 +624,79 @@ export function updateAnimationInScript(
animationId: string,
updates: Partial<GsapAnimation>,
): string {
const parsed = parseGsapScript(script);
if (isParseFailure(parsed)) return script;
const updated = parsed.animations.map((anim) =>
anim.id === animationId ? { ...anim, ...updates } : anim,
);
return serializeGsapAnimations(updated, parsed.timelineVar, {
preamble: parsed.preamble,
postamble: parsed.postamble,
});
let parsed: ParsedGsapAst;
try {
parsed = parseGsapAst(script);
} catch {
return script;
}
const target = parsed.located.find((l) => l.id === animationId);
if (!target) return script;
applyUpdatesToCall(target.call, updates);
return recast.print(parsed.ast).code;
}
export function addAnimationToScript(
script: string,
animation: Omit<GsapAnimation, "id">,
): { script: string; id: string } {
const parsed = parseGsapScript(script);
if (isParseFailure(parsed)) return { script, id: "" };
let parsed: ParsedGsapAst;
try {
parsed = parseGsapAst(script);
} catch {
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) {
return { script, id: "" };
}
const id = `anim-${Date.now()}`;
const newAnim: GsapAnimation = { ...animation, id };
const allAnimations = [...parsed.animations, newAnim];
return {
script: serializeGsapAnimations(allAnimations, parsed.timelineVar, {
preamble: parsed.preamble,
postamble: parsed.postamble,
}),
id,
};
const statementCode = buildTweenStatementCode(parsed.timelineVar, animation);
const newStatement = parseScript(statementCode).program.body[0];
const lastCall = parsed.located[parsed.located.length - 1]?.call;
const anchorPath = lastCall
? findStatementPath(lastCall.path)
: findTimelineDeclarationPath(parsed.ast, parsed.timelineVar);
if (anchorPath) {
anchorPath.insertAfter(newStatement);
} else {
parsed.ast.program.body.push(newStatement);
}
return { script: recast.print(parsed.ast).code, id };
}
/** Find the statement path of `const <timelineVar> = gsap.timeline(...)`. */
function findTimelineDeclarationPath(ast: any, timelineVar: string): any {
let found: any = null;
recast.types.visit(ast, {
visitVariableDeclaration(path: any) {
if (found) return false;
for (const decl of path.node.declarations ?? []) {
if (decl.id?.name === timelineVar && isGsapTimelineCall(decl.init)) {
found = path;
return false;
}
}
this.traverse(path);
},
});
return found;
}
export function removeAnimationFromScript(script: string, animationId: string): string {
const parsed = parseGsapScript(script);
if (isParseFailure(parsed)) return script;
const filtered = parsed.animations.filter((a) => a.id !== animationId);
return serializeGsapAnimations(filtered, parsed.timelineVar, {
preamble: parsed.preamble,
postamble: parsed.postamble,
});
let parsed: ParsedGsapAst;
try {
parsed = parseGsapAst(script);
} catch {
return script;
}
const target = parsed.located.find((l) => l.id === animationId);
if (!target) return script;
const stmtPath = findStatementPath(target.call.path);
if (!stmtPath) return script;
stmtPath.prune();
return recast.print(parsed.ast).code;
}
@@ -1,6 +1,6 @@
import { afterEach, describe, expect, it } from "vitest";
import { Hono } from "hono";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { registerFileRoutes } from "./files";
@@ -63,4 +63,89 @@ describe("registerFileRoutes", () => {
expect(response.status).toBe(404);
});
// A realistic sub-composition: markup + GSAP wrapped in a <template>, tweens
// targeting element variables resolved from querySelector, with interleaved
// gsap.set() calls. This is the shape every scaffolded composition uses.
const TEMPLATE_COMP = `<template id="scene-template">
<div id="scene" data-composition-id="scene" data-width="1920" data-height="1080" data-start="0" data-duration="3">
<div class="kicker">HELLO</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script>
(function () {
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
const root = document.querySelector('#scene');
const kicker = root.querySelector(".kicker");
gsap.set(kicker, { y: 16, opacity: 0 });
tl.to(kicker, { y: 0, opacity: 1, duration: 0.45, ease: "expo.out" }, 0.3);
window.__timelines["scene"] = tl;
})();
</script>
</template>`;
function writeComp(projectDir: string, name: string, html: string): void {
const dir = join(projectDir, "compositions");
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, name), html);
}
it("parses GSAP tweens from a <template>-wrapped sub-composition with variable targets", async () => {
const projectDir = createProjectDir();
writeComp(projectDir, "scene.html", TEMPLATE_COMP);
const app = new Hono();
registerFileRoutes(app, createAdapter(projectDir));
const response = await app.request(
"http://localhost/projects/demo/gsap-animations/compositions/scene.html",
);
const payload = (await response.json()) as {
animations: Array<{ id: string; targetSelector: string; properties: Record<string, number> }>;
};
expect(response.status).toBe(200);
expect(payload.animations).toHaveLength(1);
expect(payload.animations[0].targetSelector).toBe(".kicker");
});
it("edits a template-wrapped tween in place, preserving gsap.set and the IIFE", async () => {
const projectDir = createProjectDir();
writeComp(projectDir, "scene.html", TEMPLATE_COMP);
const app = new Hono();
registerFileRoutes(app, createAdapter(projectDir));
const parseRes = await app.request(
"http://localhost/projects/demo/gsap-animations/compositions/scene.html",
);
const { animations } = (await parseRes.json()) as { animations: Array<{ id: string }> };
const animationId = animations[0].id;
const mutateRes = await app.request(
"http://localhost/projects/demo/gsap-mutations/compositions/scene.html",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "update-property",
animationId,
property: "opacity",
value: 0.5,
}),
},
);
const result = (await mutateRes.json()) as { ok: boolean; after: string };
expect(mutateRes.status).toBe(200);
expect(result.ok).toBe(true);
// Edit landed
expect(result.after).toContain("opacity: 0.5");
// Surrounding code preserved verbatim — the in-place AST edit didn't rewrite the block
expect(result.after).toContain("gsap.set(kicker, { y: 16, opacity: 0 })");
expect(result.after).toContain('const kicker = root.querySelector(".kicker")');
expect(result.after).toContain('window.__timelines["scene"] = tl;');
expect(result.after).toContain("(function () {");
// The variable target was not flattened to a string-literal selector
expect(result.after).toContain("tl.to(kicker,");
});
});
+11 -1
View File
@@ -194,7 +194,17 @@ function extractGsapScriptBlock(
html: string,
): { scriptText: string; replaceScript: (newText: string) => string } | null {
const { document } = parseHTML(html);
const scripts = document.querySelectorAll("script:not([src])");
// linkedom's querySelectorAll doesn't descend into <template> content, but
// sub-compositions wrap their markup (and the GSAP <script>) in a <template>.
// Search top-level scripts first, then each template's own scripts. Operate
// on the template element directly (NOT .content) so textContent writes are
// reflected in document.toString().
const scripts = [
...document.querySelectorAll("script:not([src])"),
...Array.from(document.querySelectorAll("template")).flatMap((tmpl) =>
Array.from(tmpl.querySelectorAll("script:not([src])")),
),
];
for (const script of scripts) {
const content = script.textContent || "";
if (
@@ -202,7 +202,9 @@ export function useDomEditSession({
} = useGsapAnimationsForElement(
STUDIO_GSAP_PANEL_ENABLED ? (projectId ?? null) : null,
domEditSelection?.sourceFile || activeCompPath || "index.html",
domEditSelection?.id ?? null,
domEditSelection
? { id: domEditSelection.id ?? null, selector: domEditSelection.selector ?? null }
: null,
gsapCacheVersion,
);
@@ -0,0 +1,40 @@
import { describe, it, expect } from "vitest";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import { getAnimationsForElement } from "./useGsapTweenCache";
function anim(targetSelector: string): GsapAnimation {
return {
id: `${targetSelector}-to-0`,
targetSelector,
method: "to",
position: 0,
properties: {},
};
}
describe("getAnimationsForElement", () => {
const animations = [anim("#hero"), anim(".kicker"), anim(".kicker"), anim(".co-new")];
it("matches tweens by element id", () => {
const result = getAnimationsForElement(animations, { id: "hero" });
expect(result.map((a) => a.targetSelector)).toEqual(["#hero"]);
});
it("matches class-targeted tweens by the element's selector", () => {
// Real compositions target tweens by class (querySelector(".kicker")); the
// selected element has no id, so id-only matching would miss these.
const result = getAnimationsForElement(animations, { id: null, selector: ".kicker" });
expect(result).toHaveLength(2);
expect(result.every((a) => a.targetSelector === ".kicker")).toBe(true);
});
it("matches by id or selector when both are present", () => {
const result = getAnimationsForElement(animations, { id: "hero", selector: ".co-new" });
expect(result.map((a) => a.targetSelector).sort()).toEqual(["#hero", ".co-new"]);
});
it("returns nothing when neither id nor selector is provided", () => {
expect(getAnimationsForElement(animations, {})).toEqual([]);
expect(getAnimationsForElement(animations, { id: null, selector: null })).toEqual([]);
});
});
+29 -5
View File
@@ -1,8 +1,27 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { GsapAnimation, ParsedGsap } from "@hyperframes/core/gsap-parser";
function getAnimationsForElement(animations: GsapAnimation[], elementId: string): GsapAnimation[] {
return animations.filter((a) => a.targetSelector === `#${elementId}`);
/** The selected element's identity for matching tweens to it. */
export interface GsapElementTarget {
id?: string | null;
selector?: string | null;
}
/**
* A tween belongs to the selected element when its target selector addresses
* that element either by id (`#id`) or by the exact CSS selector the element
* was selected through (`.kicker`). Real compositions target tweens by class
* via `querySelector`, so id-only matching misses them.
*/
export function getAnimationsForElement(
animations: GsapAnimation[],
target: GsapElementTarget,
): GsapAnimation[] {
const matchers = new Set<string>();
if (target.id) matchers.add(`#${target.id}`);
if (target.selector) matchers.add(target.selector);
if (matchers.size === 0) return [];
return animations.filter((a) => matchers.has(a.targetSelector));
}
async function fetchParsedAnimations(
@@ -22,7 +41,7 @@ async function fetchParsedAnimations(
export function useGsapAnimationsForElement(
projectId: string | null,
sourceFile: string,
elementId: string | null,
target: GsapElementTarget | null,
version: number,
): {
animations: GsapAnimation[];
@@ -65,9 +84,14 @@ export function useGsapAnimationsForElement(
};
}, [projectId, sourceFile, version]);
const targetId = target?.id ?? null;
const targetSelector = target?.selector ?? null;
const animations = useMemo(
() => (elementId ? getAnimationsForElement(allAnimations, elementId) : []),
[allAnimations, elementId],
() =>
targetId || targetSelector
? getAnimationsForElement(allAnimations, { id: targetId, selector: targetSelector })
: [],
[allAnimations, targetId, targetSelector],
);
return { animations, multipleTimelines, unsupportedTimelinePattern };