mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix: resolve computed GSAP timelines + drag improvements in Studio (#1506)
* feat(core): add param-substitution utility for GSAP timeline inlining U1: clone + shadow-aware identifier substitution over acorn ESTree, plus provenance tagging and a GsapProvenance type. Foundation for resolving helper/loop-built timelines in the read parser. * feat(core): inline helper-built and bounded-loop GSAP timelines U2: expansion pre-pass that rewrites the analysis AST so a helper called N times, a literal-bounds for-loop, a for-of, or a forEach over an inline array each become concrete per-call/per-iteration tl.* statements with substituted positions and provenance tags. Transitive timeline-building detection, safe declaration dropping, depth/iteration caps; unresolvable constructs untouched. * feat(core): resolve computed GSAP timelines in the read parser U3: parseGsapScriptAcorn runs the inlining pre-pass before analysis, so helper-built and bounded-loop timelines resolve at true positions with motionPath arcs recognized; each tween carries provenance. Expansion order is stamped so cloned tweens (sharing source loc) sort correctly. Read path only — parseGsapScriptAcornForWrite is untouched, degrades to current behavior on failure. The add-to-basket addCycle case now yields 7 resolved animations. * feat(studio): runtime-authoritative keyframes for dynamic timelines Phase 2 (U4-U6): the live-runtime scanner returns tween-relative keyframes with per-tween timing and converts them to clip-relative when given clip dims, fixing the timeline-vs-clip-relative bug; it extracts motionPath into arcPath (shared buildArcPath) so the Arc Motion panel activates for data-driven arcs; the cache leaves statically-unresolvable tweens to the runtime scan. Exempts the pre-existing large useGsapTweenCache effects from fallow health (file-level, like files.ts) rather than suppression comments. * feat(studio): surface keyframe editability from provenance U9: editabilityForProvenance(provenance) -> direct|unroll|override (core, re-exported from the acorn subpath). A ComputedTweenNotice component shows an unroll affordance for helper/loop tweens (wired in U10) and an overrides note for dynamic ones. Extracts the shared GsapAnimationEditCallbacks interface to remove section/card prop duplication. * feat(core): lint understands computed timelines (acorn parser) U7: the GSAP lint rule now loads parseGsapScriptAcorn (which inlines helpers and bounded loops) instead of the recast parser, so overlapping_gsap_tweens and related findings reflect true resolved positions for computed timelines — and keeps recast out of the lint graph entirely. Literal compositions are unchanged (parity), all 182 lint tests pass. * docs: document the computed-timeline keyframe editing model U8: keyframes.mdx explains that helper/loop/data-built timelines display correctly, and how each is edited — literal (direct), helper/loop (unroll to edit), dynamic (composition overrides). Nothing is permanently locked. * feat: unroll computed timelines into literal tweens (U10) Adds unrollComputedTimeline (core): serializes a parsed timeline's resolved animations back to literal tl.* statements (arc/keyframe-aware) and surgically replaces the top-level helper-call/loop statements that produced them via magic-string, dropping dead helper declarations — a verified visual no-op. Wires an unroll-timeline studio-api mutation and threads onUnroll to the AnimationCard 'Unroll to edit' button. Exempts panel files whose inherited fingerprints shifted from the prop threading. * feat(runtime): declarative keyframe override layer for dynamic tweens (U11) Adds applyKeyframeOverrides: fetches a gsap-overrides.json sidecar and applies explicit per-tween value overrides to the live timeline (keyed by selector + tween ordinal), invalidating so GSAP re-reads them — the deterministic, render-safe mechanism (preview + headless) for persisting edits to dynamic tweens that can't be unrolled. Mirrors the shipped caption-overrides pattern; wired into runtime init alongside applyCaptionOverrides. * refactor: drop the keyframe override layer; rely on unroll + source Removes the gsap-overrides.json sidecar (runtime apply + init wiring + tests): it solved a near-nonexistent case (HyperFrames is deterministic, so genuinely unresolvable dynamic tweens barely exist) and introduced a parallel persistence path outside the composition. The real cases are covered without it — const/variable values resolve statically, helper/loop tweens unroll to literals and then edit in-script (single source of truth). Renames the editability strategy 'override' -> 'source' (edit in the Code tab) and updates the notice + docs accordingly. * fix(studio): drag outside tween range creates new keyframe, picks nearest tween Fixes the GSAP drag intercept to pick the position tween closest to the playhead (not the one with the most keyframes), and when dragging outside all tweens' ranges, creates a brand-new keyframed tween instead of destructively extending/replacing the nearest one. Reads the runtime position at the tween's start time (via iframe seek) so convert-to-keyframes produces correct 0% keyframes that preserve the interpolation from preceding tweens. * fix(studio): drag outside tween range creates new keyframe, picks nearest tween Also reverts all fallow health.ignore additions — pre-existing complexity in touched files is accepted as inherited, not suppressed.
This commit is contained in:
@@ -11,13 +11,13 @@ interface LintParsedGsap {
|
||||
timelineVar: string;
|
||||
}
|
||||
|
||||
// The recast-based GSAP parser lives behind the Node-only
|
||||
// `@hyperframes/core/gsap-parser` subpath. The linter runs server-side only
|
||||
// (CLI + studio-api `/lint` route), so loading it via dynamic import keeps
|
||||
// recast out of any browser/SSR-traced static graph.
|
||||
// Use the acorn read parser: it resolves computed timelines (helpers, bounded
|
||||
// loops) so lint findings like overlapping_gsap_tweens reflect true positions
|
||||
// instead of all-collapsed-at-0. It's also browser-safe, so this keeps recast
|
||||
// out of the lint graph entirely. Dynamic import preserves the lazy load.
|
||||
async function loadParseGsapScript(): Promise<(script: string) => LintParsedGsap> {
|
||||
const mod = await import("../../parsers/gsapParser.js");
|
||||
return mod.parseGsapScript as unknown as (script: string) => LintParsedGsap;
|
||||
const mod = await import("../../parsers/gsapParserAcorn.js");
|
||||
return mod.parseGsapScriptAcorn as unknown as (script: string) => LintParsedGsap;
|
||||
}
|
||||
import type { LintContext } from "../context";
|
||||
import type { HyperframeLintFinding, LintRule } from "../types";
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parse } from "acorn";
|
||||
import { simple } from "acorn-walk";
|
||||
import {
|
||||
cloneNode,
|
||||
inlineComputedTimelines,
|
||||
numericLiteral,
|
||||
readProvenance,
|
||||
substituteParams,
|
||||
tagProvenance,
|
||||
} from "./gsapInline.js";
|
||||
|
||||
// Parse a single expression / statement to its ESTree node.
|
||||
const expr = (code: string): any =>
|
||||
(parse(code, { ecmaVersion: "latest" }).body[0] as any).expression;
|
||||
const stmt = (code: string): any => parse(code, { ecmaVersion: "latest" }).body[0] as any;
|
||||
const bind = (entries: Record<string, string>): Map<string, any> =>
|
||||
new Map(Object.entries(entries).map(([k, v]) => [k, expr(v)]));
|
||||
|
||||
describe("substituteParams", () => {
|
||||
it("substitutes a scalar param inside a binary expression", () => {
|
||||
const out = substituteParams(cloneNode(expr("at + 0.15")), bind({ at: "1.0" }));
|
||||
expect(out.type).toBe("BinaryExpression");
|
||||
expect(out.left).toMatchObject({ type: "Literal", value: 1 });
|
||||
expect(out.right).toMatchObject({ type: "Literal", value: 0.15 });
|
||||
});
|
||||
|
||||
it("substitutes an array param used as a value", () => {
|
||||
const out = substituteParams(cloneNode(expr("({ path })")), bind({ path: "[{x:0},{x:1}]" }));
|
||||
expect(out.properties[0].value.type).toBe("ArrayExpression");
|
||||
expect(out.properties[0].value.elements).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("does not substitute a name shadowed by an inner const", () => {
|
||||
const out = substituteParams(
|
||||
cloneNode(stmt("function f(){ const at = 5; return at; }")),
|
||||
bind({ at: "1.0" }),
|
||||
);
|
||||
const ret = out.body.body[1].argument;
|
||||
expect(ret).toMatchObject({ type: "Identifier", name: "at" });
|
||||
});
|
||||
|
||||
it("does not substitute a name shadowed by a nested function param", () => {
|
||||
const out = substituteParams(cloneNode(expr("(at) => at")), bind({ at: "1.0" }));
|
||||
expect(out.body).toMatchObject({ type: "Identifier", name: "at" });
|
||||
});
|
||||
|
||||
it("does not substitute object keys or non-computed member properties", () => {
|
||||
const obj = substituteParams(cloneNode(expr("({ at: 1 })")), bind({ at: "9" }));
|
||||
expect(obj.properties[0].key).toMatchObject({ type: "Identifier", name: "at" });
|
||||
const mem = substituteParams(cloneNode(expr("obj.at")), bind({ at: "9" }));
|
||||
expect(mem.property).toMatchObject({ type: "Identifier", name: "at" });
|
||||
});
|
||||
|
||||
it("does substitute a computed member property", () => {
|
||||
const out = substituteParams(cloneNode(expr("obj[at]")), bind({ at: "0" }));
|
||||
expect(out.property).toMatchObject({ type: "Literal", value: 0 });
|
||||
});
|
||||
|
||||
it("does not mutate the input clone's source", () => {
|
||||
const original = expr("at + 0.15");
|
||||
substituteParams(cloneNode(original), bind({ at: "1.0" }));
|
||||
expect(original.left).toMatchObject({ type: "Identifier", name: "at" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("provenance + numericLiteral", () => {
|
||||
it("round-trips a provenance tag", () => {
|
||||
const node = expr("tl.to('#x', {}, 1)");
|
||||
tagProvenance(node, { kind: "helper", fn: "addCycle", callSite: 2 });
|
||||
expect(readProvenance(node)).toEqual({ kind: "helper", fn: "addCycle", callSite: 2 });
|
||||
});
|
||||
|
||||
it("builds a resolvable numeric literal", () => {
|
||||
expect(numericLiteral(3.5)).toMatchObject({ type: "Literal", value: 3.5 });
|
||||
});
|
||||
});
|
||||
|
||||
// Resolve only direct literals — enough to drive loop-bound resolution in tests.
|
||||
const litResolve = (n: any): any => (n?.type === "Literal" ? n.value : undefined);
|
||||
|
||||
// The tl.* method of a direct `tl.method(...)` call (test scripts don't chain), or null.
|
||||
function tlMethod(call: any, tl: string): string | null {
|
||||
if (call.callee?.object?.name !== tl) return null;
|
||||
const m = call.callee?.property?.name;
|
||||
return ["set", "to", "from", "fromTo"].includes(m) ? m : null;
|
||||
}
|
||||
|
||||
function run(code: string, tl = "tl"): { ast: any; tweens: Array<{ prov: any; pos: any }> } {
|
||||
const ast: any = parse(code, { ecmaVersion: "latest" });
|
||||
inlineComputedTimelines(ast, tl, litResolve);
|
||||
const tweens: Array<{ prov: any; pos: any }> = [];
|
||||
simple(ast, {
|
||||
CallExpression(n: any) {
|
||||
const m = tlMethod(n, tl);
|
||||
if (m) tweens.push({ prov: readProvenance(n), pos: n.arguments?.[m === "fromTo" ? 3 : 2] });
|
||||
},
|
||||
});
|
||||
return { ast, tweens };
|
||||
}
|
||||
|
||||
const kinds = (t: Array<{ prov: any }>): any[] => t.map((x) => x.prov?.kind);
|
||||
const sites = (t: Array<{ prov: any }>): any[] => t.map((x) => x.prov?.callSite);
|
||||
const iters = (t: Array<{ prov: any }>): any[] => t.map((x) => x.prov?.iteration);
|
||||
|
||||
describe("inlineComputedTimelines — helpers", () => {
|
||||
it("expands a helper called N times, substituting positions per call", () => {
|
||||
const { tweens } = run(`const tl=gsap.timeline();
|
||||
function addCycle(at){ tl.to("#p", {}, at + 0.3); }
|
||||
addCycle(1.0); addCycle(3.6);`);
|
||||
expect(tweens).toHaveLength(2);
|
||||
expect(kinds(tweens)).toEqual(["helper", "helper"]);
|
||||
expect(sites(tweens)).toEqual([1, 2]);
|
||||
expect(tweens[0]!.pos).toMatchObject({ type: "BinaryExpression", left: { value: 1 } });
|
||||
expect(tweens[1]!.pos).toMatchObject({ left: { value: 3.6 } });
|
||||
});
|
||||
|
||||
it("expands every tween in a multi-tween helper body", () => {
|
||||
const { tweens } = run(`const tl=gsap.timeline();
|
||||
function addCycle(at){ tl.to("#a", {}, at); tl.to("#b", {}, at + 1); }
|
||||
addCycle(1); addCycle(5);`);
|
||||
expect(tweens).toHaveLength(4);
|
||||
expect(sites(tweens)).toEqual([1, 1, 2, 2]);
|
||||
});
|
||||
|
||||
it("inlines nested helpers to a fixpoint", () => {
|
||||
const { tweens } = run(`const tl=gsap.timeline();
|
||||
function inner(t){ tl.to("#x", {}, t); }
|
||||
function outer(at){ inner(at); }
|
||||
outer(5);`);
|
||||
expect(tweens).toHaveLength(1);
|
||||
expect(tweens[0]!.prov?.fn).toBe("inner");
|
||||
expect(tweens[0]!.pos).toMatchObject({ type: "Literal", value: 5 });
|
||||
});
|
||||
|
||||
it("caps runaway recursion instead of hanging", () => {
|
||||
const { tweens } = run(`const tl=gsap.timeline();
|
||||
function r(n){ tl.to("#x", {}, n); r(n); }
|
||||
r(0);`);
|
||||
expect(tweens.length).toBeGreaterThan(0);
|
||||
expect(tweens.length).toBeLessThanOrEqual(8);
|
||||
});
|
||||
|
||||
it("leaves a non-timeline helper untouched", () => {
|
||||
const { ast, tweens } = run(`function bez(t){ return t * 2; }
|
||||
const tl=gsap.timeline();
|
||||
tl.to("#x", {}, bez(1));`);
|
||||
expect(
|
||||
ast.body.some((s: any) => s.type === "FunctionDeclaration" && s.id?.name === "bez"),
|
||||
).toBe(true);
|
||||
expect(tweens).toHaveLength(1);
|
||||
expect(tweens[0]!.prov).toBeUndefined(); // literal tween, no provenance tag
|
||||
});
|
||||
});
|
||||
|
||||
describe("inlineComputedTimelines — loops", () => {
|
||||
it("unrolls a for-loop with literal bounds", () => {
|
||||
const { tweens } = run(`const tl=gsap.timeline();
|
||||
for (let i = 0; i < 3; i++) { tl.to("#x", {}, i * 0.5); }`);
|
||||
expect(tweens).toHaveLength(3);
|
||||
expect(iters(tweens)).toEqual([0, 1, 2]);
|
||||
expect(tweens.map((t) => t.pos.left.value)).toEqual([0, 1, 2]);
|
||||
});
|
||||
|
||||
it("unrolls forEach over an inline array", () => {
|
||||
const { tweens } = run(`const tl=gsap.timeline();
|
||||
[{t:1},{t:2}].forEach((d) => { tl.to("#x", {}, d.t); });`);
|
||||
expect(tweens).toHaveLength(2);
|
||||
expect(kinds(tweens)).toEqual(["loop", "loop"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,584 @@
|
||||
/**
|
||||
* Static evaluation for computed GSAP timelines (browser-safe, acorn/ESTree).
|
||||
*
|
||||
* The read parser resolves only literals and top-level consts, so timelines
|
||||
* built by a helper called N times or by a bounded loop collapse to position 0.
|
||||
* This module expands those constructs into a synthetic analysis AST: each
|
||||
* helper invocation and each loop iteration becomes its own concrete set of
|
||||
* `tl.*` calls, with parameters/loop-vars substituted by the call's argument
|
||||
* (or element/index) AST nodes — after which the existing parse pipeline
|
||||
* resolves positions and `motionPath` arcs unchanged.
|
||||
*
|
||||
* Substituted nodes keep their original source offsets, so downstream
|
||||
* source-slicing (raw extras, keyframes) stays correct. The substitution
|
||||
* primitives never mutate their input; `inlineComputedTimelines` rewrites the
|
||||
* Program body of the freshly-parsed AST it is handed (owned by the caller).
|
||||
*/
|
||||
import type { GsapProvenance } from "./gsapSerialize.js";
|
||||
|
||||
// acorn ESTree nodes are structurally untyped; mirror gsapParserAcorn.ts.
|
||||
type Node = any;
|
||||
|
||||
/** Node keys that are metadata, not child AST to traverse/substitute. */
|
||||
const SKIP_KEYS = new Set(["type", "start", "end", "loc", "range", "__hfProvenance", "__hfOrder"]);
|
||||
|
||||
const FUNCTION_TYPES = new Set([
|
||||
"ArrowFunctionExpression",
|
||||
"FunctionExpression",
|
||||
"FunctionDeclaration",
|
||||
]);
|
||||
const GSAP_METHODS = new Set(["set", "to", "from", "fromTo"]);
|
||||
|
||||
// Bounds on synthetic expansion (recursion + iteration runaway guards).
|
||||
const MAX_DEPTH = 8;
|
||||
const MAX_ITERS = 512;
|
||||
|
||||
function isFunctionNode(node: Node): boolean {
|
||||
return !!node && FUNCTION_TYPES.has(node.type);
|
||||
}
|
||||
|
||||
function isNode(x: Node): boolean {
|
||||
return !!x && typeof x === "object" && typeof x.type === "string";
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply `fn` to each child AST node, writing back its return value. Skips
|
||||
* metadata keys and key/member slots that must not be treated as values.
|
||||
* The one place array-vs-single child traversal lives, so walkers stay flat.
|
||||
*/
|
||||
function transformChildren(node: Node, fn: (child: Node) => Node): void {
|
||||
for (const key of Object.keys(node)) {
|
||||
if (SKIP_KEYS.has(key) || isNonValueIdentifierSlot(node, key)) continue;
|
||||
const child = node[key];
|
||||
if (Array.isArray(child)) {
|
||||
for (let i = 0; i < child.length; i++) child[i] = fn(child[i]);
|
||||
} else {
|
||||
node[key] = fn(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Deep structural clone preserving `start`/`end`/`loc` (needed for source slicing). */
|
||||
export function cloneNode<T extends Node>(node: T): T {
|
||||
return structuredClone(node);
|
||||
}
|
||||
|
||||
// ponytail: Identifier + default + rest only. Destructured bindings (`{x}`, `[x]`)
|
||||
// aren't inlined (U2 inlines Identifier-param helpers / loop vars only), so a
|
||||
// destructuring shadow is a double-rare miss that just falls back. Add the
|
||||
// pattern cases here if that ever bites.
|
||||
function collectPatternNames(pattern: Node, out: Set<string>): void {
|
||||
if (pattern?.type === "Identifier") out.add(pattern.name);
|
||||
else if (pattern?.type === "AssignmentPattern") collectPatternNames(pattern.left, out);
|
||||
else if (pattern?.type === "RestElement") collectPatternNames(pattern.argument, out);
|
||||
}
|
||||
|
||||
/** Every identifier name bound anywhere inside the subtree (fn params, declared vars, catch params). */
|
||||
function collectBoundNames(root: Node): Set<string> {
|
||||
const names = new Set<string>();
|
||||
const visit = (node: Node): Node => {
|
||||
if (!isNode(node)) return node;
|
||||
if (isFunctionNode(node)) for (const p of node.params ?? []) collectPatternNames(p, names);
|
||||
else if (node.type === "VariableDeclarator") collectPatternNames(node.id, names);
|
||||
else if (node.type === "CatchClause") collectPatternNames(node.param, names);
|
||||
transformChildren(node, visit);
|
||||
return node;
|
||||
};
|
||||
visit(root);
|
||||
return names;
|
||||
}
|
||||
|
||||
/** A child in key/property position that must not be treated as a value identifier. */
|
||||
function isNonValueIdentifierSlot(node: Node, key: string): boolean {
|
||||
if (node.computed) return false;
|
||||
return (
|
||||
(node.type === "MemberExpression" && key === "property") ||
|
||||
(node.type === "Property" && key === "key")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Substitute bound identifiers in an already-cloned subtree, returning the
|
||||
* (possibly replaced) root. Names shadowed anywhere inside (nested function
|
||||
* params, declared vars) are dropped up front rather than tracked per scope —
|
||||
* worst case we under-substitute and the caller falls back to current behavior.
|
||||
* Never substitutes identifiers in key/member positions. Mutates the passed
|
||||
* clone in place — callers pass `cloneNode(...)`.
|
||||
*/
|
||||
export function substituteParams(node: Node, bindings: ReadonlyMap<string, Node>): Node {
|
||||
const shadowed = collectBoundNames(node);
|
||||
let effective = bindings;
|
||||
if (shadowed.size > 0) {
|
||||
effective = new Map(bindings);
|
||||
for (const name of shadowed) (effective as Map<string, Node>).delete(name);
|
||||
}
|
||||
if (effective.size === 0) return node;
|
||||
return replace(node, effective);
|
||||
}
|
||||
|
||||
function replace(node: Node, bindings: ReadonlyMap<string, Node>): Node {
|
||||
if (!isNode(node)) return node;
|
||||
if (node.type === "Identifier" && bindings.has(node.name)) {
|
||||
return cloneNode(bindings.get(node.name));
|
||||
}
|
||||
transformChildren(node, (child) => replace(child, bindings));
|
||||
return node;
|
||||
}
|
||||
|
||||
/** Tag a node (typically a `tl.*` CallExpression) with its construction provenance. */
|
||||
export function tagProvenance(node: Node, provenance: GsapProvenance): Node {
|
||||
if (node && typeof node === "object") node.__hfProvenance = provenance;
|
||||
return node;
|
||||
}
|
||||
|
||||
/** Read a provenance tag previously set by `tagProvenance`, if any. */
|
||||
export function readProvenance(node: Node): GsapProvenance | undefined {
|
||||
return node?.__hfProvenance;
|
||||
}
|
||||
|
||||
/** Synthesize a numeric `Literal` node (for loop indices, which have no source node). */
|
||||
export function numericLiteral(value: number): Node {
|
||||
return { type: "Literal", value, raw: String(value) };
|
||||
}
|
||||
|
||||
// ── Expansion engine (U2) ─────────────────────────────────────────────────────
|
||||
|
||||
/** Resolve an expression to a literal value (top-level consts in scope, arithmetic). */
|
||||
type LiteralResolver = (node: Node) => number | string | boolean | undefined;
|
||||
|
||||
interface ExpandCtx {
|
||||
helpers: Map<string, Node>;
|
||||
timelineVar: string;
|
||||
resolve: LiteralResolver;
|
||||
depth: number;
|
||||
/** Mutable source-order counter for provenance call-site ordinals. */
|
||||
site: { n: number };
|
||||
/** Mutable counter stamping expansion order onto tweens (clones share source loc). */
|
||||
order: { n: number };
|
||||
}
|
||||
|
||||
function walkNodes(node: Node, fn: (n: Node) => void): void {
|
||||
if (!isNode(node)) return;
|
||||
fn(node);
|
||||
for (const key of Object.keys(node)) {
|
||||
if (SKIP_KEYS.has(key)) continue;
|
||||
const child = node[key];
|
||||
if (Array.isArray(child)) for (const c of child) walkNodes(c, fn);
|
||||
else walkNodes(child, fn);
|
||||
}
|
||||
}
|
||||
|
||||
/** The identifier a (possibly chained) call's member expression is rooted at. */
|
||||
function timelineRootName(call: Node): string | null {
|
||||
let obj = call.callee?.object;
|
||||
while (obj?.type === "CallExpression") obj = obj.callee?.object;
|
||||
return obj?.type === "Identifier" ? obj.name : null;
|
||||
}
|
||||
|
||||
function isTimelineRooted(call: Node, timelineVar: string): boolean {
|
||||
if (timelineRootName(call) !== timelineVar) return false;
|
||||
return (
|
||||
call.callee?.property?.type === "Identifier" && GSAP_METHODS.has(call.callee.property.name)
|
||||
);
|
||||
}
|
||||
|
||||
function containsTimelineCall(node: Node, timelineVar: string): boolean {
|
||||
let found = false;
|
||||
walkNodes(node, (n) => {
|
||||
if (n.type === "CallExpression" && isTimelineRooted(n, timelineVar)) found = true;
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
function rangeOf(node: Node): [number, number] | undefined {
|
||||
return typeof node.start === "number" && typeof node.end === "number"
|
||||
? [node.start, node.end]
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/** Plain identifier params + block body (shape we can inline). Timeline content checked separately. */
|
||||
function isShapeEligible(fn: Node): boolean {
|
||||
return (
|
||||
isFunctionNode(fn) &&
|
||||
fn.body?.type === "BlockStatement" &&
|
||||
!(fn.params ?? []).some((p: Node) => p.type !== "Identifier")
|
||||
);
|
||||
}
|
||||
|
||||
/** True if the subtree calls any function named in `names`. */
|
||||
function callsAny(node: Node, names: Set<string>): boolean {
|
||||
let hit = false;
|
||||
walkNodes(node, (n) => {
|
||||
if (
|
||||
n.type === "CallExpression" &&
|
||||
n.callee?.type === "Identifier" &&
|
||||
names.has(n.callee.name)
|
||||
) {
|
||||
hit = true;
|
||||
}
|
||||
});
|
||||
return hit;
|
||||
}
|
||||
|
||||
/** `[name, fnNode]` if a single-declarator `const f = fn` is an inlinable-shaped helper. */
|
||||
function varDeclHelper(stmt: Node): [string, Node] | null {
|
||||
if (stmt.declarations?.length !== 1) return null;
|
||||
const d = stmt.declarations[0];
|
||||
return d.id?.type === "Identifier" && isShapeEligible(d.init) ? [d.id.name, d.init] : null;
|
||||
}
|
||||
|
||||
/** `[name, fnNode]` if `stmt` declares an inlinable-shaped helper, else null. */
|
||||
function helperFromStatement(stmt: Node): [string, Node] | null {
|
||||
if (stmt.type === "FunctionDeclaration") {
|
||||
return stmt.id && isShapeEligible(stmt) ? [stmt.id.name, stmt] : null;
|
||||
}
|
||||
if (stmt.type === "VariableDeclaration") return varDeclHelper(stmt);
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Top-level functions whose shape we can inline (Identifier params + block body). */
|
||||
function gatherHelperCandidates(program: Node): Map<string, Node> {
|
||||
const candidates = new Map<string, Node>();
|
||||
for (const stmt of program.body ?? []) {
|
||||
const helper = helperFromStatement(stmt);
|
||||
if (helper) candidates.set(helper[0], helper[1]);
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
/** Names that build the timeline directly or by calling another builder (transitive closure). */
|
||||
function timelineBuildingNames(candidates: Map<string, Node>, timelineVar: string): Set<string> {
|
||||
const building = new Set<string>();
|
||||
for (const [name, fn] of candidates) {
|
||||
if (containsTimelineCall(fn.body, timelineVar)) building.add(name);
|
||||
}
|
||||
for (let changed = true; changed; ) {
|
||||
changed = false;
|
||||
for (const [name, fn] of candidates) {
|
||||
if (!building.has(name) && callsAny(fn.body, building)) {
|
||||
building.add(name);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return building;
|
||||
}
|
||||
|
||||
function bump(counts: Map<string, number>, key: string): void {
|
||||
counts.set(key, (counts.get(key) ?? 0) + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep only candidates safe to drop: every reference to the name is its
|
||||
* declaration or a statement-level call. (1 decl id + 1 callee id per
|
||||
* statement-level call ⇒ total occurrences with no stray uses.)
|
||||
*/
|
||||
function safelyDroppable(program: Node, candidates: Map<string, Node>): Map<string, Node> {
|
||||
const names = new Set(candidates.keys());
|
||||
const totalIds = new Map<string, number>();
|
||||
const stmtCalls = new Map<string, number>();
|
||||
walkNodes(program, (n) => {
|
||||
if (n.type === "Identifier" && names.has(n.name)) bump(totalIds, n.name);
|
||||
const e = n.type === "ExpressionStatement" ? n.expression : undefined;
|
||||
if (
|
||||
e?.type === "CallExpression" &&
|
||||
e.callee?.type === "Identifier" &&
|
||||
names.has(e.callee.name)
|
||||
) {
|
||||
bump(stmtCalls, e.callee.name);
|
||||
}
|
||||
});
|
||||
const safe = new Map<string, Node>();
|
||||
for (const [name, fn] of candidates) {
|
||||
if ((totalIds.get(name) ?? 0) === 1 + (stmtCalls.get(name) ?? 0)) safe.set(name, fn);
|
||||
}
|
||||
return safe;
|
||||
}
|
||||
|
||||
/** Top-level timeline-building helpers that are safe to inline-and-drop. */
|
||||
function collectInlinableHelpers(program: Node, timelineVar: string): Map<string, Node> {
|
||||
const candidates = gatherHelperCandidates(program);
|
||||
if (candidates.size === 0) return candidates;
|
||||
const building = timelineBuildingNames(candidates, timelineVar);
|
||||
for (const name of [...candidates.keys()]) if (!building.has(name)) candidates.delete(name);
|
||||
if (candidates.size === 0) return candidates;
|
||||
return safelyDroppable(program, candidates);
|
||||
}
|
||||
|
||||
function isHelperDecl(stmt: Node, helpers: Map<string, Node>): boolean {
|
||||
if (stmt.type === "FunctionDeclaration") return !!stmt.id && helpers.get(stmt.id.name) === stmt;
|
||||
if (stmt.type === "VariableDeclaration" && stmt.declarations?.length === 1) {
|
||||
const d = stmt.declarations[0];
|
||||
return d.id?.type === "Identifier" && helpers.get(d.id.name) === d.init;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function bodyStatements(node: Node): Node[] {
|
||||
if (node?.type === "BlockStatement") return node.body ?? [];
|
||||
return node ? [{ type: "ExpressionStatement", expression: node }] : [];
|
||||
}
|
||||
|
||||
/** Tag this body's direct timeline tweens with provenance + a monotonic expansion-order stamp. */
|
||||
function tagTimelineCalls(stmts: Node[], prov: GsapProvenance, ctx: ExpandCtx): void {
|
||||
for (const stmt of stmts) {
|
||||
walkNodes(stmt, (n) => {
|
||||
if (n.type === "CallExpression" && isTimelineRooted(n, ctx.timelineVar)) {
|
||||
tagProvenance(n, { ...prov });
|
||||
n.__hfOrder = ctx.order.n++;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Clone a body as one scope, substitute the bindings, tag provenance, recurse. */
|
||||
function expandBody(
|
||||
bodyStmts: Node[],
|
||||
bindings: Map<string, Node>,
|
||||
prov: GsapProvenance,
|
||||
ctx: ExpandCtx,
|
||||
): Node[] {
|
||||
const block = substituteParams(cloneNode({ type: "BlockStatement", body: bodyStmts }), bindings);
|
||||
tagTimelineCalls(block.body, prov, ctx);
|
||||
return expandStatements(block.body, { ...ctx, depth: ctx.depth + 1 });
|
||||
}
|
||||
|
||||
function inlineHelper(call: Node, ctx: ExpandCtx): Node[] {
|
||||
const fn = ctx.helpers.get(call.callee.name);
|
||||
const bindings = new Map<string, Node>();
|
||||
(fn.params ?? []).forEach((p: Node, i: number) => {
|
||||
const arg = call.arguments?.[i];
|
||||
if (arg) bindings.set(p.name, arg);
|
||||
});
|
||||
const prov: GsapProvenance = {
|
||||
kind: "helper",
|
||||
fn: call.callee.name,
|
||||
callSite: ++ctx.site.n,
|
||||
sourceRange: rangeOf(call),
|
||||
};
|
||||
return expandBody(fn.body.body, bindings, prov, ctx);
|
||||
}
|
||||
|
||||
function assignStep(update: Node, resolve: LiteralResolver): number | undefined {
|
||||
if (update.operator === "+=") return asNum(resolve(update.right));
|
||||
if (update.operator === "-=") {
|
||||
const s = asNum(resolve(update.right));
|
||||
return s === undefined ? undefined : -s;
|
||||
}
|
||||
// `i = i + S` — the step is the right operand of the addition.
|
||||
if (update.operator === "=" && update.right?.type === "BinaryExpression") {
|
||||
return asNum(resolve(update.right.right));
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** The loop variable a `for` update clause mutates (`i++` or `i += S`), or null. */
|
||||
function updatedVarName(update: Node): string | null {
|
||||
if (update?.type === "UpdateExpression") return update.argument?.name ?? null;
|
||||
if (update?.type === "AssignmentExpression") return update.left?.name ?? null;
|
||||
return null;
|
||||
}
|
||||
|
||||
function loopStep(update: Node, varName: string, resolve: LiteralResolver): number | undefined {
|
||||
if (updatedVarName(update) !== varName) return undefined;
|
||||
if (update.type === "UpdateExpression") return update.operator === "++" ? 1 : -1;
|
||||
return assignStep(update, resolve);
|
||||
}
|
||||
|
||||
function asNum(v: unknown): number | undefined {
|
||||
return typeof v === "number" && Number.isFinite(v) ? v : undefined;
|
||||
}
|
||||
|
||||
function loopSatisfied(op: string, x: number, end: number): boolean {
|
||||
if (op === "<") return x < end;
|
||||
if (op === "<=") return x <= end;
|
||||
if (op === ">") return x > end;
|
||||
if (op === ">=") return x >= end;
|
||||
return false;
|
||||
}
|
||||
|
||||
interface ForHeader {
|
||||
v: string;
|
||||
start: number;
|
||||
end: number;
|
||||
op: string;
|
||||
step: number;
|
||||
}
|
||||
|
||||
/** The single `let v = <init>` of a for-loop init clause, or null. */
|
||||
function forInitVar(init: Node): { name: string; initExpr: Node } | null {
|
||||
if (init?.type !== "VariableDeclaration" || init.declarations?.length !== 1) return null;
|
||||
const d = init.declarations[0];
|
||||
return d.id?.type === "Identifier" ? { name: d.id.name, initExpr: d.init } : null;
|
||||
}
|
||||
|
||||
/** Parse `for (let v = A; v <op> B; v += S)` into resolved bounds, or null if not statically bounded. */
|
||||
function parseForHeader(stmt: Node, resolve: LiteralResolver): ForHeader | null {
|
||||
const iv = forInitVar(stmt.init);
|
||||
const test = stmt.test;
|
||||
if (!iv || test?.type !== "BinaryExpression" || test.left?.name !== iv.name) return null;
|
||||
const start = asNum(resolve(iv.initExpr));
|
||||
const end = asNum(resolve(test.right));
|
||||
const step = loopStep(stmt.update, iv.name, resolve);
|
||||
if (start === undefined || end === undefined || !step) return null;
|
||||
return { v: iv.name, start, end, op: test.operator, step };
|
||||
}
|
||||
|
||||
function unrollFor(stmt: Node, ctx: ExpandCtx): Node[] | null {
|
||||
const h = parseForHeader(stmt, ctx.resolve);
|
||||
if (!h) return null;
|
||||
const body = bodyStatements(stmt.body);
|
||||
const out: Node[] = [];
|
||||
const site = ++ctx.site.n;
|
||||
let iteration = 0;
|
||||
for (let x = h.start; loopSatisfied(h.op, x, h.end); x += h.step) {
|
||||
if (iteration >= MAX_ITERS) return null;
|
||||
const prov: GsapProvenance = {
|
||||
kind: "loop",
|
||||
callSite: site,
|
||||
iteration,
|
||||
sourceRange: rangeOf(stmt),
|
||||
};
|
||||
out.push(...expandBody(body, new Map([[h.v, numericLiteral(x)]]), prov, ctx));
|
||||
iteration++;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function forOfVarName(left: Node): string | null {
|
||||
if (left?.type === "VariableDeclaration") {
|
||||
const id = left.declarations?.[0]?.id;
|
||||
return id?.type === "Identifier" ? id.name : null;
|
||||
}
|
||||
return left?.type === "Identifier" ? left.name : null;
|
||||
}
|
||||
|
||||
/** Expand `for (const el of [literal array]) {...}` and `[literal array].forEach((el, i) => {...})`. */
|
||||
function unrollOverArray(
|
||||
elements: Node[],
|
||||
body: Node[],
|
||||
elName: string | null,
|
||||
idxName: string | null,
|
||||
range: [number, number] | undefined,
|
||||
ctx: ExpandCtx,
|
||||
): Node[] {
|
||||
const out: Node[] = [];
|
||||
const site = ++ctx.site.n;
|
||||
elements.forEach((el, i) => {
|
||||
if (!el) return;
|
||||
const bindings = new Map<string, Node>();
|
||||
if (elName) bindings.set(elName, el);
|
||||
if (idxName) bindings.set(idxName, numericLiteral(i));
|
||||
const prov: GsapProvenance = { kind: "loop", callSite: site, iteration: i, sourceRange: range };
|
||||
out.push(...expandBody(body, bindings, prov, ctx));
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function unrollForOf(stmt: Node, ctx: ExpandCtx): Node[] | null {
|
||||
if (stmt.right?.type !== "ArrayExpression") return null;
|
||||
const elName = forOfVarName(stmt.left);
|
||||
if (!elName) return null;
|
||||
return unrollOverArray(
|
||||
stmt.right.elements ?? [],
|
||||
bodyStatements(stmt.body),
|
||||
elName,
|
||||
null,
|
||||
rangeOf(stmt),
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
|
||||
/** The (element, index) param names of a callback, or null if either is non-Identifier. */
|
||||
function callbackParamNames(cb: Node): { el: string | null; idx: string | null } | null {
|
||||
const names: Array<string | null> = [];
|
||||
for (const p of [cb.params?.[0], cb.params?.[1]]) {
|
||||
if (!p) names.push(null);
|
||||
else if (p.type !== "Identifier") return null;
|
||||
else names.push(p.name);
|
||||
}
|
||||
return { el: names[0]!, idx: names[1]! };
|
||||
}
|
||||
|
||||
/** True for `[arrayLiteral].forEach` member callees. */
|
||||
function isForEachCall(callee: Node): boolean {
|
||||
return (
|
||||
callee?.type === "MemberExpression" &&
|
||||
callee.property?.name === "forEach" &&
|
||||
callee.object?.type === "ArrayExpression"
|
||||
);
|
||||
}
|
||||
|
||||
/** The element array + callback of `[...].forEach(cb)`, or null. */
|
||||
function forEachTarget(call: Node): { elements: Node[]; cb: Node } | null {
|
||||
if (!isForEachCall(call.callee)) return null;
|
||||
const cb = call.arguments?.[0];
|
||||
return isFunctionNode(cb) ? { elements: call.callee.object.elements ?? [], cb } : null;
|
||||
}
|
||||
|
||||
function unrollForEach(call: Node, ctx: ExpandCtx): Node[] | null {
|
||||
const target = forEachTarget(call);
|
||||
if (!target) return null;
|
||||
const params = callbackParamNames(target.cb);
|
||||
if (!params) return null;
|
||||
return unrollOverArray(
|
||||
target.elements,
|
||||
bodyStatements(target.cb.body),
|
||||
params.el,
|
||||
params.idx,
|
||||
rangeOf(call),
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
|
||||
function expandCall(call: Node, ctx: ExpandCtx): Node[] | null {
|
||||
if (call.callee?.type === "Identifier" && ctx.helpers.has(call.callee.name)) {
|
||||
return inlineHelper(call, ctx);
|
||||
}
|
||||
return unrollForEach(call, ctx);
|
||||
}
|
||||
|
||||
function expandStatement(stmt: Node, ctx: ExpandCtx): Node[] | null {
|
||||
if (ctx.depth >= MAX_DEPTH) return null;
|
||||
if (stmt.type === "ForStatement") return unrollFor(stmt, ctx);
|
||||
if (stmt.type === "ForOfStatement") return unrollForOf(stmt, ctx);
|
||||
if (stmt.type === "ExpressionStatement" && stmt.expression?.type === "CallExpression") {
|
||||
return expandCall(stmt.expression, ctx);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function expandStatements(stmts: Node[], ctx: ExpandCtx): Node[] {
|
||||
const out: Node[] = [];
|
||||
for (const stmt of stmts) {
|
||||
const expanded = expandStatement(stmt, ctx);
|
||||
if (expanded) out.push(...expanded);
|
||||
else out.push(stmt);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite the Program body so helper invocations and bounded loops that build
|
||||
* the timeline are expanded into concrete per-call / per-iteration `tl.*`
|
||||
* statements, each tagged with provenance. Mutates `ast` in place (caller owns
|
||||
* the freshly-parsed tree). Constructs it can't statically resolve are left
|
||||
* untouched, so the parser falls back to current behavior for them.
|
||||
*/
|
||||
export function inlineComputedTimelines(
|
||||
ast: Node,
|
||||
timelineVar: string,
|
||||
resolve: LiteralResolver,
|
||||
): void {
|
||||
const helpers = collectInlinableHelpers(ast, timelineVar);
|
||||
const ctx: ExpandCtx = {
|
||||
helpers,
|
||||
timelineVar,
|
||||
resolve,
|
||||
depth: 0,
|
||||
site: { n: 0 },
|
||||
order: { n: 0 },
|
||||
};
|
||||
const body = (ast.body ?? []).filter((stmt: Node) => !isHelperDecl(stmt, helpers));
|
||||
ast.body = expandStatements(body, ctx);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* U3: end-to-end resolution of computed timelines (helpers, loops) through the
|
||||
* read parser — true positions, motionPath arcs, and provenance — plus
|
||||
* regression coverage that literal-position compositions are unchanged.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseGsapScriptAcorn, editabilityForProvenance } from "./gsapParserAcorn.js";
|
||||
|
||||
describe("editabilityForProvenance", () => {
|
||||
it("maps provenance kinds to an editing strategy", () => {
|
||||
expect(editabilityForProvenance(undefined)).toBe("direct");
|
||||
expect(editabilityForProvenance({ kind: "literal" })).toBe("direct");
|
||||
expect(editabilityForProvenance({ kind: "helper", fn: "addCycle", callSite: 1 })).toBe(
|
||||
"unroll",
|
||||
);
|
||||
expect(editabilityForProvenance({ kind: "loop", callSite: 1, iteration: 0 })).toBe("unroll");
|
||||
expect(editabilityForProvenance({ kind: "runtime-dynamic" })).toBe("source");
|
||||
});
|
||||
});
|
||||
|
||||
const start = (a: { resolvedStart?: number }): number | undefined => a.resolvedStart;
|
||||
|
||||
describe("parseGsapScriptAcorn — computed timelines", () => {
|
||||
it("resolves an add-to-basket helper called twice (the reported case)", () => {
|
||||
const script = `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
const DX = 852, DY = -322, FLY_SCALE = 56 / 160;
|
||||
tl.from("#product", { opacity: 0, scale: 0.8, duration: 0.5 }, 0.1);
|
||||
function addCycle(at, path, curviness, spin) {
|
||||
tl.to("#product", { y: -15, scale: 1.05, duration: 0.15 }, at + 0.15);
|
||||
tl.to("#product", { motionPath: { path, curviness }, scale: FLY_SCALE, rotation: spin, duration: 0.55 }, at + 0.3);
|
||||
tl.to("#product", { opacity: 0, duration: 0.08 }, at + 0.78);
|
||||
}
|
||||
addCycle(1.0, [{x:0,y:-15},{x:180,y:-300},{x:520,y:-360},{x:DX,y:DY}], 2, 18);
|
||||
addCycle(3.6, [{x:0,y:-15},{x:-120,y:-220},{x:350,y:-380},{x:DX,y:DY}], 2.5, -22);
|
||||
`;
|
||||
const { animations } = parseGsapScriptAcorn(script);
|
||||
|
||||
// 1 entrance + 3 body tweens × 2 cycles = 7 (was 4 before inlining).
|
||||
expect(animations).toHaveLength(7);
|
||||
|
||||
// Entrance keeps its literal position and has no provenance.
|
||||
expect(start(animations[0]!)).toBeCloseTo(0.1);
|
||||
expect(animations[0]!.provenance).toBeUndefined();
|
||||
|
||||
// Cycle tweens land at their true absolute times, in order.
|
||||
expect(animations.slice(1).map(start)).toEqual([1.15, 1.3, 1.78, 3.75, 3.9, 4.38]);
|
||||
|
||||
// Both flight tweens are recognized as arcs and tagged with helper provenance.
|
||||
const arcs = animations.filter((a) => a.arcPath?.enabled);
|
||||
expect(arcs).toHaveLength(2);
|
||||
expect(arcs.map((a) => a.provenance?.fn)).toEqual(["addCycle", "addCycle"]);
|
||||
expect(arcs.map((a) => a.provenance?.callSite)).toEqual([1, 2]);
|
||||
// 4 waypoints ⇒ Arc Motion's ">= 2 position keyframes" gate passes.
|
||||
expect(arcs[0]!.keyframes?.keyframes).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("resolves a bounded for-loop", () => {
|
||||
const { animations } = parseGsapScriptAcorn(`
|
||||
const tl = gsap.timeline();
|
||||
for (let i = 0; i < 3; i++) { tl.to("#x", { x: 100, duration: 0.5 }, i * 0.5); }
|
||||
`);
|
||||
expect(animations).toHaveLength(3);
|
||||
expect(animations.map(start)).toEqual([0, 0.5, 1]);
|
||||
expect(animations.map((a) => a.provenance?.kind)).toEqual(["loop", "loop", "loop"]);
|
||||
});
|
||||
|
||||
it("leaves a literal-position composition unchanged (regression)", () => {
|
||||
const { animations } = parseGsapScriptAcorn(`
|
||||
const tl = gsap.timeline();
|
||||
tl.from("#a", { opacity: 0, duration: 0.5 }, 0.1);
|
||||
tl.to("#b", { x: 50, duration: 0.4 }, 1.0);
|
||||
`);
|
||||
expect(animations).toHaveLength(2);
|
||||
expect(animations.map(start)).toEqual([0.1, 1.0]);
|
||||
expect(animations.every((a) => a.provenance === undefined)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -12,7 +12,6 @@ import * as acorn from "acorn";
|
||||
import * as acornWalk from "acorn-walk";
|
||||
import type {
|
||||
ArcPathConfig,
|
||||
ArcPathSegment,
|
||||
GsapAnimation,
|
||||
GsapKeyframesData,
|
||||
GsapMethod,
|
||||
@@ -20,6 +19,20 @@ import type {
|
||||
ParsedGsap,
|
||||
} from "./gsapSerialize.js";
|
||||
import { classifyTweenPropertyGroup } from "./gsapConstants.js";
|
||||
import { buildArcPath } from "./gsapSerialize.js";
|
||||
import { inlineComputedTimelines, readProvenance } from "./gsapInline.js";
|
||||
|
||||
// Browser-safe re-exports so studio code can build arc config without importing
|
||||
// the recast parser (this acorn module is the browser-safe gsap subpath).
|
||||
export { buildArcPath, editabilityForProvenance } from "./gsapSerialize.js";
|
||||
export type {
|
||||
ArcPathConfig,
|
||||
ArcPathSegment,
|
||||
MotionPathShape,
|
||||
GsapProvenance,
|
||||
GsapProvenanceKind,
|
||||
KeyframeEditability,
|
||||
} from "./gsapSerialize.js";
|
||||
|
||||
const GSAP_METHODS = new Set<string>(["set", "to", "from", "fromTo"]);
|
||||
const QUERY_METHODS = new Set(["querySelector", "querySelectorAll"]);
|
||||
@@ -790,34 +803,7 @@ function parseMotionPathNode(
|
||||
if (x !== undefined && y !== undefined) coords.push({ x, y });
|
||||
}
|
||||
|
||||
if (coords.length < 2) return undefined;
|
||||
|
||||
let waypoints: Array<{ x: number; y: number }>;
|
||||
const segments: ArcPathSegment[] = [];
|
||||
|
||||
if (isCubic && coords.length >= 4) {
|
||||
waypoints = [];
|
||||
const first = coords[0];
|
||||
if (first) waypoints.push(first);
|
||||
for (let i = 1; i + 2 < coords.length; i += 3) {
|
||||
const cp1 = coords[i];
|
||||
const cp2 = coords[i + 1];
|
||||
const anchor = coords[i + 2];
|
||||
if (!cp1 || !cp2 || !anchor) continue;
|
||||
waypoints.push(anchor);
|
||||
segments.push({ curviness, cp1, cp2 });
|
||||
}
|
||||
} else {
|
||||
waypoints = coords;
|
||||
for (let i = 0; i < waypoints.length - 1; i++) {
|
||||
segments.push({ curviness });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
arcPath: { enabled: true, autoRotate, segments },
|
||||
waypoints,
|
||||
};
|
||||
return buildArcPath(coords, curviness, autoRotate, isCubic);
|
||||
}
|
||||
|
||||
// ── Animation assembly ────────────────────────────────────────────────────────
|
||||
@@ -942,6 +928,8 @@ function tweenCallToAnimation(
|
||||
if (motionPathResult) anim.arcPath = motionPathResult.arcPath;
|
||||
if (hasUnresolvedKeyframes) anim.hasUnresolvedKeyframes = true;
|
||||
if (call.selector === "__unresolved__") anim.hasUnresolvedSelector = true;
|
||||
const provenance = readProvenance(call.node);
|
||||
if (provenance) anim.provenance = provenance;
|
||||
return anim;
|
||||
}
|
||||
|
||||
@@ -1016,13 +1004,26 @@ function resolveTimelinePositions(anims: Omit<GsapAnimation, "id">[]): void {
|
||||
}
|
||||
}
|
||||
|
||||
function compareByLoc(a: TweenCallInfo, b: TweenCallInfo): number {
|
||||
const aLoc = a.node.callee?.property?.loc?.start;
|
||||
const bLoc = b.node.callee?.property?.loc?.start;
|
||||
if (!aLoc || !bLoc) return 0;
|
||||
return aLoc.line - bLoc.line || aLoc.column - bLoc.column;
|
||||
}
|
||||
|
||||
// Inlined tweens carry a monotonic __hfOrder (clones share source loc, so loc
|
||||
// can't order them); they sort by that, after all literal (loc-ordered) tweens.
|
||||
function compareCallOrder(a: TweenCallInfo, b: TweenCallInfo): number {
|
||||
const ao = a.node.__hfOrder;
|
||||
const bo = b.node.__hfOrder;
|
||||
if (ao === undefined && bo === undefined) return compareByLoc(a, b);
|
||||
if (ao === undefined) return -1;
|
||||
if (bo === undefined) return 1;
|
||||
return ao - bo;
|
||||
}
|
||||
|
||||
function sortBySourcePosition(calls: TweenCallInfo[]): void {
|
||||
calls.sort((a, b) => {
|
||||
const aLoc = a.node.callee?.property?.loc?.start;
|
||||
const bLoc = b.node.callee?.property?.loc?.start;
|
||||
if (!aLoc || !bLoc) return 0;
|
||||
return aLoc.line - bLoc.line || aLoc.column - bLoc.column;
|
||||
});
|
||||
calls.sort(compareCallOrder);
|
||||
}
|
||||
|
||||
// ── Stable ID generation ──────────────────────────────────────────────────────
|
||||
@@ -1098,9 +1099,17 @@ export function parseGsapScriptAcorn(script: string): ParsedGsap {
|
||||
locations: true,
|
||||
});
|
||||
const scope = collectScopeBindings(ast);
|
||||
const targetBindings = collectTargetBindings(ast, scope);
|
||||
const detection = findTimelineVar(ast, scope);
|
||||
const timelineVar = detection.timelineVar ?? "tl";
|
||||
// Expand helper-built / bounded-loop timelines before analysis so their
|
||||
// tweens resolve at true positions (read path only — the write path keeps
|
||||
// original source nodes). Degrades to the un-inlined AST on any failure.
|
||||
try {
|
||||
inlineComputedTimelines(ast, timelineVar, (node) => resolveNode(node, scope));
|
||||
} catch {
|
||||
/* fall back to current behavior */
|
||||
}
|
||||
const targetBindings = collectTargetBindings(ast, scope);
|
||||
const calls = findAllTweenCalls(ast, timelineVar, scope, targetBindings);
|
||||
sortBySourcePosition(calls);
|
||||
const rawAnims = calls.map((call) => tweenCallToAnimation(call, scope, script));
|
||||
|
||||
@@ -11,6 +11,42 @@ import type { PropertyGroupName } from "./gsapConstants";
|
||||
|
||||
export type GsapMethod = "set" | "to" | "from" | "fromTo";
|
||||
|
||||
/** How a tween was constructed in source — drives display classification and editability. */
|
||||
export type GsapProvenanceKind = "literal" | "helper" | "loop" | "runtime-dynamic";
|
||||
|
||||
/**
|
||||
* Origin of a parsed tween. `literal` tweens map 1:1 to a source call and edit
|
||||
* directly; `helper`/`loop` tweens are expanded from a reused construct (unroll
|
||||
* to edit); `runtime-dynamic` tweens come from live introspection (override to
|
||||
* edit). Absent provenance is treated as `literal`.
|
||||
*/
|
||||
export interface GsapProvenance {
|
||||
kind: GsapProvenanceKind;
|
||||
/** Helper function name (kind === "helper"). */
|
||||
fn?: string;
|
||||
/** 1-based ordinal of the originating call site / loop construct in source order. */
|
||||
callSite?: number;
|
||||
/** 0-based iteration index (kind === "loop"). */
|
||||
iteration?: number;
|
||||
/** Source offset [start, end] of the originating call/loop, when known. */
|
||||
sourceRange?: [number, number];
|
||||
}
|
||||
|
||||
/** How a tween's keyframes can be edited, derived from its provenance. */
|
||||
export type KeyframeEditability = "direct" | "unroll" | "source";
|
||||
|
||||
/**
|
||||
* Map provenance to an editing strategy:
|
||||
* - `direct` — literal tween, maps 1:1 to source; edit in place.
|
||||
* - `unroll` — helper/loop expansion; unroll to literal tweens, then edit.
|
||||
* - `source` — runtime-dynamic value; not statically editable, edit the code.
|
||||
*/
|
||||
export function editabilityForProvenance(provenance?: GsapProvenance): KeyframeEditability {
|
||||
if (!provenance || provenance.kind === "literal") return "direct";
|
||||
if (provenance.kind === "runtime-dynamic") return "source";
|
||||
return "unroll";
|
||||
}
|
||||
|
||||
export interface GsapAnimation {
|
||||
id: string;
|
||||
targetSelector: string;
|
||||
@@ -37,6 +73,8 @@ export interface GsapAnimation {
|
||||
/** Which property group this tween belongs to (position, scale, size, rotation, visual, other).
|
||||
* Undefined for legacy mixed tweens that bundle multiple groups. */
|
||||
propertyGroup?: PropertyGroupName;
|
||||
/** How this tween was constructed in source. Absent ⇒ literal. */
|
||||
provenance?: GsapProvenance;
|
||||
}
|
||||
|
||||
export interface GsapPercentageKeyframe {
|
||||
@@ -66,6 +104,39 @@ export interface ArcPathConfig {
|
||||
segments: ArcPathSegment[];
|
||||
}
|
||||
|
||||
export interface MotionPathShape {
|
||||
arcPath: ArcPathConfig;
|
||||
waypoints: Array<{ x: number; y: number }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build arcPath segments + waypoints from resolved path coordinates. Shared by
|
||||
* the AST parser (coords from literal nodes) and the runtime scanner (coords
|
||||
* from a live `vars.motionPath`), so both produce identical arc config.
|
||||
*/
|
||||
export function buildArcPath(
|
||||
coords: Array<{ x: number; y: number }>,
|
||||
curviness: number,
|
||||
autoRotate: boolean | number,
|
||||
isCubic: boolean,
|
||||
): MotionPathShape | undefined {
|
||||
if (coords.length < 2) return undefined;
|
||||
const segments: ArcPathSegment[] = [];
|
||||
let waypoints: Array<{ x: number; y: number }>;
|
||||
if (isCubic && coords.length >= 4) {
|
||||
// coords are [anchor, cp1, cp2, anchor, cp1, cp2, anchor, ...].
|
||||
waypoints = [coords[0]!];
|
||||
for (let i = 1; i + 2 < coords.length; i += 3) {
|
||||
waypoints.push(coords[i + 2]!);
|
||||
segments.push({ curviness, cp1: coords[i]!, cp2: coords[i + 1]! });
|
||||
}
|
||||
} else {
|
||||
waypoints = coords;
|
||||
for (let i = 0; i < waypoints.length - 1; i++) segments.push({ curviness });
|
||||
}
|
||||
return { arcPath: { enabled: true, autoRotate, segments }, waypoints };
|
||||
}
|
||||
|
||||
export interface ParsedGsap {
|
||||
animations: GsapAnimation[];
|
||||
timelineVar: string;
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { unrollComputedTimeline } from "./gsapUnroll.js";
|
||||
import { parseGsapScriptAcorn } from "./gsapParserAcorn.js";
|
||||
|
||||
const ARC_SCRIPT = `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
const DX = 852, DY = -322, FLY_SCALE = 56 / 160;
|
||||
tl.from("#product", { opacity: 0, scale: 0.8, duration: 0.5 }, 0.1);
|
||||
function addCycle(at, path, curviness, spin) {
|
||||
tl.to("#product", { y: -15, scale: 1.05, duration: 0.15 }, at + 0.15);
|
||||
tl.to("#product", { motionPath: { path, curviness }, scale: FLY_SCALE, rotation: spin, duration: 0.55 }, at + 0.3);
|
||||
tl.to("#basket", { keyframes: { "0%": { y: 0 }, "50%": { y: -12 }, "100%": { y: 0 }, easeEach: "power2.out" }, duration: 0.5 }, at + 0.85);
|
||||
}
|
||||
addCycle(1.0, [{x:0,y:-15},{x:180,y:-300},{x:520,y:-360},{x:DX,y:DY}], 2, 18);
|
||||
addCycle(3.6, [{x:0,y:-15},{x:-120,y:-220},{x:350,y:-380},{x:DX,y:DY}], 2.5, -22);
|
||||
`;
|
||||
|
||||
const sig = (anims: ReturnType<typeof parseGsapScriptAcorn>["animations"]) =>
|
||||
anims
|
||||
.map(
|
||||
(a) =>
|
||||
`${a.targetSelector}|${a.method}|${a.resolvedStart}|arc:${a.arcPath?.segments.length ?? 0}|kf:${a.keyframes?.keyframes.length ?? 0}`,
|
||||
)
|
||||
.join("\n");
|
||||
|
||||
describe("unrollComputedTimeline", () => {
|
||||
it("unrolls helper calls into literal tweens (visual no-op)", () => {
|
||||
const before = parseGsapScriptAcorn(ARC_SCRIPT);
|
||||
const unrolled = unrollComputedTimeline(ARC_SCRIPT);
|
||||
const after = parseGsapScriptAcorn(unrolled);
|
||||
|
||||
// Same animations, same times, same arcs/keyframes — the render is unchanged.
|
||||
expect(after.animations).toHaveLength(before.animations.length);
|
||||
expect(sig(after.animations)).toBe(sig(before.animations));
|
||||
});
|
||||
|
||||
it("produces only literal tweens (no helper, no provenance)", () => {
|
||||
const unrolled = unrollComputedTimeline(ARC_SCRIPT);
|
||||
expect(unrolled).not.toContain("addCycle");
|
||||
expect(unrolled).not.toContain("function ");
|
||||
const after = parseGsapScriptAcorn(unrolled);
|
||||
expect(after.animations.every((a) => a.provenance === undefined)).toBe(true);
|
||||
// Arc tweens survive as real motionPath arcs.
|
||||
expect(after.animations.filter((a) => a.arcPath?.enabled)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("unrolls a bounded for-loop", () => {
|
||||
const script = `const tl = gsap.timeline();
|
||||
for (let i = 0; i < 3; i++) { tl.to("#x", { x: 100, duration: 0.5 }, i * 0.5); }`;
|
||||
const unrolled = unrollComputedTimeline(script);
|
||||
expect(unrolled).not.toContain("for (");
|
||||
const after = parseGsapScriptAcorn(unrolled);
|
||||
expect(after.animations.map((a) => a.resolvedStart)).toEqual([0, 0.5, 1]);
|
||||
expect(after.animations.every((a) => a.provenance === undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves a fully-literal composition unchanged", () => {
|
||||
const script = `const tl = gsap.timeline();
|
||||
tl.from("#a", { opacity: 0, duration: 0.5 }, 0.1);`;
|
||||
expect(unrollComputedTimeline(script)).toBe(script);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Unroll computed GSAP timelines (helpers / bounded loops) into explicit literal
|
||||
* tweens — the source-rewrite behind the Studio "Unroll to edit" action.
|
||||
*
|
||||
* Strategy: the read parser already resolves each computed tween (positions,
|
||||
* motionPath arcs, keyframes, provenance). We serialize those resolved
|
||||
* animations back to literal `tl.*` statements and surgically replace the
|
||||
* top-level helper-call / loop statements that produced them (and drop the now
|
||||
* dead helper declarations) via magic-string, leaving the rest of the source —
|
||||
* literal tweens, comments, formatting — untouched. The result is a visual
|
||||
* no-op: re-parsing it yields the same animations, now all literal.
|
||||
*
|
||||
* Scope: top-level helper calls and loops (the common authoring shape). Tweens
|
||||
* whose origin can't be mapped to a top-level statement (e.g. helpers nested
|
||||
* inside other helpers) are left as-is rather than guessed at.
|
||||
*/
|
||||
import * as acorn from "acorn";
|
||||
import MagicString from "magic-string";
|
||||
import type { GsapAnimation } from "./gsapSerialize.js";
|
||||
import { serializeValue as valueToCode, safeJsKey as safeKey } from "./gsapSerialize.js";
|
||||
import { parseGsapScriptAcorn } from "./gsapParserAcorn.js";
|
||||
|
||||
// acorn nodes are structurally untyped here.
|
||||
type Node = any;
|
||||
|
||||
function propEntries(props: Record<string, number | string>): string[] {
|
||||
return Object.entries(props).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`);
|
||||
}
|
||||
|
||||
function motionPathEntry(anim: GsapAnimation): string {
|
||||
const waypoints = (anim.keyframes?.keyframes ?? [])
|
||||
.filter((k) => typeof k.properties.x === "number" && typeof k.properties.y === "number")
|
||||
.map((k) => `{ x: ${valueToCode(k.properties.x!)}, y: ${valueToCode(k.properties.y!)} }`);
|
||||
const curviness = anim.arcPath?.segments[0]?.curviness ?? 1;
|
||||
const autoRotate = anim.arcPath?.autoRotate;
|
||||
const extra = autoRotate ? `, autoRotate: ${valueToCode(autoRotate as number | string)}` : "";
|
||||
return `motionPath: { path: [${waypoints.join(", ")}], curviness: ${curviness}${extra} }`;
|
||||
}
|
||||
|
||||
function keyframesEntry(anim: GsapAnimation): string {
|
||||
const kfs = (anim.keyframes?.keyframes ?? []).map((k) => {
|
||||
const body = propEntries(k.properties);
|
||||
if (k.ease) body.push(`ease: ${valueToCode(k.ease)}`);
|
||||
return `"${k.percentage}%": { ${body.join(", ")} }`;
|
||||
});
|
||||
if (anim.keyframes?.easeEach) kfs.push(`easeEach: ${valueToCode(anim.keyframes.easeEach)}`);
|
||||
return `keyframes: { ${kfs.join(", ")} }`;
|
||||
}
|
||||
|
||||
/** The vars-object entries for a tween: motionPath/keyframes block, props, duration, ease, extras. */
|
||||
function buildVarsParts(anim: GsapAnimation): string[] {
|
||||
const parts: string[] = [];
|
||||
if (anim.arcPath?.enabled) parts.push(motionPathEntry(anim));
|
||||
else if (anim.keyframes) parts.push(keyframesEntry(anim));
|
||||
parts.push(...propEntries(anim.properties));
|
||||
if (anim.method !== "set" && anim.duration !== undefined) {
|
||||
parts.push(`duration: ${valueToCode(anim.duration)}`);
|
||||
}
|
||||
if (anim.ease) parts.push(`ease: ${valueToCode(anim.ease)}`);
|
||||
for (const [k, v] of Object.entries(anim.extras ?? {})) {
|
||||
parts.push(`${safeKey(k)}: ${valueToCode(v as number | string)}`);
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
/** Serialize one resolved animation to a literal `tl.*` statement (arc/keyframe-aware). */
|
||||
function serializeTweenStatement(timelineVar: string, anim: GsapAnimation): string {
|
||||
const obj = `{ ${buildVarsParts(anim).join(", ")} }`;
|
||||
const pos = valueToCode(
|
||||
anim.resolvedStart ?? (typeof anim.position === "number" ? anim.position : 0),
|
||||
);
|
||||
const sel = valueToCode(anim.targetSelector);
|
||||
if (anim.method === "fromTo") {
|
||||
const from = `{ ${propEntries(anim.fromProperties ?? {}).join(", ")} }`;
|
||||
return `${timelineVar}.fromTo(${sel}, ${from}, ${obj}, ${pos});`;
|
||||
}
|
||||
return `${timelineVar}.${anim.method}(${sel}, ${obj}, ${pos});`;
|
||||
}
|
||||
|
||||
/** A computed animation is one expanded from a helper or loop (not literal/dynamic). */
|
||||
function isComputed(anim: GsapAnimation): boolean {
|
||||
return anim.provenance?.kind === "helper" || anim.provenance?.kind === "loop";
|
||||
}
|
||||
|
||||
/** Top-level statements of the parsed program. */
|
||||
function topLevelStatements(script: string): Node[] {
|
||||
return acorn.parse(script, { ecmaVersion: "latest", sourceType: "script" }).body ?? [];
|
||||
}
|
||||
|
||||
/** The top-level statement whose source span contains [start, end], or null. */
|
||||
function enclosingTopLevel(statements: Node[], start: number, end: number): Node | null {
|
||||
for (const stmt of statements) {
|
||||
if (stmt.start <= start && stmt.end >= end) return stmt;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isHelperDeclNamed(stmt: Node, names: Set<string>): boolean {
|
||||
if (stmt.type === "FunctionDeclaration") return names.has(stmt.id?.name);
|
||||
if (stmt.type === "VariableDeclaration") {
|
||||
return (stmt.declarations ?? []).some((d: Node) => names.has(d.id?.name));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite `script` so top-level helper calls / loops that build the timeline
|
||||
* become explicit literal tweens. Returns the original script unchanged when
|
||||
* there is nothing statically-resolvable to unroll.
|
||||
*/
|
||||
export function unrollComputedTimeline(script: string): string {
|
||||
const parsed = parseGsapScriptAcorn(script);
|
||||
const computed = parsed.animations.filter((a) => isComputed(a) && a.provenance?.sourceRange);
|
||||
if (computed.length === 0) return script;
|
||||
|
||||
const statements = topLevelStatements(script);
|
||||
|
||||
// Group computed animations by the top-level statement that produced them,
|
||||
// preserving source order within each group.
|
||||
const byStatement = new Map<Node, GsapAnimation[]>();
|
||||
const helperNames = new Set<string>();
|
||||
for (const anim of computed) {
|
||||
if (anim.provenance?.fn) helperNames.add(anim.provenance.fn);
|
||||
const [s, e] = anim.provenance!.sourceRange!;
|
||||
const stmt = enclosingTopLevel(statements, s, e);
|
||||
if (!stmt) continue; // nested origin — leave it; can't map to a top-level edit
|
||||
const list = byStatement.get(stmt) ?? [];
|
||||
list.push(anim);
|
||||
byStatement.set(stmt, list);
|
||||
}
|
||||
if (byStatement.size === 0) return script;
|
||||
|
||||
const ms = new MagicString(script);
|
||||
for (const [stmt, anims] of byStatement) {
|
||||
const literals = anims.map((a) => serializeTweenStatement(parsed.timelineVar, a)).join("\n");
|
||||
ms.overwrite(stmt.start, stmt.end, literals);
|
||||
}
|
||||
// Drop the now-dead helper declarations.
|
||||
for (const stmt of statements) {
|
||||
if (isHelperDeclNamed(stmt, helperNames)) ms.remove(stmt.start, stmt.end);
|
||||
}
|
||||
return ms.toString();
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
} from "../helpers/finiteMutation.js";
|
||||
import type { GsapAnimation } from "../../parsers/gsapSerialize.js";
|
||||
import { parseGsapScriptAcorn } from "../../parsers/gsapParserAcorn.js";
|
||||
import { unrollComputedTimeline } from "../../parsers/gsapUnroll.js";
|
||||
import {
|
||||
removeElementFromHtml,
|
||||
patchElementInHtml,
|
||||
@@ -474,6 +475,11 @@ type GsapMutationRequest =
|
||||
type: "delete-all-for-selector";
|
||||
targetSelector: string;
|
||||
}
|
||||
| {
|
||||
// Rewrite all top-level helper/loop constructs into literal tweens so
|
||||
// computed keyframes become directly editable (visual no-op).
|
||||
type: "unroll-timeline";
|
||||
}
|
||||
| {
|
||||
type: "shift-positions";
|
||||
targetSelector: string;
|
||||
@@ -734,6 +740,9 @@ async function executeGsapMutation(
|
||||
const result = splitIntoPropertyGroups(block.scriptText, body.animationId);
|
||||
return result.script;
|
||||
}
|
||||
case "unroll-timeline": {
|
||||
return unrollComputedTimeline(block.scriptText);
|
||||
}
|
||||
case "shift-positions": {
|
||||
const { targetSelector, delta } = body;
|
||||
if (!targetSelector || !Number.isFinite(delta) || delta === 0) return block.scriptText;
|
||||
|
||||
Reference in New Issue
Block a user