mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-12 15:20:13 +00:00
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:
@@ -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)');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user