mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 07:19:52 +00:00
- fs.watch's async 'error' event had no listener, crashing the preview
server on EMFILE (exhausted OS watch handles)
- moveKeyframeInScript/resizeKeyframedTweenInScript/removeAllKeyframesFromScript
required object-form keyframes: {"0%": {...}}, silently no-opping on
array-form keyframes: [{...}, {...}]
- a keyframe diamond click's auto-synthesized native click event bubbled
to the ancestor clip's onClick, which toggles selection off when the
clip is already selected (the state every diamond click happens in)
- the clip's trim-resize handles (z-index 4) visually and functionally
covered any keyframe diamond within their 14px edge strip
- synthesizeFlatTweenKeyframes didn't recognize a collapsed
duration:0 + immediateRender static hold (what remove-all-keyframes
produces) as non-animated, so it kept showing a phantom diamond after
Delete All Keyframes
- resolveMediaStartSeconds's fast path for elements with their own
data-start discarded the host composition's inherited start offset,
so a video nested inside a sub-composition played from the root
timeline's time instead of holding until its parent scene began
Fixes #1838
3315 lines
123 KiB
TypeScript
3315 lines
123 KiB
TypeScript
/**
|
|
* Node-only GSAP AST parser. Depends on recast / @babel/parser, which compile
|
|
* to CommonJS that calls `require("fs")` — so this module must never be in the
|
|
* static import graph of isomorphic/browser code. It is reachable only via the
|
|
* `@hyperframes/core/gsap-parser` subpath (studio-api mutations + the linter).
|
|
*
|
|
* Recast-free helpers (serialization, keyframe conversion, validation, types)
|
|
* live in `./gsapSerialize` and are re-exported here so this subpath exposes the
|
|
* full surface for tests and server-side consumers.
|
|
*/
|
|
import * as recast from "recast";
|
|
import { parse as babelParse } from "@babel/parser";
|
|
import {
|
|
type ArcPathConfig,
|
|
type ArcPathSegment,
|
|
type GsapAnimation,
|
|
type GsapKeyframesData,
|
|
type GsapMethod,
|
|
type GsapPercentageKeyframe,
|
|
type ParsedGsap,
|
|
serializeValue as valueToCode,
|
|
safeJsKey as safeKey,
|
|
resolveConversionProps,
|
|
} from "./gsapSerialize";
|
|
|
|
export type {
|
|
ArcPathConfig,
|
|
ArcPathSegment,
|
|
GsapAnimation,
|
|
GsapMethod,
|
|
ParsedGsap,
|
|
GsapKeyframesData,
|
|
GsapPercentageKeyframe,
|
|
GsapKeyframeFormat,
|
|
} from "./gsapSerialize";
|
|
export {
|
|
serializeGsapAnimations,
|
|
getAnimationsForElementId,
|
|
validateCompositionGsap,
|
|
keyframesToGsapAnimations,
|
|
gsapAnimationsToKeyframes,
|
|
SUPPORTED_PROPS,
|
|
SUPPORTED_EASES,
|
|
} from "./gsapSerialize";
|
|
export type { PropertyGroupName } from "./gsapConstants";
|
|
export {
|
|
PROPERTY_GROUPS,
|
|
classifyPropertyGroup,
|
|
classifyTweenPropertyGroup,
|
|
} from "./gsapConstants";
|
|
import { classifyPropertyGroup, classifyTweenPropertyGroup } from "./gsapConstants";
|
|
import type { PropertyGroupName } from "./gsapConstants";
|
|
export { generateSpringEaseData, SPRING_PRESETS } from "./springEase";
|
|
export type { SpringPreset } from "./springEase";
|
|
|
|
const GSAP_METHODS = new Set<string>(["set", "to", "from", "fromTo"]);
|
|
|
|
// ── Recast / Babel AST shape types ────────────────────────────────────────
|
|
//
|
|
// Recast's own typings are loose (`any` everywhere). These local shapes
|
|
// capture the properties we actually access, giving us IDE navigation and
|
|
// catch-at-write-time safety without depending on @babel/types at runtime.
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- recast AST nodes are inherently untyped
|
|
interface AstNode extends Record<string, any> {
|
|
type: string;
|
|
}
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- recast visitor paths are inherently untyped
|
|
interface AstPath extends Record<string, any> {
|
|
node: AstNode;
|
|
}
|
|
|
|
// ── Recast AST Helpers ──────────────────────────────────────────────────────
|
|
|
|
type ScopeBindings = ReadonlyMap<string, number | string | boolean>;
|
|
|
|
function parseScript(script: string) {
|
|
return recast.parse(script, {
|
|
parser: {
|
|
parse(source: string) {
|
|
return babelParse(source, { sourceType: "script", plugins: [], tokens: true });
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
function collectScopeBindings(ast: AstNode): ScopeBindings {
|
|
const bindings = new Map<string, number | string | boolean>();
|
|
recast.types.visit(ast, {
|
|
visitVariableDeclarator(path: AstPath) {
|
|
const name = path.node.id?.name;
|
|
const init = path.node.init;
|
|
if (name && init) {
|
|
const val = resolveNode(init, bindings);
|
|
if (val !== undefined) bindings.set(name, val);
|
|
}
|
|
this.traverse(path);
|
|
},
|
|
});
|
|
return bindings;
|
|
}
|
|
|
|
function resolveNode(
|
|
node: AstNode | undefined,
|
|
scope: ReadonlyMap<string, number | string | boolean>,
|
|
): number | string | boolean | undefined {
|
|
if (!node) return undefined;
|
|
if (node.type === "NumericLiteral" || (node.type === "Literal" && typeof node.value === "number"))
|
|
return node.value;
|
|
if (node.type === "StringLiteral" || (node.type === "Literal" && typeof node.value === "string"))
|
|
return node.value;
|
|
if (
|
|
node.type === "BooleanLiteral" ||
|
|
(node.type === "Literal" && typeof node.value === "boolean")
|
|
)
|
|
return node.value;
|
|
if (node.type === "UnaryExpression" && node.operator === "-" && node.argument) {
|
|
const val = resolveNode(node.argument, scope);
|
|
return typeof val === "number" ? -val : undefined;
|
|
}
|
|
if (node.type === "BinaryExpression") {
|
|
const left = resolveNode(node.left, scope);
|
|
const right = resolveNode(node.right, scope);
|
|
if (typeof left === "number" && typeof right === "number") {
|
|
switch (node.operator) {
|
|
case "+":
|
|
return left + right;
|
|
case "-":
|
|
return left - right;
|
|
case "*":
|
|
return left * right;
|
|
case "/":
|
|
return right !== 0 ? left / right : undefined;
|
|
}
|
|
}
|
|
if (typeof left === "string" && node.operator === "+") return left + String(right ?? "");
|
|
if (typeof right === "string" && node.operator === "+") return String(left ?? "") + right;
|
|
}
|
|
if (node.type === "Identifier" && scope.has(node.name)) {
|
|
return scope.get(node.name);
|
|
}
|
|
if (node.type === "TemplateLiteral" && node.expressions?.length === 0) {
|
|
return node.quasis?.[0]?.value?.cooked ?? undefined;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function extractLiteralValue(node: AstNode | undefined, 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, …)`), arrays
|
|
// of them (`tl.to([a, b], …)`), `gsap.utils.toArray(".sel")`, and per-element
|
|
// loop variables (`items.forEach(el => tl.to(el, …))`) — not inline string
|
|
// selectors. To make those tweens editable we resolve each target back to the
|
|
// CSS selector(s) it addresses. Resolution is lexically scoped: the same
|
|
// variable name can mean different elements in different IIFEs.
|
|
|
|
const QUERY_METHODS = new Set(["querySelector", "querySelectorAll"]);
|
|
const ITERATION_METHODS = new Set(["forEach", "map"]);
|
|
const SCOPE_NODE_TYPES = new Set([
|
|
"Program",
|
|
"FunctionDeclaration",
|
|
"FunctionExpression",
|
|
"ArrowFunctionExpression",
|
|
]);
|
|
|
|
/**
|
|
* If `node` is a DOM lookup call — `x.querySelector(".sel")`,
|
|
* `document.querySelectorAll(".sel")`, `document.getElementById("id")`, or
|
|
* `gsap.utils.toArray(".sel")` — return the CSS selector it resolves to.
|
|
* `getElementById("id")` maps to `#id`. Returns null for anything else.
|
|
*/
|
|
function selectorFromQueryCall(node: AstNode, 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) || method === "toArray") return argValue;
|
|
if (method === "getElementById") return `#${argValue}`;
|
|
return null;
|
|
}
|
|
|
|
/** The nearest enclosing function/program node — the binding scope of `path`. */
|
|
function enclosingScopeNode(path: AstPath): AstNode | null {
|
|
let p = path?.parentPath;
|
|
while (p) {
|
|
if (SCOPE_NODE_TYPES.has(p.node?.type)) return p.node;
|
|
p = p.parentPath;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** Scope nodes enclosing `path`, innermost first. */
|
|
function scopeChainOf(path: AstPath): AstNode[] {
|
|
const chain: AstNode[] = [];
|
|
let p = path;
|
|
while (p) {
|
|
if (SCOPE_NODE_TYPES.has(p.node?.type)) chain.push(p.node);
|
|
p = p.parentPath;
|
|
}
|
|
return chain;
|
|
}
|
|
|
|
/** Per-scope element bindings: scopeNode → (variable name → selector). */
|
|
type TargetBindings = Map<any, Map<string, string>>;
|
|
|
|
function addBinding(
|
|
bindings: TargetBindings,
|
|
scopeNode: AstNode,
|
|
name: string,
|
|
selector: string,
|
|
): void {
|
|
let scoped = bindings.get(scopeNode);
|
|
if (!scoped) {
|
|
scoped = new Map();
|
|
bindings.set(scopeNode, scoped);
|
|
}
|
|
if (!scoped.has(name)) scoped.set(name, selector);
|
|
}
|
|
|
|
/**
|
|
* Build a lexically-scoped index of element variables → selector. Two passes:
|
|
* (1) direct DOM-lookup assignments (`const x = root.querySelector(...)`), then
|
|
* (2) iteration callback params (`coll.forEach(el => …)`), whose element type is
|
|
* the collection's selector — resolved against the pass-1 bindings.
|
|
*/
|
|
function collectTargetBindings(ast: AstNode, scope: ScopeBindings): TargetBindings {
|
|
const bindings: TargetBindings = new Map();
|
|
|
|
recast.types.visit(ast, {
|
|
visitVariableDeclarator(path: AstPath) {
|
|
const name = path.node.id?.name;
|
|
const selector = selectorFromQueryCall(path.node.init, scope);
|
|
const scopeNode = enclosingScopeNode(path);
|
|
if (name && selector !== null && scopeNode) addBinding(bindings, scopeNode, name, selector);
|
|
this.traverse(path);
|
|
},
|
|
visitAssignmentExpression(path: AstPath) {
|
|
const left = path.node.left;
|
|
const selector = selectorFromQueryCall(path.node.right, scope);
|
|
const scopeNode = enclosingScopeNode(path);
|
|
if (left?.type === "Identifier" && selector !== null && scopeNode) {
|
|
addBinding(bindings, scopeNode, left.name, selector);
|
|
}
|
|
this.traverse(path);
|
|
},
|
|
});
|
|
|
|
// Pass 2: forEach/map callback params take the collection's selector.
|
|
recast.types.visit(ast, {
|
|
visitCallExpression(path: AstPath) {
|
|
const node = path.node;
|
|
const callee = node.callee;
|
|
if (
|
|
callee?.type === "MemberExpression" &&
|
|
callee.property?.type === "Identifier" &&
|
|
ITERATION_METHODS.has(callee.property.name)
|
|
) {
|
|
const collectionSelector = resolveCollectionSelector(callee.object, path, scope, bindings);
|
|
const fn = node.arguments?.[0];
|
|
const param = fn?.params?.[0];
|
|
if (collectionSelector && param?.type === "Identifier" && isFunctionNode(fn)) {
|
|
addBinding(bindings, fn, param.name, collectionSelector);
|
|
}
|
|
}
|
|
this.traverse(path);
|
|
},
|
|
});
|
|
|
|
return bindings;
|
|
}
|
|
|
|
function isFunctionNode(node: AstNode): boolean {
|
|
return (
|
|
node?.type === "ArrowFunctionExpression" ||
|
|
node?.type === "FunctionExpression" ||
|
|
node?.type === "FunctionDeclaration"
|
|
);
|
|
}
|
|
|
|
/** Resolve the selector a `.forEach`/`.map` is iterating over (variable or inline call). */
|
|
function resolveCollectionSelector(
|
|
node: AstNode,
|
|
callPath: AstPath,
|
|
scope: ScopeBindings,
|
|
bindings: TargetBindings,
|
|
): string | null {
|
|
if (node?.type === "Identifier") return lookupBinding(node.name, callPath, bindings);
|
|
if (node?.type === "CallExpression") return selectorFromQueryCall(node, scope);
|
|
return null;
|
|
}
|
|
|
|
/** Resolve a variable name to its selector using the lexical scope chain of `path`. */
|
|
function lookupBinding(name: string, path: AstPath, bindings: TargetBindings): string | null {
|
|
for (const scopeNode of scopeChainOf(path)) {
|
|
const selector = bindings.get(scopeNode)?.get(name);
|
|
if (selector !== undefined) return selector;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Resolve a tween's first argument to a CSS selector. Handles inline string
|
|
* literals, element variables (lexically scoped), arrays of elements (joined
|
|
* into a CSS group selector), inline DOM lookup / `toArray` calls, and indexed
|
|
* access (`items[i]`). Returns null when the target can't be resolved
|
|
* statically (e.g. an object-target duration anchor `tl.to({ _: 0 }, …)`, or a
|
|
* runtime-computed selector).
|
|
*/
|
|
function resolveTargetSelector(
|
|
node: AstNode,
|
|
path: AstPath,
|
|
scope: ScopeBindings,
|
|
bindings: 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 lookupBinding(node.name, path, bindings);
|
|
}
|
|
if (node.type === "CallExpression") {
|
|
return selectorFromQueryCall(node, scope);
|
|
}
|
|
if (node.type === "ArrayExpression") {
|
|
const parts = node.elements
|
|
.map((el: AstNode) => resolveTargetSelector(el, path, scope, bindings))
|
|
.filter((s: string | null): s is string => typeof s === "string" && s.length > 0);
|
|
return parts.length > 0 ? parts.join(", ") : null;
|
|
}
|
|
if (node.type === "MemberExpression" && node.object?.type === "Identifier") {
|
|
// `items[i]` — the element type is the collection's selector.
|
|
return lookupBinding(node.object.name, path, bindings);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function objectExpressionToRecord(node: AstNode, scope: ScopeBindings): Record<string, unknown> {
|
|
const result: Record<string, unknown> = {};
|
|
if (node?.type !== "ObjectExpression") return result;
|
|
for (const prop of node.properties ?? []) {
|
|
if (prop.type !== "ObjectProperty" && prop.type !== "Property") continue;
|
|
const key = prop.key?.name ?? prop.key?.value;
|
|
if (!key) continue;
|
|
const resolved = resolveNode(prop.value, scope);
|
|
if (resolved !== undefined) {
|
|
result[key] = resolved;
|
|
} else {
|
|
// Preserve unresolvable values as raw source text so they survive round-trips
|
|
result[key] = `__raw:${recast.print(prop.value).code}`;
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// ── Timeline Variable Detection ─────────────────────────────────────────────
|
|
|
|
function isGsapTimelineCall(node: AstNode): boolean {
|
|
return (
|
|
node?.type === "CallExpression" &&
|
|
node.callee?.type === "MemberExpression" &&
|
|
node.callee.object?.name === "gsap" &&
|
|
node.callee.property?.name === "timeline"
|
|
);
|
|
}
|
|
|
|
interface TimelineDefaults {
|
|
ease?: string;
|
|
duration?: number;
|
|
}
|
|
|
|
// `identifier` is the canonical `const tl = …` form; `member` is the inline
|
|
// `window.__timelines["scene"] = …` form (the timeline IS the member expression).
|
|
type TimelineRef = { kind: "identifier"; name: string } | { kind: "member"; node: AstNode };
|
|
|
|
interface TimelineDetection {
|
|
timelineVar: string | null;
|
|
ref: TimelineRef | null;
|
|
timelineCount: number;
|
|
defaults?: TimelineDefaults;
|
|
}
|
|
|
|
/** The static string key of a member access (`window.__timelines["scene"]` → "scene"), else null. */
|
|
function staticMemberKey(node: AstNode): string | null {
|
|
if (!node || node.type !== "MemberExpression") return null;
|
|
if (node.computed) {
|
|
const p = node.property;
|
|
if (p?.type === "StringLiteral") return p.value;
|
|
if (p?.type === "Literal" && typeof p.value === "string") return p.value;
|
|
return null;
|
|
}
|
|
return node.property?.type === "Identifier" ? node.property.name : null;
|
|
}
|
|
|
|
function isStaticMemberRef(node: AstNode): boolean {
|
|
return node?.type === "MemberExpression" && staticMemberKey(node) !== null;
|
|
}
|
|
|
|
/** Structural equality of two member accesses (object chain + static key), quote-insensitive. */
|
|
function sameMemberAccess(a: AstNode, b: AstNode): boolean {
|
|
if (a?.type !== "MemberExpression" || b?.type !== "MemberExpression") return false;
|
|
if (staticMemberKey(a) !== staticMemberKey(b) || staticMemberKey(a) === null) return false;
|
|
const ao = a.object;
|
|
const bo = b.object;
|
|
if (ao?.type === "Identifier" && bo?.type === "Identifier") return ao.name === bo.name;
|
|
if (ao?.type === "MemberExpression" && bo?.type === "MemberExpression")
|
|
return sameMemberAccess(ao, bo);
|
|
return false;
|
|
}
|
|
|
|
/** The source string a tween call roots at: identifier name, or the member source as written. */
|
|
function timelineRootSource(ref: TimelineRef): string {
|
|
return ref.kind === "identifier" ? ref.name : recast.print(ref.node).code;
|
|
}
|
|
|
|
function escapeRegExp(s: string): string {
|
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
}
|
|
|
|
function extractTimelineDefaults(
|
|
callNode: AstNode,
|
|
scope: ScopeBindings,
|
|
): TimelineDefaults | undefined {
|
|
const arg = callNode.arguments?.[0];
|
|
if (!arg || arg.type !== "ObjectExpression") return undefined;
|
|
const defaultsProp = arg.properties?.find(
|
|
(p: AstNode) => isObjectProperty(p) && propKeyName(p) === "defaults",
|
|
);
|
|
if (!defaultsProp?.value || defaultsProp.value.type !== "ObjectExpression") return undefined;
|
|
const record = objectExpressionToRecord(defaultsProp.value, scope);
|
|
const result: TimelineDefaults = {};
|
|
if (typeof record.ease === "string") result.ease = record.ease;
|
|
if (typeof record.duration === "number") result.duration = record.duration;
|
|
return Object.keys(result).length > 0 ? result : undefined;
|
|
}
|
|
|
|
function findTimelineVar(ast: AstNode, scope?: ScopeBindings): TimelineDetection {
|
|
let timelineVar: string | null = null;
|
|
let ref: TimelineRef | null = null;
|
|
let timelineCount = 0;
|
|
let defaults: TimelineDefaults | undefined;
|
|
const emptyScope: ScopeBindings = scope ?? new Map();
|
|
recast.types.visit(ast, {
|
|
visitVariableDeclarator(path: AstPath) {
|
|
if (isGsapTimelineCall(path.node.init)) {
|
|
timelineCount += 1;
|
|
if (!ref && path.node.id?.type === "Identifier") {
|
|
timelineVar = path.node.id.name;
|
|
ref = { kind: "identifier", name: path.node.id.name };
|
|
defaults = extractTimelineDefaults(path.node.init, emptyScope);
|
|
}
|
|
}
|
|
this.traverse(path);
|
|
},
|
|
visitAssignmentExpression(path: AstPath) {
|
|
if (isGsapTimelineCall(path.node.right)) {
|
|
timelineCount += 1;
|
|
if (!ref) {
|
|
const left = path.node.left;
|
|
if (left?.type === "Identifier") {
|
|
timelineVar = left.name;
|
|
ref = { kind: "identifier", name: left.name };
|
|
defaults = extractTimelineDefaults(path.node.right, emptyScope);
|
|
} else if (isStaticMemberRef(left)) {
|
|
ref = { kind: "member", node: left };
|
|
defaults = extractTimelineDefaults(path.node.right, emptyScope);
|
|
}
|
|
}
|
|
}
|
|
this.traverse(path);
|
|
},
|
|
});
|
|
return { timelineVar, ref, timelineCount, defaults };
|
|
}
|
|
|
|
// ── Find All Tween Calls ────────────────────────────────────────────────────
|
|
|
|
interface TweenCallInfo {
|
|
path: AstPath;
|
|
node: AstNode;
|
|
method: GsapMethod;
|
|
selector: string;
|
|
varsArg: AstNode;
|
|
fromArg?: AstNode;
|
|
positionArg?: AstNode;
|
|
/** True for a base `gsap.set(...)` (off-timeline) rather than `tl.set(...)`. */
|
|
global?: boolean;
|
|
}
|
|
|
|
/**
|
|
* True when the member chain of `callNode.callee` is rooted at the timeline
|
|
* variable — `tl.to(...)` and every link of a chain `tl.to(...).to(...)`.
|
|
*/
|
|
function isTimelineRootedCall(callNode: AstNode, ref: TimelineRef): boolean {
|
|
let obj = callNode.callee?.object;
|
|
while (obj?.type === "CallExpression") {
|
|
obj = obj.callee?.object;
|
|
}
|
|
if (ref.kind === "identifier") return obj?.type === "Identifier" && obj.name === ref.name;
|
|
return sameMemberAccess(obj, ref.node);
|
|
}
|
|
|
|
function findAllTweenCalls(
|
|
ast: AstNode,
|
|
ref: TimelineRef,
|
|
scope: ScopeBindings,
|
|
targetBindings: TargetBindings,
|
|
): TweenCallInfo[] {
|
|
const results: TweenCallInfo[] = [];
|
|
recast.types.visit(ast, {
|
|
visitCallExpression(path: AstPath) {
|
|
const node = path.node;
|
|
const callee = node.callee;
|
|
// A base `gsap.set("#sel", props)` is an off-timeline static hold (no position,
|
|
// no keyframe marker). Treat it as an editable `set` animation so a static
|
|
// value (e.g. a 3D transform) round-trips and re-edits in place. Restricted to
|
|
// a STRING-LITERAL selector: variable-target `gsap.set(el, ...)` holds stay
|
|
// opaque surrounding source (editing them by selector would be ambiguous).
|
|
const gsapSetArg = node.arguments?.[0];
|
|
const isGlobalSet =
|
|
callee?.type === "MemberExpression" &&
|
|
callee.object?.type === "Identifier" &&
|
|
callee.object.name === "gsap" &&
|
|
callee.property?.type === "Identifier" &&
|
|
callee.property.name === "set" &&
|
|
(gsapSetArg?.type === "StringLiteral" ||
|
|
(gsapSetArg?.type === "Literal" && typeof gsapSetArg.value === "string"));
|
|
if (
|
|
callee?.type === "MemberExpression" &&
|
|
callee.property?.type === "Identifier" &&
|
|
(isTimelineRootedCall(node, ref) || isGlobalSet)
|
|
) {
|
|
const method = callee.property.name;
|
|
if (!GSAP_METHODS.has(method)) {
|
|
this.traverse(path);
|
|
return;
|
|
}
|
|
const args = node.arguments;
|
|
if (args.length < 2) {
|
|
this.traverse(path);
|
|
return;
|
|
}
|
|
const selectorValue =
|
|
resolveTargetSelector(args[0], path, scope, targetBindings) ?? "__unresolved__";
|
|
|
|
if (method === "fromTo") {
|
|
results.push({
|
|
path,
|
|
node,
|
|
method: "fromTo",
|
|
selector: selectorValue,
|
|
fromArg: args[1],
|
|
varsArg: args[2],
|
|
positionArg: args[3],
|
|
});
|
|
} else {
|
|
results.push({
|
|
path,
|
|
node,
|
|
method: method as GsapMethod,
|
|
selector: selectorValue,
|
|
varsArg: args[1],
|
|
positionArg: args[2],
|
|
...(isGlobalSet ? { global: true } : {}),
|
|
});
|
|
}
|
|
}
|
|
this.traverse(path);
|
|
},
|
|
});
|
|
return results;
|
|
}
|
|
|
|
/** Keys that are stored on dedicated GsapAnimation fields (not in properties/extras). */
|
|
const BUILTIN_VAR_KEYS = new Set(["duration", "ease", "delay"]);
|
|
|
|
/** Keys that are never preserved (callbacks / advanced patterns). */
|
|
const DROPPED_VAR_KEYS = new Set(["onComplete", "onStart", "onUpdate", "onRepeat"]);
|
|
|
|
/** Keys that belong in `extras` — non-editable GSAP config that must survive round-trips. */
|
|
const EXTRAS_KEYS = new Set([
|
|
"stagger",
|
|
"yoyo",
|
|
"repeat",
|
|
"repeatDelay",
|
|
"snap",
|
|
"overwrite",
|
|
"immediateRender",
|
|
]);
|
|
|
|
/**
|
|
* Extract raw source text for a property in an ObjectExpression AST node.
|
|
* Returns the printed source of the value node, suitable for verbatim re-emission.
|
|
*/
|
|
function extractRawPropertySource(varsArgNode: AstNode, key: string): string | undefined {
|
|
const node = findPropertyNode(varsArgNode, key);
|
|
return node ? recast.print(node).code : undefined;
|
|
}
|
|
|
|
/** Find the raw AST node for a named property inside an ObjectExpression. */
|
|
function findPropertyNode(varsArgNode: AstNode, key: string): AstNode | undefined {
|
|
if (varsArgNode?.type !== "ObjectExpression") return undefined;
|
|
for (const prop of varsArgNode.properties ?? []) {
|
|
if (!isObjectProperty(prop)) continue;
|
|
if (propKeyName(prop) === key) return prop.value;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
// ── Native GSAP Keyframes Parsing ──────────────────────────────────────────
|
|
|
|
const PERCENTAGE_KEY_RE = /^(\d+(?:\.\d+)?)%$/;
|
|
|
|
/** Extract a string-valued ease or easeEach from an AST property node. */
|
|
function tryResolveStringProp(propValue: AstNode, scope: ScopeBindings): string | undefined {
|
|
const val = resolveNode(propValue, scope);
|
|
return typeof val === "string" ? val : undefined;
|
|
}
|
|
|
|
/**
|
|
* Parse a `keyframes` property value from a tween vars AST node into a
|
|
* normalized `GsapKeyframesData` structure. Handles all three GSAP formats:
|
|
* percentage objects, object arrays, and simple (property-array) objects.
|
|
*/
|
|
// fallow-ignore-next-line complexity
|
|
function parseKeyframesNode(
|
|
node: AstNode | undefined,
|
|
scope: ScopeBindings,
|
|
): GsapKeyframesData | undefined {
|
|
if (!node) return undefined;
|
|
|
|
// ── Object array format: keyframes: [ { x: 0, duration: 0.5 }, ... ] ──
|
|
if (node.type === "ArrayExpression") {
|
|
return parseObjectArrayKeyframes(node, scope);
|
|
}
|
|
|
|
if (node.type !== "ObjectExpression") return undefined;
|
|
|
|
// Distinguish percentage vs simple-array by inspecting property keys/values.
|
|
const props = node.properties ?? [];
|
|
let hasPercentageKey = false;
|
|
let hasArrayValue = false;
|
|
|
|
for (const prop of props) {
|
|
if (prop.type !== "ObjectProperty" && prop.type !== "Property") continue;
|
|
const key = prop.key?.value ?? prop.key?.name;
|
|
if (typeof key === "string" && PERCENTAGE_KEY_RE.test(key)) {
|
|
hasPercentageKey = true;
|
|
break;
|
|
}
|
|
if (prop.value?.type === "ArrayExpression") {
|
|
hasArrayValue = true;
|
|
}
|
|
}
|
|
|
|
if (hasPercentageKey) return parsePercentageKeyframes(node, scope);
|
|
if (hasArrayValue) return parseSimpleArrayKeyframes(node, scope);
|
|
|
|
return undefined;
|
|
}
|
|
|
|
// fallow-ignore-next-line complexity
|
|
function parsePercentageKeyframes(node: AstNode, scope: ScopeBindings): GsapKeyframesData {
|
|
const keyframes: GsapPercentageKeyframe[] = [];
|
|
let ease: string | undefined;
|
|
let easeEach: string | undefined;
|
|
|
|
for (const prop of node.properties ?? []) {
|
|
if (prop.type !== "ObjectProperty" && prop.type !== "Property") continue;
|
|
const key = prop.key?.value ?? prop.key?.name;
|
|
if (typeof key !== "string") continue;
|
|
|
|
const pctMatch = PERCENTAGE_KEY_RE.exec(key);
|
|
if (pctMatch) {
|
|
const percentage = Number.parseFloat(pctMatch[1]!);
|
|
const record = objectExpressionToRecord(prop.value, scope);
|
|
const properties: Record<string, number | string> = {};
|
|
let kfEase: string | undefined;
|
|
for (const [k, v] of Object.entries(record)) {
|
|
if (k === "ease" && typeof v === "string") {
|
|
kfEase = v;
|
|
} else if (typeof v === "number" || typeof v === "string") {
|
|
properties[k] = v;
|
|
}
|
|
}
|
|
keyframes.push({ percentage, properties, ...(kfEase ? { ease: kfEase } : {}) });
|
|
} else if (key === "ease") {
|
|
ease = tryResolveStringProp(prop.value, scope) ?? ease;
|
|
} else if (key === "easeEach") {
|
|
easeEach = tryResolveStringProp(prop.value, scope) ?? easeEach;
|
|
}
|
|
}
|
|
|
|
keyframes.sort((a, b) => a.percentage - b.percentage);
|
|
|
|
return {
|
|
format: "percentage",
|
|
keyframes,
|
|
...(ease ? { ease } : {}),
|
|
...(easeEach ? { easeEach } : {}),
|
|
};
|
|
}
|
|
|
|
function computeKeyframesTotalDuration(
|
|
varsNode: AstNode,
|
|
scope: ScopeBindings,
|
|
): number | undefined {
|
|
const kfNode = (varsNode.properties ?? []).find(
|
|
(p: AstNode) => (p.key?.name ?? p.key?.value) === "keyframes",
|
|
)?.value;
|
|
if (!kfNode || kfNode.type !== "ArrayExpression") return undefined;
|
|
let total = 0;
|
|
for (const el of kfNode.elements ?? []) {
|
|
if (!el || el.type !== "ObjectExpression") continue;
|
|
const r = objectExpressionToRecord(el, scope);
|
|
if (typeof r.duration === "number") total += r.duration;
|
|
}
|
|
return total > 0 ? total : undefined;
|
|
}
|
|
|
|
// fallow-ignore-next-line complexity
|
|
function parseObjectArrayKeyframes(node: AstNode, scope: ScopeBindings): GsapKeyframesData {
|
|
const elements = node.elements ?? [];
|
|
const raw: Array<{
|
|
properties: Record<string, number | string>;
|
|
duration?: number;
|
|
ease?: string;
|
|
}> = [];
|
|
|
|
for (const el of elements) {
|
|
if (!el || (el.type !== "ObjectExpression" && el.type !== "ObjectProperty")) {
|
|
// Skip non-object elements
|
|
if (el?.type !== "ObjectExpression") continue;
|
|
}
|
|
const record = objectExpressionToRecord(el, scope);
|
|
const properties: Record<string, number | string> = {};
|
|
let duration: number | undefined;
|
|
let ease: string | undefined;
|
|
for (const [k, v] of Object.entries(record)) {
|
|
if (k === "duration" && typeof v === "number") {
|
|
duration = v;
|
|
} else if (k === "ease" && typeof v === "string") {
|
|
ease = v;
|
|
} else if (typeof v === "number" || typeof v === "string") {
|
|
properties[k] = v;
|
|
}
|
|
}
|
|
raw.push({ properties, duration, ease });
|
|
}
|
|
|
|
// Convert durations to percentage positions. If durations are present, use
|
|
// cumulative ratios; otherwise distribute evenly.
|
|
const totalDuration = raw.reduce((sum, r) => sum + (r.duration ?? 0), 0);
|
|
const keyframes: GsapPercentageKeyframe[] = [];
|
|
|
|
if (totalDuration > 0) {
|
|
let cumulative = 0;
|
|
for (const entry of raw) {
|
|
cumulative += entry.duration ?? 0;
|
|
const percentage = Math.round((cumulative / totalDuration) * 100);
|
|
keyframes.push({
|
|
percentage,
|
|
properties: entry.properties,
|
|
...(entry.ease ? { ease: entry.ease } : {}),
|
|
});
|
|
}
|
|
} else {
|
|
for (let i = 0; i < raw.length; i++) {
|
|
const entry = raw[i]!;
|
|
const percentage = raw.length > 1 ? Math.round((i / (raw.length - 1)) * 100) : 0;
|
|
keyframes.push({
|
|
percentage,
|
|
properties: entry.properties,
|
|
...(entry.ease ? { ease: entry.ease } : {}),
|
|
});
|
|
}
|
|
}
|
|
|
|
return { format: "object-array", keyframes };
|
|
}
|
|
|
|
// fallow-ignore-next-line complexity
|
|
function parseSimpleArrayKeyframes(node: AstNode, scope: ScopeBindings): GsapKeyframesData {
|
|
const arrayProps: Map<string, (number | string)[]> = new Map();
|
|
let ease: string | undefined;
|
|
let easeEach: string | undefined;
|
|
|
|
for (const prop of node.properties ?? []) {
|
|
if (prop.type !== "ObjectProperty" && prop.type !== "Property") continue;
|
|
const key = prop.key?.name ?? prop.key?.value;
|
|
if (typeof key !== "string") continue;
|
|
|
|
if (prop.value?.type === "ArrayExpression") {
|
|
const values: (number | string)[] = [];
|
|
for (const el of prop.value.elements ?? []) {
|
|
const val = resolveNode(el, scope);
|
|
if (typeof val === "number" || typeof val === "string") {
|
|
values.push(val);
|
|
}
|
|
}
|
|
if (values.length > 0) arrayProps.set(key, values);
|
|
} else if (key === "ease") {
|
|
ease = tryResolveStringProp(prop.value, scope) ?? ease;
|
|
} else if (key === "easeEach") {
|
|
easeEach = tryResolveStringProp(prop.value, scope) ?? easeEach;
|
|
}
|
|
}
|
|
|
|
// Zip arrays into percentage keyframes (evenly spaced).
|
|
const maxLen = Math.max(...[...arrayProps.values()].map((a) => a.length), 0);
|
|
const keyframes: GsapPercentageKeyframe[] = [];
|
|
|
|
for (let i = 0; i < maxLen; i++) {
|
|
const percentage = maxLen > 1 ? Math.round((i / (maxLen - 1)) * 100) : 0;
|
|
const properties: Record<string, number | string> = {};
|
|
for (const [key, values] of arrayProps) {
|
|
if (i < values.length) properties[key] = values[i]!;
|
|
}
|
|
keyframes.push({ percentage, properties });
|
|
}
|
|
|
|
return {
|
|
format: "simple-array",
|
|
keyframes,
|
|
...(ease ? { ease } : {}),
|
|
...(easeEach ? { easeEach } : {}),
|
|
};
|
|
}
|
|
|
|
// ── MotionPath Parsing ────────────────────────────────────────────────────
|
|
|
|
interface MotionPathParseResult {
|
|
arcPath: ArcPathConfig;
|
|
waypoints: Array<{ x: number; y: number }>;
|
|
}
|
|
|
|
function parseMotionPathNode(
|
|
node: AstNode | undefined,
|
|
scope: ScopeBindings,
|
|
): MotionPathParseResult | undefined {
|
|
if (!node) return undefined;
|
|
|
|
let pathNode: AstNode | undefined;
|
|
let autoRotate: boolean | number = false;
|
|
let curviness = 1;
|
|
let isCubic = false;
|
|
|
|
if (node.type === "ObjectExpression") {
|
|
for (const prop of node.properties ?? []) {
|
|
if (!isObjectProperty(prop)) continue;
|
|
const key = propKeyName(prop);
|
|
if (key === "path") pathNode = prop.value;
|
|
else if (key === "autoRotate") {
|
|
const val = resolveNode(prop.value, scope);
|
|
autoRotate = typeof val === "number" ? val : val === true;
|
|
} else if (key === "curviness") {
|
|
const val = resolveNode(prop.value, scope);
|
|
if (typeof val === "number") curviness = val;
|
|
} else if (key === "type") {
|
|
const val = resolveNode(prop.value, scope);
|
|
if (val === "cubic") isCubic = true;
|
|
}
|
|
}
|
|
} else if (node.type === "ArrayExpression") {
|
|
pathNode = node;
|
|
}
|
|
|
|
if (!pathNode || pathNode.type !== "ArrayExpression") return undefined;
|
|
|
|
const elements = pathNode.elements ?? [];
|
|
const coords: Array<{ x: number; y: number }> = [];
|
|
for (const elem of elements) {
|
|
if (!elem || elem.type !== "ObjectExpression") continue;
|
|
const rec = objectExpressionToRecord(elem, scope);
|
|
const x = typeof rec.x === "number" ? rec.x : undefined;
|
|
const y = typeof rec.y === "number" ? rec.y : undefined;
|
|
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) {
|
|
// type: "cubic" — coords are [anchor, cp1, cp2, anchor, cp1, cp2, anchor, ...]
|
|
// Every 3rd coord starting from 0 is an anchor, the two between are control points.
|
|
waypoints = [];
|
|
waypoints.push(coords[0]!);
|
|
for (let i = 1; i + 2 < coords.length; i += 3) {
|
|
const cp1 = coords[i]!;
|
|
const cp2 = coords[i + 1]!;
|
|
const anchor = coords[i + 2]!;
|
|
waypoints.push(anchor);
|
|
segments.push({ curviness, cp1, cp2 });
|
|
}
|
|
} else {
|
|
// Waypoint array with global curviness
|
|
waypoints = coords;
|
|
for (let i = 0; i < waypoints.length - 1; i++) {
|
|
segments.push({ curviness });
|
|
}
|
|
}
|
|
|
|
return {
|
|
arcPath: { enabled: true, autoRotate, segments },
|
|
waypoints,
|
|
};
|
|
}
|
|
|
|
// fallow-ignore-next-line complexity
|
|
function tweenCallToAnimation(
|
|
call: TweenCallInfo,
|
|
scope: ScopeBindings,
|
|
): Omit<GsapAnimation, "id"> {
|
|
const vars = objectExpressionToRecord(call.varsArg, scope);
|
|
const properties: Record<string, number | string> = {};
|
|
const extras: Record<string, unknown> = {};
|
|
let keyframesData: GsapKeyframesData | undefined;
|
|
let hasUnresolvedKeyframes = false;
|
|
let motionPathResult: MotionPathParseResult | undefined;
|
|
|
|
for (const [key, val] of Object.entries(vars)) {
|
|
if (BUILTIN_VAR_KEYS.has(key)) continue;
|
|
if (DROPPED_VAR_KEYS.has(key)) continue;
|
|
|
|
if (key === "keyframes") {
|
|
const kfNode = findPropertyNode(call.varsArg, "keyframes");
|
|
keyframesData = parseKeyframesNode(kfNode, scope);
|
|
if (!keyframesData && kfNode) hasUnresolvedKeyframes = true;
|
|
continue;
|
|
}
|
|
|
|
if (key === "motionPath") {
|
|
const mpNode = findPropertyNode(call.varsArg, "motionPath");
|
|
motionPathResult = parseMotionPathNode(mpNode, scope);
|
|
continue;
|
|
}
|
|
|
|
if (key === "easeEach") {
|
|
// easeEach is only meaningful alongside keyframes — handled below.
|
|
continue;
|
|
}
|
|
|
|
if (EXTRAS_KEYS.has(key)) {
|
|
// For extras, prefer the raw AST source so complex objects like
|
|
// `stagger: { each: 0.15, from: "start" }` survive verbatim.
|
|
const rawSource = extractRawPropertySource(call.varsArg, key);
|
|
if (rawSource !== undefined) {
|
|
extras[key] = `__raw:${rawSource}`;
|
|
} else if (val !== undefined) {
|
|
extras[key] = val;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (typeof val === "number" || typeof val === "string") {
|
|
properties[key] = val;
|
|
}
|
|
}
|
|
|
|
// Apply tween-level easeEach to keyframes data.
|
|
if (keyframesData && typeof vars.easeEach === "string") {
|
|
keyframesData.easeEach = vars.easeEach as string;
|
|
}
|
|
|
|
// When motionPath is present, reconstruct x/y as keyframe waypoints.
|
|
if (motionPathResult) {
|
|
const { waypoints } = motionPathResult;
|
|
if (!keyframesData) {
|
|
// No explicit keyframes — create synthetic percentage keyframes from waypoints.
|
|
const kf: GsapPercentageKeyframe[] = waypoints.map((wp, i) => ({
|
|
percentage: waypoints.length > 1 ? Math.round((i / (waypoints.length - 1)) * 100) : 0,
|
|
properties: { x: wp.x, y: wp.y },
|
|
}));
|
|
keyframesData = { format: "percentage", keyframes: kf };
|
|
} else {
|
|
// Merge waypoint positions into existing keyframes at matching percentages.
|
|
// If keyframe count matches waypoint count, assign positionally.
|
|
const kfs = keyframesData.keyframes;
|
|
if (kfs.length === waypoints.length) {
|
|
for (let i = 0; i < kfs.length; i++) {
|
|
kfs[i]!.properties.x = waypoints[i]!.x;
|
|
kfs[i]!.properties.y = waypoints[i]!.y;
|
|
}
|
|
}
|
|
}
|
|
// arcPath is attached below on the animation result.
|
|
}
|
|
|
|
let fromProperties: Record<string, number | string> | undefined;
|
|
if (call.method === "fromTo" && call.fromArg) {
|
|
fromProperties = {};
|
|
const fromVars = objectExpressionToRecord(call.fromArg, scope);
|
|
for (const [key, val] of Object.entries(fromVars)) {
|
|
if (typeof val === "number" || typeof val === "string") {
|
|
fromProperties[key] = val;
|
|
}
|
|
}
|
|
}
|
|
|
|
const hasPositionArg = !!call.positionArg;
|
|
const posVal = call.positionArg ? extractLiteralValue(call.positionArg, scope) : 0;
|
|
const position: number | string =
|
|
typeof posVal === "number" ? posVal : typeof posVal === "string" ? posVal : 0;
|
|
let duration = typeof vars.duration === "number" ? vars.duration : undefined;
|
|
const ease = typeof vars.ease === "string" ? vars.ease : undefined;
|
|
|
|
if (duration === undefined && keyframesData) {
|
|
duration = computeKeyframesTotalDuration(call.varsArg, scope);
|
|
}
|
|
|
|
const anim: Omit<GsapAnimation, "id"> = {
|
|
targetSelector: call.selector,
|
|
method: call.method,
|
|
position,
|
|
properties,
|
|
fromProperties,
|
|
duration,
|
|
ease,
|
|
};
|
|
if (!hasPositionArg) anim.implicitPosition = true;
|
|
let group = classifyTweenPropertyGroup(properties);
|
|
if (!group && keyframesData) {
|
|
const kfProps: Record<string, unknown> = {};
|
|
for (const kf of keyframesData.keyframes) {
|
|
for (const k of Object.keys(kf.properties)) kfProps[k] = true;
|
|
}
|
|
group = classifyTweenPropertyGroup(kfProps);
|
|
}
|
|
if (group) anim.propertyGroup = group;
|
|
if (call.global) anim.global = true;
|
|
if (Object.keys(extras).length > 0) anim.extras = extras;
|
|
if (keyframesData) anim.keyframes = keyframesData;
|
|
if (motionPathResult) anim.arcPath = motionPathResult.arcPath;
|
|
if (hasUnresolvedKeyframes) anim.hasUnresolvedKeyframes = true;
|
|
if (call.selector === "__unresolved__") anim.hasUnresolvedSelector = true;
|
|
return anim;
|
|
}
|
|
|
|
// ── Timeline Position Resolution ──────────────────────────────────────────
|
|
|
|
const GSAP_DEFAULT_DURATION = 0.5;
|
|
|
|
// NOTE: Label-based positions (e.g. "myLabel+=0.5") are not yet resolved —
|
|
// they fall through to parseFloat which returns null for non-numeric strings.
|
|
function resolvePositionString(pos: string, cursor: number, prevStart: number): number | null {
|
|
const trimmed = pos.trim();
|
|
if (trimmed === "") return cursor;
|
|
if (trimmed.startsWith("+=")) {
|
|
const n = Number.parseFloat(trimmed.slice(2));
|
|
return Number.isFinite(n) ? cursor + n : null;
|
|
}
|
|
if (trimmed.startsWith("-=")) {
|
|
const n = Number.parseFloat(trimmed.slice(2));
|
|
return Number.isFinite(n) ? cursor - n : null;
|
|
}
|
|
if (trimmed === "<") return prevStart;
|
|
if (trimmed === ">") return cursor;
|
|
if (trimmed.startsWith("<")) {
|
|
const n = Number.parseFloat(trimmed.slice(1));
|
|
return Number.isFinite(n) ? prevStart + n : null;
|
|
}
|
|
if (trimmed.startsWith(">")) {
|
|
const n = Number.parseFloat(trimmed.slice(1));
|
|
return Number.isFinite(n) ? cursor + n : null;
|
|
}
|
|
const n = Number.parseFloat(trimmed);
|
|
return Number.isFinite(n) ? n : null;
|
|
}
|
|
|
|
function applyTimelineDefaults(
|
|
anims: Omit<GsapAnimation, "id">[],
|
|
defaults?: TimelineDefaults,
|
|
): void {
|
|
if (!defaults) return;
|
|
for (const anim of anims) {
|
|
if (anim.method === "set") continue;
|
|
if (anim.duration === undefined && defaults.duration !== undefined) {
|
|
anim.duration = defaults.duration;
|
|
}
|
|
if (anim.ease === undefined && defaults.ease !== undefined) {
|
|
anim.ease = defaults.ease;
|
|
}
|
|
}
|
|
}
|
|
|
|
function resolveTimelinePositions(anims: Omit<GsapAnimation, "id">[]): void {
|
|
let cursor = 0;
|
|
let prevStart = 0;
|
|
for (const anim of anims) {
|
|
// A global `gsap.set(...)` is off-timeline — it's applied once at load, not
|
|
// sequenced on the master timeline. It carries no position arg, so the
|
|
// cursor-based fallback below would otherwise hand it the comp-end time
|
|
// (every prior tween's duration summed). Pin it to 0 (its load-time start)
|
|
// and don't let it advance the cursor/prevStart for following tweens.
|
|
if (anim.method === "set" && anim.global) {
|
|
anim.resolvedStart = 0;
|
|
continue;
|
|
}
|
|
const duration = anim.method === "set" ? 0 : (anim.duration ?? GSAP_DEFAULT_DURATION);
|
|
let start: number | null;
|
|
|
|
if (anim.implicitPosition) {
|
|
start = cursor;
|
|
} else if (typeof anim.position === "number") {
|
|
start = anim.position;
|
|
} else if (typeof anim.position === "string") {
|
|
start = resolvePositionString(anim.position, cursor, prevStart);
|
|
} else {
|
|
start = cursor;
|
|
}
|
|
|
|
if (start != null) {
|
|
anim.resolvedStart = Math.max(0, start);
|
|
prevStart = anim.resolvedStart;
|
|
cursor = Math.max(cursor, anim.resolvedStart + duration);
|
|
}
|
|
}
|
|
}
|
|
|
|
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;
|
|
});
|
|
}
|
|
|
|
// ── Stable ID Generation ───────────────────────────────────────────────────
|
|
|
|
/**
|
|
* IDs are transient — recomputed on every parse, never persisted across sessions.
|
|
* They exist only in ephemeral request/response payloads, React component state,
|
|
* and the in-memory keyframe cache (rebuilt on every page load). No database,
|
|
* localStorage, or file stores animation IDs, so changing the ID format (e.g.
|
|
* adding a `-scale`/`-position` suffix) is safe.
|
|
*/
|
|
function assignStableIds(anims: Omit<GsapAnimation, "id">[]): GsapAnimation[] {
|
|
const counts = new Map<string, number>();
|
|
return anims.map((anim) => {
|
|
const posKey =
|
|
typeof anim.position === "number"
|
|
? String(Math.round(anim.position * 1000))
|
|
: String(anim.position);
|
|
const groupSuffix = anim.propertyGroup ? `-${anim.propertyGroup}` : "";
|
|
const base = `${anim.targetSelector}-${anim.method}-${posKey}${groupSuffix}`;
|
|
const count = (counts.get(base) ?? 0) + 1;
|
|
counts.set(base, count);
|
|
const id = count === 1 ? base : `${base}-${count}`;
|
|
return { ...anim, id };
|
|
});
|
|
}
|
|
|
|
// ── Shared parse (AST + located tween calls) ────────────────────────────────
|
|
|
|
interface ParsedGsapAst {
|
|
ast: AstNode;
|
|
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, scope);
|
|
const ref: TimelineRef = detection.ref ?? { kind: "identifier", name: "tl" };
|
|
const timelineVar = timelineRootSource(ref);
|
|
const calls = findAllTweenCalls(ast, ref, scope, targetBindings);
|
|
sortBySourcePosition(calls);
|
|
const rawAnims = calls.map((call) => tweenCallToAnimation(call, scope));
|
|
applyTimelineDefaults(rawAnims, detection.defaults);
|
|
resolveTimelinePositions(rawAnims);
|
|
const animations = assignStableIds(rawAnims);
|
|
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 { detection, timelineVar, located } = parseGsapAst(script);
|
|
const ref: TimelineRef = detection.ref ?? { kind: "identifier", name: "tl" };
|
|
const animations = located.map((l) => l.animation);
|
|
|
|
const declPattern =
|
|
ref.kind === "identifier"
|
|
? `(?:const|let|var)\\s+${timelineVar}\\s*=\\s*gsap\\.timeline\\s*\\([^)]*\\)\\s*;?`
|
|
: `${escapeRegExp(timelineVar)}\\s*=\\s*gsap\\.timeline\\s*\\([^)]*\\)\\s*;?`;
|
|
const timelineMatch = script.match(new RegExp(`^[\\s\\S]*?${declPattern}`));
|
|
const fallbackPreamble =
|
|
ref.kind === "identifier"
|
|
? `const ${timelineVar} = gsap.timeline({ paused: true });`
|
|
: `${timelineVar} = gsap.timeline({ paused: true });`;
|
|
const preamble = timelineMatch?.[0] ?? fallbackPreamble;
|
|
|
|
const lastCallIdx = script.lastIndexOf(`${timelineVar}.`);
|
|
let postamble = "";
|
|
if (lastCallIdx !== -1) {
|
|
const afterLast = script.slice(lastCallIdx);
|
|
const endOfCall = afterLast.indexOf(";");
|
|
if (endOfCall !== -1) {
|
|
postamble = script.slice(lastCallIdx + endOfCall + 1).trim();
|
|
}
|
|
}
|
|
|
|
const result: ParsedGsap = { animations, timelineVar, preamble, postamble };
|
|
if (detection.timelineCount > 1) result.multipleTimelines = true;
|
|
if (detection.timelineCount > 0 && detection.ref === null)
|
|
result.unsupportedTimelinePattern = true;
|
|
return result;
|
|
} catch {
|
|
return { animations: [], timelineVar: "tl", preamble: "", postamble: "" };
|
|
}
|
|
}
|
|
|
|
// ── 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.
|
|
|
|
/**
|
|
* 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): AstNode {
|
|
return parseScript(`__hf__ = ${code};`).program.body[0].expression.right;
|
|
}
|
|
|
|
function propKeyName(prop: AstNode): string | undefined {
|
|
return prop?.key?.name ?? prop?.key?.value;
|
|
}
|
|
|
|
function isObjectProperty(prop: AstNode): 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 | boolean): AstNode {
|
|
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: AstNode, key: string, value: number | string | boolean): void {
|
|
if (varsArg?.type !== "ObjectExpression") return;
|
|
const existing = varsArg.properties.find(
|
|
(p: AstNode) => isObjectProperty(p) && propKeyName(p) === key,
|
|
);
|
|
if (existing) {
|
|
existing.value = parseExpr(valueToCode(value));
|
|
} else {
|
|
varsArg.properties.push(makeObjectProperty(key, value));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Filter an ObjectExpression's properties, keeping non-editable keys
|
|
* and delegating the keep/drop decision for editable keys to `shouldKeep`.
|
|
*/
|
|
function filterEditableKeys(varsArg: AstNode, shouldKeep: (key: string) => boolean): void {
|
|
if (varsArg?.type !== "ObjectExpression") return;
|
|
varsArg.properties = varsArg.properties.filter((p: AstNode) => {
|
|
if (!isObjectProperty(p)) return true;
|
|
const key = propKeyName(p);
|
|
if (typeof key !== "string") return true;
|
|
if (!isEditablePropertyKey(key)) return true;
|
|
return shouldKeep(key);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Replace the editable-property keys on an ObjectExpression with `newProps`,
|
|
* leaving `duration`, `ease`, `stagger`, callbacks and other non-editable keys
|
|
* untouched.
|
|
*/
|
|
function reconcileEditableProperties(
|
|
varsArg: AstNode,
|
|
newProps: Record<string, number | string>,
|
|
): void {
|
|
filterEditableKeys(varsArg, (key) => 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 applyEaseUpdate(varsArg: AstNode, ease: string): void {
|
|
const kfNode = findKeyframesObjectNode(varsArg);
|
|
if (kfNode) {
|
|
setVarsKey(kfNode, "easeEach", ease);
|
|
removeVarsKey(varsArg, "ease");
|
|
} else {
|
|
setVarsKey(varsArg, "ease", ease);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* "Apply to all segments": drop every per-keyframe `ease` override so the single
|
|
* `easeEach` governs all segments uniformly (AE select-all + F9). Mirrors the
|
|
* acorn writer's resetKeyframeEases branch.
|
|
*/
|
|
function stripKeyframeEases(varsArg: AstNode): void {
|
|
const kfNode = findKeyframesObjectNode(varsArg);
|
|
const props = kfNode?.properties;
|
|
if (!Array.isArray(props)) return;
|
|
for (const entry of props) {
|
|
if (isObjectProperty(entry)) removeVarsKey(entry.value, "ease");
|
|
}
|
|
}
|
|
|
|
function applyUpdatesToCall(
|
|
call: TweenCallInfo,
|
|
updates: Partial<GsapAnimation> & { easeEach?: string; resetKeyframeEases?: boolean },
|
|
): void {
|
|
if (updates.properties) reconcileEditableProperties(call.varsArg, updates.properties);
|
|
if (updates.fromProperties && call.method === "fromTo" && call.fromArg) {
|
|
reconcileEditableProperties(call.fromArg, updates.fromProperties);
|
|
}
|
|
if (updates.duration !== undefined) setVarsKey(call.varsArg, "duration", updates.duration);
|
|
if (updates.easeEach !== undefined) applyEaseUpdate(call.varsArg, updates.easeEach);
|
|
else if (updates.ease !== undefined) applyEaseUpdate(call.varsArg, updates.ease);
|
|
if (updates.resetKeyframeEases) stripKeyframeEases(call.varsArg);
|
|
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: AstPath): AstPath | null {
|
|
let p = path;
|
|
while (p) {
|
|
if (p.node?.type === "ExpressionStatement") return p;
|
|
p = p.parentPath;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function insertAfterAnchor(parsed: ParsedGsapAst, newStatement: AstNode): void {
|
|
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);
|
|
}
|
|
}
|
|
|
|
/** 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.method !== "set" && 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)}`);
|
|
// immediateRender forces GSAP to apply the set when added to the timeline,
|
|
// not on the first seek — without it, tl.set at position 0 on a paused
|
|
// timeline is invisible until the playhead moves past 0. A base `gsap.set`
|
|
// already runs immediately, so it doesn't need (or get) the flag.
|
|
if (anim.method === "set" && !anim.global) entries.push("immediateRender: true");
|
|
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});`;
|
|
}
|
|
// A base `gsap.set` is off the timeline: no timeline var, no position arg.
|
|
if (anim.method === "set" && anim.global) {
|
|
return `gsap.set(${selector}, ${objCode});`;
|
|
}
|
|
return `${timelineVar}.${anim.method}(${selector}, ${objCode}, ${posCode});`;
|
|
}
|
|
|
|
export function updateAnimationInScript(
|
|
script: string,
|
|
animationId: string,
|
|
updates: Partial<GsapAnimation> & { easeEach?: string; resetKeyframeEases?: boolean },
|
|
): string {
|
|
let parsed: ParsedGsapAst;
|
|
try {
|
|
parsed = parseGsapAst(script);
|
|
} catch (e) {
|
|
console.warn("[gsap-parser] updateAnimationInScript parse failed:", e);
|
|
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 shiftPositionsInScript(
|
|
script: string,
|
|
targetSelector: string,
|
|
delta: number,
|
|
): string {
|
|
let parsed: ParsedGsapAst;
|
|
try {
|
|
parsed = parseGsapAst(script);
|
|
} catch (e) {
|
|
console.warn("[gsap-parser] shiftPositionsInScript parse failed:", e);
|
|
return script;
|
|
}
|
|
let changed = false;
|
|
for (const entry of parsed.located) {
|
|
if (entry.animation.targetSelector !== targetSelector) continue;
|
|
if (typeof entry.animation.position !== "number") continue;
|
|
const newPos = Math.max(0, Math.round((entry.animation.position + delta) * 1000) / 1000);
|
|
applyUpdatesToCall(entry.call, { position: newPos });
|
|
changed = true;
|
|
}
|
|
return changed ? recast.print(parsed.ast).code : script;
|
|
}
|
|
|
|
export function scalePositionsInScript(
|
|
script: string,
|
|
targetSelector: string,
|
|
oldStart: number,
|
|
oldDuration: number,
|
|
newStart: number,
|
|
newDuration: number,
|
|
): string {
|
|
if (oldDuration <= 0 || newDuration <= 0) return script;
|
|
const ratio = newDuration / oldDuration;
|
|
let parsed: ParsedGsapAst;
|
|
try {
|
|
parsed = parseGsapAst(script);
|
|
} catch (e) {
|
|
console.warn("[gsap-parser] scalePositionsInScript parse failed:", e);
|
|
return script;
|
|
}
|
|
let changed = false;
|
|
for (const entry of parsed.located) {
|
|
if (entry.animation.targetSelector !== targetSelector) continue;
|
|
if (typeof entry.animation.position !== "number") continue;
|
|
const newPos = Math.max(
|
|
0,
|
|
Math.round((newStart + (entry.animation.position - oldStart) * ratio) * 1000) / 1000,
|
|
);
|
|
const updates: Partial<GsapAnimation> = { position: newPos };
|
|
if (typeof entry.animation.duration === "number" && entry.animation.duration > 0) {
|
|
updates.duration = Math.max(
|
|
0.001,
|
|
Math.round(entry.animation.duration * ratio * 1000) / 1000,
|
|
);
|
|
}
|
|
applyUpdatesToCall(entry.call, updates);
|
|
changed = true;
|
|
}
|
|
return changed ? recast.print(parsed.ast).code : script;
|
|
}
|
|
|
|
function updateAnimationSelector(script: string, animationId: string, newSelector: string): string {
|
|
let parsed: ParsedGsapAst;
|
|
try {
|
|
parsed = parseGsapAst(script);
|
|
} catch {
|
|
return script;
|
|
}
|
|
const target = parsed.located.find((l) => l.id === animationId);
|
|
if (!target) return script;
|
|
const selectorArg = target.call.path.node.arguments?.[0];
|
|
if (selectorArg?.type === "StringLiteral") {
|
|
selectorArg.value = newSelector;
|
|
} else if (selectorArg?.type === "Identifier") {
|
|
target.call.path.node.arguments[0] = { type: "StringLiteral", value: newSelector };
|
|
}
|
|
return recast.print(parsed.ast).code;
|
|
}
|
|
|
|
export function addAnimationToScript(
|
|
script: string,
|
|
animation: Omit<GsapAnimation, "id">,
|
|
): { script: string; id: string } {
|
|
let parsed: ParsedGsapAst;
|
|
try {
|
|
parsed = parseGsapAst(script);
|
|
} catch (e) {
|
|
console.warn("[gsap-parser] addAnimationToScript parse failed:", e);
|
|
return { script, id: "" };
|
|
}
|
|
// Nothing to anchor against and no timeline to target — treat as parse failure.
|
|
if (parsed.located.length === 0 && parsed.detection.ref === null) {
|
|
return { script, id: "" };
|
|
}
|
|
|
|
const id = `anim-${Date.now()}`;
|
|
const statementCode = buildTweenStatementCode(parsed.timelineVar, animation);
|
|
const newStatement = parseScript(statementCode).program.body[0];
|
|
insertAfterAnchor(parsed, newStatement);
|
|
return { script: recast.print(parsed.ast).code, id };
|
|
}
|
|
|
|
export function addAnimationWithKeyframesToScript(
|
|
script: string,
|
|
targetSelector: string,
|
|
position: number,
|
|
duration: number,
|
|
keyframes: Array<{
|
|
percentage: number;
|
|
properties: Record<string, number | string>;
|
|
ease?: string;
|
|
auto?: boolean;
|
|
}>,
|
|
ease?: string,
|
|
easeEach?: string,
|
|
): { script: string; id: string } {
|
|
let parsed: ParsedGsapAst;
|
|
try {
|
|
parsed = parseGsapAst(script);
|
|
} catch (e) {
|
|
console.warn("[gsap-parser] addAnimationWithKeyframesToScript parse failed:", e);
|
|
return { script, id: "" };
|
|
}
|
|
if (parsed.located.length === 0 && parsed.detection.ref === null) {
|
|
return { script, id: "" };
|
|
}
|
|
|
|
const selector = JSON.stringify(targetSelector);
|
|
const kfCode = buildKeyframeObjectCode(keyframes, easeEach ? { easeEach } : undefined);
|
|
const varEntries = [`keyframes: ${kfCode}`, `duration: ${valueToCode(duration)}`];
|
|
if (ease) varEntries.push(`ease: ${JSON.stringify(ease)}`);
|
|
const posCode = valueToCode(position);
|
|
const stmtCode = `${parsed.timelineVar}.to(${selector}, { ${varEntries.join(", ")} }, ${posCode});`;
|
|
|
|
const newStatement = parseScript(stmtCode).program.body[0];
|
|
insertAfterAnchor(parsed, newStatement);
|
|
|
|
const result = recast.print(parsed.ast).code;
|
|
const reParsed = parseGsapAst(result);
|
|
const newId = reParsed.located[reParsed.located.length - 1]?.id ?? "";
|
|
return { script: result, id: newId };
|
|
}
|
|
|
|
/** Find the statement path of `const <timelineVar> = gsap.timeline(...)`. */
|
|
function findTimelineDeclarationPath(ast: AstNode, timelineVar: string): AstPath | null {
|
|
let found: AstPath | null = null;
|
|
recast.types.visit(ast, {
|
|
visitVariableDeclaration(path: AstPath) {
|
|
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;
|
|
}
|
|
|
|
/** Find the call that chains off `targetNode` (i.e. whose callee object IS it). */
|
|
function findChainParentCall(stmtNode: AstNode, targetNode: AstNode): AstNode | null {
|
|
let found: AstNode | null = null;
|
|
recast.types.visit(stmtNode, {
|
|
visitCallExpression(p: AstPath) {
|
|
if (found) return false;
|
|
if (p.node.callee?.type === "MemberExpression" && p.node.callee.object === targetNode) {
|
|
found = p.node;
|
|
return false;
|
|
}
|
|
this.traverse(p);
|
|
},
|
|
});
|
|
return found;
|
|
}
|
|
|
|
export function removeAnimationFromScript(script: string, animationId: string): string {
|
|
let parsed: ParsedGsapAst;
|
|
try {
|
|
parsed = parseGsapAst(script);
|
|
} catch (e) {
|
|
console.warn("[gsap-parser] removeAnimationFromScript parse failed:", e);
|
|
return script;
|
|
}
|
|
let target = parsed.located.find((l) => l.id === animationId);
|
|
if (!target) {
|
|
const convertedId = animationId.replace(/-from-|-fromTo-/, "-to-");
|
|
target = parsed.located.find((l) => l.id === convertedId);
|
|
}
|
|
if (!target) return script;
|
|
removeCallFromAst(target.call);
|
|
return recast.print(parsed.ast).code;
|
|
}
|
|
|
|
/** Remove a single located tween call from the AST (standalone stmt or chain link). */
|
|
function removeCallFromAst(call: TweenCallInfo): void {
|
|
const node = call.node;
|
|
const stmtPath = findStatementPath(call.path);
|
|
if (!stmtPath) return;
|
|
const parentCall = findChainParentCall(stmtPath.node, node);
|
|
if (parentCall) {
|
|
// Inner link of a chain — splice it out by re-pointing the next link.
|
|
parentCall.callee.object = node.callee.object;
|
|
} else if (node.callee?.object?.type === "CallExpression") {
|
|
// Outermost link of a chain with earlier links — drop just this link.
|
|
stmtPath.node.expression = node.callee.object;
|
|
} else {
|
|
// Standalone tween — remove the whole statement.
|
|
stmtPath.prune();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Recast twin of {@link dedupePositionWritesInScript} (acorn). Enforce "exactly
|
|
* one position write per element": keep `keepId` (or the LAST position write in
|
|
* source order if stale), remove every OTHER pure-position write
|
|
* (`propertyGroup === "position"` — tl.to/from/fromTo flat-or-keyframed, tl.set,
|
|
* standalone gsap.set, incl. degenerate duration:0 tweens). Non-position writes
|
|
* for the selector are left untouched.
|
|
*/
|
|
export function dedupePositionWritesInScript(
|
|
script: string,
|
|
selector: string,
|
|
keepId?: string,
|
|
): string {
|
|
let parsed: ParsedGsapAst;
|
|
try {
|
|
parsed = parseGsapAst(script);
|
|
} catch {
|
|
return script;
|
|
}
|
|
const posWrites = parsed.located.filter(
|
|
(l) => l.animation.targetSelector === selector && l.animation.propertyGroup === "position",
|
|
);
|
|
if (posWrites.length <= 1) return script;
|
|
const keeper = posWrites.find((l) => l.id === keepId) ?? posWrites[posWrites.length - 1]!;
|
|
for (const l of posWrites) {
|
|
if (l === keeper) continue;
|
|
removeCallFromAst(l.call);
|
|
}
|
|
return recast.print(parsed.ast).code;
|
|
}
|
|
|
|
function insertInheritedStateSet(
|
|
script: string,
|
|
selector: string,
|
|
position: number,
|
|
properties: Record<string, number | string>,
|
|
): string {
|
|
let parsed: ParsedGsapAst;
|
|
try {
|
|
parsed = parseGsapAst(script);
|
|
} catch {
|
|
return script;
|
|
}
|
|
const tlVar = parsed.timelineVar;
|
|
const props = Object.entries(properties)
|
|
.map(([k, v]) => `${k}: ${typeof v === "string" ? JSON.stringify(v) : v}`)
|
|
.join(", ");
|
|
const code = `${tlVar}.set(${JSON.stringify(selector)}, { ${props} }, ${position});`;
|
|
const newStatement = parseScript(code).program.body[0];
|
|
const anchor = findTimelineDeclarationPath(parsed.ast, tlVar);
|
|
if (anchor) {
|
|
anchor.insertAfter(newStatement);
|
|
} else if (parsed.located.length > 0) {
|
|
const firstTween = parsed.located[0]!.call;
|
|
const stmtPath = findStatementPath(firstTween.path);
|
|
if (stmtPath) stmtPath.insertBefore(newStatement);
|
|
else parsed.ast.program.body.unshift(newStatement);
|
|
} else {
|
|
parsed.ast.program.body.push(newStatement);
|
|
}
|
|
return recast.print(parsed.ast).code;
|
|
}
|
|
|
|
/** Marker on Studio-emitted pre-keyframe hold `set`s. `data` is a GSAP-reserved
|
|
* config key (attached to the tween, never applied to the target), so it carries
|
|
* the tag without triggering GSAP's "Invalid property" warning. */
|
|
const STUDIO_HOLD_MARKER = "hf-hold";
|
|
|
|
/** True for a `tl.set(...)` this module emitted to hold a keyframe before its tween.
|
|
* The Studio filters these out so they never appear as user keyframes/diamonds. */
|
|
export function isStudioHoldSet(anim: GsapAnimation): boolean {
|
|
return anim.method === "set" && anim.properties?.data === STUDIO_HOLD_MARKER;
|
|
}
|
|
|
|
/**
|
|
* Keep a `tl.set(selector, {x,y}, 0)` "hold" in front of every position-keyframed
|
|
* tween that starts after t=0, so the element holds its first keyframe's position
|
|
* BEFORE the tween plays instead of snapping to its CSS base (the universal NLE
|
|
* "hold before first keyframe" behavior). The set is tagged with `data: "hf-hold"`
|
|
* so this pass owns it: every call wipes the prior holds and recomputes from the
|
|
* current keyframes, keeping them in sync as keyframes are added/moved/deleted.
|
|
*
|
|
* Idempotent. Only position props (x/y/xPercent/yPercent) are held — opacity/scale
|
|
* keep their authored pre-tween behavior. A tween already starting at 0 needs no
|
|
* hold (no gap before it).
|
|
*/
|
|
export function syncPositionHoldsBeforeKeyframes(script: string): string {
|
|
let parsed: ParsedGsap;
|
|
try {
|
|
parsed = parseGsapScript(script);
|
|
} catch {
|
|
return script;
|
|
}
|
|
// 1. Drop every hold this pass previously emitted, so we recompute fresh.
|
|
let result = script;
|
|
const staleHoldIds = parsed.animations.filter(isStudioHoldSet).map((a) => a.id);
|
|
for (const id of staleHoldIds) result = removeAnimationFromScript(result, id);
|
|
|
|
// 2. Re-add a hold for each position-keyframed tween that starts after t=0.
|
|
let reparsed: ParsedGsap;
|
|
try {
|
|
reparsed = parseGsapScript(result);
|
|
} catch {
|
|
return result;
|
|
}
|
|
for (const anim of reparsed.animations) {
|
|
if (!anim.keyframes) continue;
|
|
const start = anim.resolvedStart ?? (typeof anim.position === "number" ? anim.position : 0);
|
|
if (!(start > 0.001)) continue;
|
|
const firstKf = [...anim.keyframes.keyframes].sort((a, b) => a.percentage - b.percentage)[0];
|
|
if (!firstKf) continue;
|
|
const posProps: Record<string, number | string> = {};
|
|
for (const [k, v] of Object.entries(firstKf.properties)) {
|
|
if (classifyPropertyGroup(k) === "position" && typeof v === "number") posProps[k] = v;
|
|
}
|
|
if (Object.keys(posProps).length === 0) continue;
|
|
result = insertInheritedStateSet(result, anim.targetSelector, 0, {
|
|
...posProps,
|
|
data: STUDIO_HOLD_MARKER,
|
|
});
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// ── Split Animation Functions ─────────────────────────────────────────────
|
|
|
|
export interface SplitAnimationsOptions {
|
|
originalId: string;
|
|
newId: string;
|
|
splitTime: number;
|
|
elementStart: number;
|
|
elementDuration: number;
|
|
}
|
|
|
|
export interface SplitAnimationsResult {
|
|
script: string;
|
|
/** Non-ID-selector animations that the engine cannot safely retarget. */
|
|
skippedSelectors: string[];
|
|
}
|
|
|
|
// fallow-ignore-next-line complexity
|
|
export function splitAnimationsInScript(
|
|
script: string,
|
|
opts: SplitAnimationsOptions,
|
|
): SplitAnimationsResult {
|
|
const parsed = parseGsapScript(script);
|
|
const originalSelector = `#${opts.originalId}`;
|
|
const newSelector = `#${opts.newId}`;
|
|
|
|
const skippedSelectors: string[] = [];
|
|
for (const a of parsed.animations) {
|
|
if (a.targetSelector !== originalSelector && a.targetSelector.includes(opts.originalId)) {
|
|
skippedSelectors.push(a.targetSelector);
|
|
}
|
|
}
|
|
|
|
const matching = parsed.animations.filter((a) => a.targetSelector === originalSelector);
|
|
if (matching.length === 0) return { script, skippedSelectors };
|
|
|
|
let result = script;
|
|
const newElementStart = opts.splitTime;
|
|
const inheritedProps: Record<string, number | string> = {};
|
|
|
|
// Reverse iteration: updateAnimationSelector mutates selectors in the source
|
|
// string, which can shift count-based ID suffixes (e.g. "#hero-1" → "#hero-2")
|
|
// for later animations. Processing last-to-first prevents stale ID collisions.
|
|
for (let i = matching.length - 1; i >= 0; i--) {
|
|
const anim = matching[i]!;
|
|
const pos = typeof anim.position === "number" ? anim.position : 0;
|
|
const dur = anim.duration ?? 0;
|
|
const animEnd = pos + dur;
|
|
|
|
if (anim.keyframes) {
|
|
if (pos >= opts.splitTime) {
|
|
result = updateAnimationSelector(result, anim.id, newSelector);
|
|
} else if (animEnd > opts.splitTime) {
|
|
// Spanning keyframes can't be correctly split without renormalizing
|
|
// percentages and durations — leave on original, warn the caller.
|
|
skippedSelectors.push(`${originalSelector} (keyframes spanning split)`);
|
|
const kfs = anim.keyframes.keyframes;
|
|
for (const kf of kfs) {
|
|
const kfTime = pos + (kf.percentage / 100) * dur;
|
|
if (kfTime <= opts.splitTime) {
|
|
for (const [k, v] of Object.entries(kf.properties)) {
|
|
inheritedProps[k] = v;
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
// Entirely before split — extract final keyframe properties
|
|
const kfs = anim.keyframes.keyframes;
|
|
if (kfs.length > 0) {
|
|
for (const [k, v] of Object.entries(kfs[kfs.length - 1]!.properties)) {
|
|
inheritedProps[k] = v;
|
|
}
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// `<=` (not `<`) is deliberate: a tween whose end coincides exactly with
|
|
// the split boundary has fully played by splitTime, so it belongs to the
|
|
// first half and contributes its resting state to the clone. The spanning
|
|
// branch below handles only strictly-mid-flight tweens (pos < split < end).
|
|
if (animEnd <= opts.splitTime) {
|
|
// Only a completed .from() reverts the element to its natural state, so
|
|
// its recorded properties are the HIDDEN start (e.g. opacity:0), not the
|
|
// resting state — clearing them keeps the clone at its natural value
|
|
// instead of pinning it to the from-values (which made it invisible).
|
|
// .fromTo() and .to() both END at their to-values (no revert), so they
|
|
// fall through to `else` and inherit `anim.properties` (the to-values) —
|
|
// .fromTo() must NOT join the .from() clear-branch or the clone would
|
|
// drop the very state the fromTo just established.
|
|
if (anim.method === "from") {
|
|
for (const k of Object.keys(anim.properties)) delete inheritedProps[k];
|
|
} else {
|
|
for (const [k, v] of Object.entries(anim.properties)) {
|
|
inheritedProps[k] = v;
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (pos >= opts.splitTime) {
|
|
result = updateAnimationSelector(result, anim.id, newSelector);
|
|
continue;
|
|
}
|
|
|
|
// Spans the split — use linear interpolation to compute mid-values,
|
|
// then .fromTo() on the clone so both halves play the correct range.
|
|
// For .fromTo() tweens we have explicit from-values; for .to() tweens
|
|
// we use accumulated state from prior animations, defaulting to 0 for
|
|
// unknown numeric properties (the standard GSAP transform initial state).
|
|
const progress = dur > 0 ? (opts.splitTime - pos) / dur : 0;
|
|
const fromSource = anim.fromProperties ?? inheritedProps;
|
|
const midProps: Record<string, number | string> = {};
|
|
for (const [k, v] of Object.entries(anim.properties)) {
|
|
if (typeof v !== "number") {
|
|
midProps[k] = v;
|
|
continue;
|
|
}
|
|
const fromVal = typeof fromSource[k] === "number" ? (fromSource[k] as number) : 0;
|
|
midProps[k] = fromVal + (v - fromVal) * progress;
|
|
}
|
|
|
|
const firstHalfDuration = opts.splitTime - pos;
|
|
result = updateAnimationInScript(result, anim.id, {
|
|
duration: firstHalfDuration,
|
|
properties: midProps,
|
|
});
|
|
|
|
const secondHalfDuration = animEnd - opts.splitTime;
|
|
const addResult = addAnimationToScript(result, {
|
|
targetSelector: newSelector,
|
|
method: "fromTo",
|
|
position: newElementStart,
|
|
duration: secondHalfDuration,
|
|
properties: { ...anim.properties },
|
|
fromProperties: { ...midProps },
|
|
ease: anim.ease,
|
|
extras: anim.extras,
|
|
});
|
|
result = addResult.script;
|
|
|
|
for (const [k, v] of Object.entries(midProps)) {
|
|
inheritedProps[k] = v;
|
|
}
|
|
}
|
|
|
|
if (Object.keys(inheritedProps).length > 0) {
|
|
result = insertInheritedStateSet(result, newSelector, newElementStart, inheritedProps);
|
|
}
|
|
|
|
return { script: result, skippedSelectors };
|
|
}
|
|
|
|
// ── Keyframe Mutation Functions ────────────────────────────────────────────
|
|
|
|
function sortedKeyframes(
|
|
kfs: Array<{ percentage: number; properties: Record<string, number | string>; ease?: string }>,
|
|
) {
|
|
return kfs.slice().sort((a, b) => a.percentage - b.percentage);
|
|
}
|
|
|
|
function keyframePropsToCode(kf: { properties: Record<string, number | string> }): string[] {
|
|
return Object.entries(kf.properties).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`);
|
|
}
|
|
|
|
function buildKeyframeObjectCode(
|
|
keyframes: Array<{
|
|
percentage: number;
|
|
properties: Record<string, number | string>;
|
|
ease?: string;
|
|
auto?: boolean;
|
|
}>,
|
|
options?: { easeEach?: string },
|
|
): string {
|
|
const entries = keyframes.map((kf) => {
|
|
const props = keyframePropsToCode(kf);
|
|
if (kf.ease) props.push(`ease: ${JSON.stringify(kf.ease)}`);
|
|
if (kf.auto) props.push(`_auto: 1`);
|
|
return `${JSON.stringify(`${kf.percentage}%`)}: { ${props.join(", ")} }`;
|
|
});
|
|
if (options?.easeEach) entries.push(`easeEach: ${JSON.stringify(options.easeEach)}`);
|
|
return `{ ${entries.join(", ")} }`;
|
|
}
|
|
|
|
/** Remove a named property from an ObjectExpression's properties array. */
|
|
function removeVarsKey(varsArg: AstNode, key: string): void {
|
|
if (varsArg?.type !== "ObjectExpression") return;
|
|
varsArg.properties = varsArg.properties.filter(
|
|
(p: AstNode) => !(isObjectProperty(p) && propKeyName(p) === key),
|
|
);
|
|
}
|
|
|
|
/** Extract the numeric percentage from a key like "50%". Returns NaN for non-percentage keys. */
|
|
function percentageFromKey(key: string): number {
|
|
const m = PERCENTAGE_KEY_RE.exec(key);
|
|
return m ? Number.parseFloat(m[1]!) : Number.NaN;
|
|
}
|
|
|
|
const PCT_TOLERANCE = 2;
|
|
|
|
// Below this (tween-%) a retime resolves onto its own source keyframe → skip the
|
|
// write. Mirrors the drag layer's NOOP_EPSILON so a deliberate 1% retime commits.
|
|
const MOVE_NOOP_EPSILON_PCT = 0.05;
|
|
|
|
function findKeyframePropByPct(
|
|
kfNode: AstNode,
|
|
percentage: number,
|
|
): { idx: number; prop: AstNode } | null {
|
|
const props = kfNode.properties;
|
|
for (let i = 0; i < props.length; i++) {
|
|
if (!isObjectProperty(props[i])) continue;
|
|
const key = propKeyName(props[i]);
|
|
if (typeof key !== "string") continue;
|
|
const parsed = percentageFromKey(key);
|
|
if (Number.isNaN(parsed)) continue;
|
|
if (Math.abs(parsed - percentage) <= PCT_TOLERANCE) return { idx: i, prop: props[i] };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** Build a keyframe value AST node from properties and optional ease. */
|
|
function buildKeyframeValueNode(
|
|
properties: Record<string, number | string>,
|
|
ease?: string,
|
|
): AstNode {
|
|
const effectiveEase = ease ?? (typeof properties.ease === "string" ? properties.ease : undefined);
|
|
const entries = Object.entries(properties)
|
|
.filter(([k]) => k !== "ease")
|
|
.map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`);
|
|
if (effectiveEase) entries.push(`ease: ${JSON.stringify(effectiveEase)}`);
|
|
return parseExpr(`{ ${entries.join(", ")} }`);
|
|
}
|
|
|
|
/** Parse + locate a target animation, returning null on failure. */
|
|
function locateAnimation(
|
|
script: string,
|
|
animationId: string,
|
|
): { parsed: ParsedGsapAst; target: ParsedGsapAst["located"][number] } | null {
|
|
let parsed: ParsedGsapAst;
|
|
try {
|
|
parsed = parseGsapAst(script);
|
|
} catch {
|
|
return null;
|
|
}
|
|
const target = parsed.located.find((l) => l.id === animationId);
|
|
return target ? { parsed, target } : null;
|
|
}
|
|
|
|
// Animation ids encode the tween's timeline position in ms
|
|
// (`#puck-a-to-1200-position`). A gesture/convert can re-emit a tween at a
|
|
// different position, changing its id — so a client that cached the old id (its
|
|
// selectedGsapAnimations hasn't refreshed) edits a now-nonexistent id and the op
|
|
// no-ops. Parse `{selector}-{method}-{posMs}-{group}` so we can fall back to the
|
|
// same selector+method+group tween nearest the requested position.
|
|
const ANIM_ID_RE = /^(.*)-(fromTo|from|to|set)-(\d+)-([a-z]+)$/;
|
|
|
|
function locateAnimationWithFallback(
|
|
script: string,
|
|
animationId: string,
|
|
): ReturnType<typeof locateAnimation> {
|
|
const loc = locateAnimation(script, animationId);
|
|
if (loc) return loc;
|
|
const convertedId = animationId.replace(/-from-|-fromTo-/, "-to-");
|
|
if (convertedId !== animationId) {
|
|
const converted = locateAnimation(script, convertedId);
|
|
if (converted) return converted;
|
|
}
|
|
// Position-drift fallback: match by stable identity (selector+method+group),
|
|
// disambiguating by the position closest to the one the caller asked for.
|
|
const want = ANIM_ID_RE.exec(animationId);
|
|
if (!want) return null;
|
|
const [, sel, method, wantPosStr, group] = want;
|
|
const wantPos = Number(wantPosStr);
|
|
let parsed: ParsedGsapAst;
|
|
try {
|
|
parsed = parseGsapAst(script);
|
|
} catch {
|
|
return null;
|
|
}
|
|
let best: ParsedGsapAst["located"][number] | null = null;
|
|
let bestDist = Number.POSITIVE_INFINITY;
|
|
for (const l of parsed.located) {
|
|
const m = ANIM_ID_RE.exec(l.id);
|
|
if (!m || m[1] !== sel || m[2] !== method || m[4] !== group) continue;
|
|
const dist = Math.abs(Number(m[3]) - wantPos);
|
|
if (dist < bestDist) {
|
|
best = l;
|
|
bestDist = dist;
|
|
}
|
|
}
|
|
return best ? { parsed, target: best } : null;
|
|
}
|
|
|
|
/** Find the keyframes ObjectExpression node on a tween's varsArg, or null. */
|
|
function findKeyframesObjectNode(varsArg: AstNode): AstNode | null {
|
|
const node = findPropertyNode(varsArg, "keyframes");
|
|
return node?.type === "ObjectExpression" ? node : null;
|
|
}
|
|
|
|
/**
|
|
* Convert array-form keyframes (`keyframes: [{x,y}, …]`) to even-percentage object
|
|
* form (`{ "0%": {…}, "33.3%": {…}, … }`) IN PLACE, returning the new object node
|
|
* (or null if not array-form). GSAP distributes an array evenly, so this is
|
|
* runtime-identical — but it gives the percentage-keyed write ops something to
|
|
* target. Needed before INSERTING a keyframe at an arbitrary percentage, which an
|
|
* even array can't host.
|
|
*/
|
|
function convertArrayKeyframesToObjectNode(varsArg: AstNode): AstNode | null {
|
|
if (varsArg?.type !== "ObjectExpression") return null;
|
|
const prop = (varsArg.properties ?? []).find(
|
|
(p: AstNode) => isObjectProperty(p) && propKeyName(p) === "keyframes",
|
|
);
|
|
if (!prop || prop.value?.type !== "ArrayExpression") return null;
|
|
const els: AstNode[] = (prop.value.elements ?? []).filter(
|
|
(e: AstNode | null): e is AstNode => !!e && e.type === "ObjectExpression",
|
|
);
|
|
const n = els.length;
|
|
if (n === 0) return null;
|
|
const entries = els.map((el: AstNode, i: number) => {
|
|
const pct = n > 1 ? Math.round((i / (n - 1)) * 1000) / 10 : 0;
|
|
return `${JSON.stringify(`${pct}%`)}: ${recast.print(el).code}`;
|
|
});
|
|
prop.value = parseExpr(`{ ${entries.join(", ")} }`);
|
|
return prop.value;
|
|
}
|
|
|
|
/** Filter percentage-keyed properties from a keyframes ObjectExpression. */
|
|
function filterPercentageProps(kfNode: AstNode): AstNode[] {
|
|
return kfNode.properties.filter((p: AstNode) => {
|
|
if (!isObjectProperty(p)) return false;
|
|
const key = propKeyName(p);
|
|
return typeof key === "string" && PERCENTAGE_KEY_RE.test(key);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Collapse a keyframes node to flat tween: apply `record` entries as vars keys,
|
|
* then remove `keyframes` and `easeEach` from varsArg. Skips the `ease` key
|
|
* from the record (per-keyframe ease, not a tween ease).
|
|
*/
|
|
function collapseKeyframesToFlat(varsArg: AstNode, record: Record<string, unknown>): void {
|
|
for (const [k, v] of Object.entries(record)) {
|
|
if (k === "ease") continue;
|
|
if (typeof v === "number" || typeof v === "string") setVarsKey(varsArg, k, v);
|
|
}
|
|
removeVarsKey(varsArg, "keyframes");
|
|
removeVarsKey(varsArg, "easeEach");
|
|
}
|
|
|
|
/**
|
|
* Locate an animation's keyframes ObjectExpression and build the percentage key.
|
|
* Shared preamble for addKeyframeToScript, removeKeyframeFromScript, and
|
|
* updateKeyframeInScript.
|
|
*/
|
|
function locateKeyframeCtx(script: string, animationId: string, percentage: number) {
|
|
const loc = locateAnimationWithFallback(script, animationId);
|
|
if (!loc) return null;
|
|
const kfNode = findKeyframesObjectNode(loc.target.call.varsArg);
|
|
if (!kfNode) return null;
|
|
return { loc, kfNode, pctKey: `${percentage}%` };
|
|
}
|
|
|
|
/**
|
|
* Insert a keyframe at the given percentage in an existing percentage-keyframes
|
|
* object. If the percentage already exists, its value is replaced.
|
|
*/
|
|
export function addKeyframeToScript(
|
|
script: string,
|
|
animationId: string,
|
|
percentage: number,
|
|
properties: Record<string, number | string>,
|
|
ease?: string,
|
|
backfillDefaults?: Record<string, number | string>,
|
|
): string {
|
|
let loc = locateAnimationWithFallback(script, animationId);
|
|
if (!loc) return script;
|
|
let kfNode = findKeyframesObjectNode(loc.target.call.varsArg);
|
|
|
|
// Array-form keyframes can't host an arbitrary new percentage — normalize to
|
|
// object form in place first. (convertToKeyframesInScript below only converts
|
|
// FLAT tweens; it early-returns when keyframes already exist.)
|
|
if (!kfNode) kfNode = convertArrayKeyframesToObjectNode(loc.target.call.varsArg);
|
|
|
|
if (!kfNode) {
|
|
script = convertToKeyframesInScript(script, animationId);
|
|
loc = locateAnimationWithFallback(script, animationId);
|
|
if (!loc) return script;
|
|
kfNode = findKeyframesObjectNode(loc.target.call.varsArg);
|
|
if (!kfNode) return script;
|
|
}
|
|
const pctKey = `${percentage}%`;
|
|
|
|
const newValueNode = buildKeyframeValueNode(properties, ease);
|
|
|
|
// Merge into existing keyframe at this percentage, or insert new
|
|
const existing = findKeyframePropByPct(kfNode, percentage);
|
|
if (existing) {
|
|
if (existing.prop.value?.type === "ObjectExpression") {
|
|
const existingRecord = objectExpressionToRecord(existing.prop.value, loc.parsed.scope);
|
|
const merged = { ...existingRecord };
|
|
for (const [k, v] of Object.entries(properties)) merged[k] = v;
|
|
existing.prop.value = buildKeyframeValueNode(
|
|
merged as Record<string, number | string>,
|
|
ease ?? (typeof existingRecord.ease === "string" ? existingRecord.ease : undefined),
|
|
);
|
|
} else {
|
|
existing.prop.value = newValueNode;
|
|
}
|
|
} else {
|
|
// Build the new property node with a quoted percentage key
|
|
const newProp = parseExpr(`{ ${JSON.stringify(pctKey)}: {} }`).properties[0];
|
|
newProp.value = newValueNode;
|
|
|
|
// Insert in sorted order by percentage
|
|
let insertIdx = kfNode.properties.length;
|
|
for (let i = 0; i < kfNode.properties.length; i++) {
|
|
const key = isObjectProperty(kfNode.properties[i])
|
|
? propKeyName(kfNode.properties[i])
|
|
: undefined;
|
|
if (typeof key === "string" && percentageFromKey(key) > percentage) {
|
|
insertIdx = i;
|
|
break;
|
|
}
|
|
}
|
|
kfNode.properties.splice(insertIdx, 0, newProp);
|
|
}
|
|
|
|
// Auto-update adjacent endpoints: only update an `_auto` 0% or 100%
|
|
// keyframe when the new keyframe is directly next to it (no other keyframe
|
|
// between them). This prevents a keyframe at 74% from clobbering 100% when
|
|
// 75% already exists, and a keyframe at 30% from clobbering 0% when 25%
|
|
// already exists.
|
|
if (percentage > 0 && percentage < 100) {
|
|
const pctProps = filterPercentageProps(kfNode);
|
|
const allPcts = pctProps
|
|
.map((p: AstNode) => percentageFromKey(propKeyName(p) ?? ""))
|
|
.filter((n: number) => !Number.isNaN(n) && n !== percentage)
|
|
.sort((a: number, b: number) => a - b);
|
|
const leftNeighbor = allPcts.filter((p: number) => p < percentage).pop();
|
|
const rightNeighbor = allPcts.find((p: number) => p > percentage);
|
|
for (const endPct of [0, 100]) {
|
|
const isNeighbor = endPct === 0 ? leftNeighbor === 0 : rightNeighbor === 100;
|
|
if (!isNeighbor) continue;
|
|
const endProp = pctProps.find(
|
|
(p: AstNode) => percentageFromKey(propKeyName(p) ?? "") === endPct,
|
|
);
|
|
if (!endProp?.value || endProp.value.type !== "ObjectExpression") continue;
|
|
const hasAuto = endProp.value.properties.some(
|
|
(p: AstNode) => isObjectProperty(p) && propKeyName(p) === "_auto",
|
|
);
|
|
if (!hasAuto) continue;
|
|
const updatedProps = { ...properties, _auto: 1 as number | string };
|
|
endProp.value = buildKeyframeValueNode(updatedProps, undefined);
|
|
}
|
|
}
|
|
|
|
// Backfill: when the new keyframe introduces properties absent from other
|
|
// keyframes, add default values so GSAP can interpolate them.
|
|
if (backfillDefaults) {
|
|
const newPropKeys = Object.keys(properties);
|
|
const pctProps = filterPercentageProps(kfNode);
|
|
for (const prop of pctProps) {
|
|
const key = propKeyName(prop);
|
|
if (key === pctKey) continue;
|
|
const valObj = prop.value;
|
|
if (!valObj || valObj.type !== "ObjectExpression") continue;
|
|
const existingKeys = new Set(
|
|
valObj.properties
|
|
.filter((p: AstNode) => isObjectProperty(p))
|
|
.map((p: AstNode) => propKeyName(p)),
|
|
);
|
|
for (const pk of newPropKeys) {
|
|
if (existingKeys.has(pk)) continue;
|
|
const defaultVal = backfillDefaults[pk];
|
|
if (defaultVal == null) continue;
|
|
const fillProp = parseExpr(`{ ${safeKey(pk)}: ${valueToCode(defaultVal)} }`).properties[0];
|
|
valObj.properties.push(fillProp);
|
|
}
|
|
}
|
|
}
|
|
|
|
return recast.print(loc.parsed.ast).code;
|
|
}
|
|
|
|
/**
|
|
* Remove a keyframe at the given percentage. If fewer than 2 keyframes remain
|
|
* after removal, collapse the keyframes object to a flat tween using the
|
|
* remaining keyframe's properties.
|
|
*/
|
|
export function removeKeyframeFromScript(
|
|
script: string,
|
|
animationId: string,
|
|
percentage: number,
|
|
): string {
|
|
// Array-form keyframes (`keyframes: [{x,y}, …]`) have no explicit percentages —
|
|
// GSAP distributes them evenly. The object-form path below can't see them
|
|
// (findKeyframesObjectNode only matches ObjectExpression), so removing from an
|
|
// array-form tween silently no-op'd. Resolve the element by its implicit
|
|
// percentage and splice it; collapse to a flat tween when fewer than two remain.
|
|
const arrLoc = locateAnimationWithFallback(script, animationId);
|
|
// findPropertyNode here returns the property's VALUE node directly.
|
|
const arrVal = arrLoc && findPropertyNode(arrLoc.target.call.varsArg, "keyframes");
|
|
if (arrLoc && arrVal?.type === "ArrayExpression") {
|
|
const elements: AstNode[] = (arrVal.elements ?? []).filter(
|
|
(e: AstNode | null): e is AstNode => !!e && e.type === "ObjectExpression",
|
|
);
|
|
const n = elements.length;
|
|
if (n === 0) return script;
|
|
let matchIdx = -1;
|
|
let bestDist = Number.POSITIVE_INFINITY;
|
|
for (let i = 0; i < n; i++) {
|
|
const pct = n > 1 ? (i / (n - 1)) * 100 : 0;
|
|
const dist = Math.abs(pct - percentage);
|
|
if (dist <= PCT_TOLERANCE && dist < bestDist) {
|
|
matchIdx = i;
|
|
bestDist = dist;
|
|
}
|
|
}
|
|
if (matchIdx === -1) return script;
|
|
const remaining = elements.filter((_, i) => i !== matchIdx);
|
|
if (remaining.length < 2) {
|
|
const sole = remaining[0];
|
|
const record = sole ? objectExpressionToRecord(sole, arrLoc.parsed.scope) : {};
|
|
collapseKeyframesToFlat(arrLoc.target.call.varsArg, record);
|
|
} else {
|
|
const realIdx = arrVal.elements.indexOf(elements[matchIdx]);
|
|
arrVal.elements.splice(realIdx, 1);
|
|
}
|
|
return recast.print(arrLoc.parsed.ast).code;
|
|
}
|
|
|
|
const ctx = locateKeyframeCtx(script, animationId, percentage);
|
|
if (!ctx) return script;
|
|
const { loc, kfNode } = ctx;
|
|
|
|
const match = findKeyframePropByPct(kfNode, percentage);
|
|
if (!match) return script;
|
|
const removeIdx = match.idx;
|
|
|
|
kfNode.properties.splice(removeIdx, 1);
|
|
|
|
const remainingKfs = filterPercentageProps(kfNode);
|
|
if (remainingKfs.length < 2) {
|
|
const record =
|
|
remainingKfs.length === 1
|
|
? objectExpressionToRecord(remainingKfs[0]!.value, loc.parsed.scope)
|
|
: {};
|
|
collapseKeyframesToFlat(loc.target.call.varsArg, record);
|
|
}
|
|
|
|
return recast.print(loc.parsed.ast).code;
|
|
}
|
|
|
|
/**
|
|
* Retime a keyframe: move the keyframe at `fromPercentage` to `toPercentage`,
|
|
* PRESERVING its properties and per-keyframe ease (the Studio "Move to Playhead"
|
|
* gesture). Re-sorts keyframes by percentage. If a keyframe already exists at
|
|
* `toPercentage`, it is overwritten by the moved one (no duplicate). No-op when
|
|
* the animation/keyframe isn't found, the tween has no object-form keyframes, or
|
|
* the move resolves onto the same keyframe. Acorn twin: moveKeyframeInScript.
|
|
*/
|
|
export function moveKeyframeInScript(
|
|
script: string,
|
|
animationId: string,
|
|
fromPercentage: number,
|
|
toPercentage: number,
|
|
): string {
|
|
const loc = locateAnimationWithFallback(script, animationId);
|
|
if (!loc) return script;
|
|
// Array-form keyframes can't host an arbitrary destination percentage —
|
|
// normalize to object form in place first (mirrors addKeyframeToScript).
|
|
const kfNode =
|
|
findKeyframesObjectNode(loc.target.call.varsArg) ??
|
|
convertArrayKeyframesToObjectNode(loc.target.call.varsArg);
|
|
if (!kfNode) return script;
|
|
|
|
const match = findKeyframePropByPct(kfNode, fromPercentage);
|
|
if (!match) return script;
|
|
// No-op ONLY for a negligible move (matches the drag's NOOP_EPSILON). The old
|
|
// `collision.prop === match.prop` guard dropped EVERY sub-PCT_TOLERANCE (2%)
|
|
// retime, because findKeyframePropByPct resolves the destination back onto the
|
|
// from-keyframe — so a deliberate 1% drag committed nothing. Acorn twin too.
|
|
if (Math.abs(fromPercentage - toPercentage) < MOVE_NOOP_EPSILON_PCT) return script;
|
|
// A destination keyframe is only a real collision (overwrite) when it's a
|
|
// DIFFERENT keyframe; resolving back onto the from-keyframe is not.
|
|
const dest = findKeyframePropByPct(kfNode, toPercentage);
|
|
const collision = dest && dest.prop !== match.prop ? dest : null;
|
|
|
|
// Reuse each keyframe's value node verbatim (preserves properties +
|
|
// per-keyframe ease + _auto). Drop the moved keyframe (and any destination
|
|
// keyframe it overwrites), re-key the moved value to toPercentage, then re-sort.
|
|
const movedValue = match.prop.value;
|
|
const entries: Array<{ pct: number; value: AstNode }> = [];
|
|
for (const prop of filterPercentageProps(kfNode)) {
|
|
if (prop === match.prop) continue;
|
|
if (collision && prop === collision.prop) continue;
|
|
const pct = percentageFromKey(propKeyName(prop) ?? "");
|
|
if (Number.isNaN(pct)) continue;
|
|
entries.push({ pct, value: prop.value });
|
|
}
|
|
entries.push({ pct: toPercentage, value: movedValue });
|
|
entries.sort((a, b) => a.pct - b.pct);
|
|
|
|
kfNode.properties = entries.map((e) => {
|
|
const p = parseExpr(`{ ${JSON.stringify(`${e.pct}%`)}: {} }`).properties[0];
|
|
p.value = e.value;
|
|
return p;
|
|
});
|
|
return recast.print(loc.parsed.ast).code;
|
|
}
|
|
|
|
/**
|
|
* Resize a keyframed tween's window (boundary drag-to-retime): set the tween's
|
|
* `position` + `duration` and RE-KEY each existing keyframe to its new percentage
|
|
* via `pctRemap` (each `{ from, to }` matches an existing keyframe by its current
|
|
* tween-% and re-keys it to `to`).
|
|
*
|
|
* Re-keys the percentage KEY in place, leaving every value node (so `_auto` +
|
|
* per-keyframe `ease` survive), the keyframes-object `easeEach`, and the OUTER
|
|
* tween `ease` untouched. Acorn twin: resizeKeyframedTweenInScript.
|
|
*/
|
|
export function resizeKeyframedTweenInScript(
|
|
script: string,
|
|
animationId: string,
|
|
newPosition: number,
|
|
newDuration: number,
|
|
pctRemap: ReadonlyArray<{ from: number; to: number }>,
|
|
): string {
|
|
const loc = locateAnimationWithFallback(script, animationId);
|
|
if (!loc) return script;
|
|
// Array-form keyframes can't host an arbitrary re-keyed percentage —
|
|
// normalize to object form in place first (mirrors addKeyframeToScript).
|
|
const kfNode =
|
|
findKeyframesObjectNode(loc.target.call.varsArg) ??
|
|
convertArrayKeyframesToObjectNode(loc.target.call.varsArg);
|
|
if (!kfNode) return script;
|
|
|
|
const seen = new Set<AstNode>();
|
|
for (const { from, to } of pctRemap) {
|
|
const match = findKeyframePropByPct(kfNode, from);
|
|
if (!match || seen.has(match.prop)) continue;
|
|
seen.add(match.prop);
|
|
// Replace only the key node; the value node (incl. _auto + per-keyframe ease)
|
|
// stays verbatim. easeEach is a sibling non-percentage prop, left untouched.
|
|
match.prop.key = parseExpr(`{ ${JSON.stringify(`${to}%`)}: 0 }`).properties[0].key;
|
|
}
|
|
|
|
applyUpdatesToCall(loc.target.call, { position: newPosition, duration: newDuration });
|
|
return recast.print(loc.parsed.ast).code;
|
|
}
|
|
|
|
/**
|
|
* Replace the properties (and optionally ease) at an existing keyframe percentage.
|
|
*/
|
|
export function updateKeyframeInScript(
|
|
script: string,
|
|
animationId: string,
|
|
percentage: number,
|
|
properties: Record<string, number | string>,
|
|
ease?: string,
|
|
): string {
|
|
// Array-form keyframes (`keyframes: [{x,y}, …]`) have no explicit percentages —
|
|
// GSAP distributes them evenly. The percentage-keyed object path below can't
|
|
// match them (findKeyframesObjectNode only matches ObjectExpression), so dragging
|
|
// a motion-path node on an array-authored tween silently no-op'd. Resolve the
|
|
// element by its implicit percentage and replace it in place. Mirrors the array
|
|
// branch in removeKeyframeFromScript.
|
|
const arrLoc = locateAnimationWithFallback(script, animationId);
|
|
const arrVal = arrLoc && findPropertyNode(arrLoc.target.call.varsArg, "keyframes");
|
|
if (arrLoc && arrVal?.type === "ArrayExpression") {
|
|
const elements: AstNode[] = (arrVal.elements ?? []).filter(
|
|
(e: AstNode | null): e is AstNode => !!e && e.type === "ObjectExpression",
|
|
);
|
|
const n = elements.length;
|
|
if (n === 0) return script;
|
|
let matchIdx = -1;
|
|
let bestDist = Number.POSITIVE_INFINITY;
|
|
for (let i = 0; i < n; i++) {
|
|
const pct = n > 1 ? (i / (n - 1)) * 100 : 0;
|
|
const dist = Math.abs(pct - percentage);
|
|
if (dist <= PCT_TOLERANCE && dist < bestDist) {
|
|
matchIdx = i;
|
|
bestDist = dist;
|
|
}
|
|
}
|
|
if (matchIdx === -1) return script;
|
|
const realIdx = arrVal.elements.indexOf(elements[matchIdx]);
|
|
arrVal.elements[realIdx] = buildKeyframeValueNode(properties, ease);
|
|
return recast.print(arrLoc.parsed.ast).code;
|
|
}
|
|
|
|
const ctx = locateKeyframeCtx(script, animationId, percentage);
|
|
if (!ctx) return script;
|
|
const { loc, kfNode } = ctx;
|
|
|
|
const match = findKeyframePropByPct(kfNode, percentage);
|
|
if (!match) return script;
|
|
|
|
if (Object.keys(properties).length === 0 && ease) {
|
|
// Ease-only update: preserve existing properties, just add/replace ease
|
|
const existing = match.prop.value;
|
|
if (existing?.type === "ObjectExpression") {
|
|
const props = (existing.properties ?? []) as AstNode[];
|
|
const easeIdx = props.findIndex(
|
|
(p: AstNode) => isObjectProperty(p) && propKeyName(p) === "ease",
|
|
);
|
|
const easeNode = parseExpr(`({ ease: ${JSON.stringify(ease)} })`).properties[0];
|
|
if (easeIdx >= 0) {
|
|
props[easeIdx] = easeNode;
|
|
} else {
|
|
props.push(easeNode);
|
|
}
|
|
return recast.print(loc.parsed.ast).code;
|
|
}
|
|
// Non-object keyframe value (primitive shorthand, e.g. "50%": "0.5"): there
|
|
// is no property bag to merge the ease into. Rebuilding from empty
|
|
// `properties` would wipe the primitive — leave the keyframe untouched.
|
|
return script;
|
|
}
|
|
// MERGE edited props into the existing keyframe, preserving props not in this edit
|
|
// (z, transformPerspective, rotation, …). A whole-value rebuild drops them, so editing
|
|
// one prop at the 0% keyframe strips z/transformPerspective and the element pops.
|
|
// Mirrors acorn updateKeyframeInScript; parity-locked by gsapWriterParity.corpus.
|
|
const existing = match.prop.value;
|
|
if (existing?.type === "ObjectExpression") {
|
|
const props = (existing.properties ?? []) as AstNode[];
|
|
const upsert = (key: string, valueCode: string) => {
|
|
const idx = props.findIndex((p: AstNode) => isObjectProperty(p) && propKeyName(p) === key);
|
|
const node = parseExpr(`({ ${safeKey(key)}: ${valueCode} })`).properties[0];
|
|
if (idx >= 0) props[idx] = node;
|
|
else props.push(node);
|
|
};
|
|
for (const [k, v] of Object.entries(properties)) upsert(k, valueToCode(v));
|
|
if (ease !== undefined) upsert("ease", JSON.stringify(ease));
|
|
return recast.print(loc.parsed.ast).code;
|
|
}
|
|
match.prop.value = buildKeyframeValueNode(properties, ease);
|
|
return recast.print(loc.parsed.ast).code;
|
|
}
|
|
|
|
/** Strip editable properties and ease/keyframes keys from a varsArg. */
|
|
function stripEditableAndEase(varsArg: AstNode): void {
|
|
// ease is a BUILTIN_VAR_KEY (not editable), so filterEditableKeys won't remove it —
|
|
// drop it explicitly before filtering, along with keyframes.
|
|
if (varsArg?.type !== "ObjectExpression") return;
|
|
varsArg.properties = varsArg.properties.filter((p: AstNode) => {
|
|
if (!isObjectProperty(p)) return true;
|
|
const key = propKeyName(p);
|
|
return key !== "ease" && key !== "keyframes";
|
|
});
|
|
filterEditableKeys(varsArg, () => false);
|
|
}
|
|
|
|
/** Build and prepend a keyframes property node onto varsArg. */
|
|
function insertKeyframesProp(
|
|
varsArg: AstNode,
|
|
fromProps: Record<string, number | string>,
|
|
toProps: Record<string, number | string>,
|
|
easeEach?: string,
|
|
): void {
|
|
const fromEntries = Object.entries(fromProps).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`);
|
|
const toEntries = Object.entries(toProps).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`);
|
|
const easeEntry = easeEach ? `, easeEach: ${JSON.stringify(easeEach)}` : "";
|
|
const kfCode = `{ "0%": { ${fromEntries.join(", ")} }, "100%": { ${toEntries.join(", ")} }${easeEntry} }`;
|
|
const kfProp = parseExpr(`{ keyframes: {} }`).properties[0];
|
|
kfProp.value = parseExpr(kfCode);
|
|
if (varsArg?.type === "ObjectExpression") varsArg.properties.unshift(kfProp);
|
|
}
|
|
|
|
/**
|
|
* Convert a flat tween (to/from/fromTo) to percentage-keyframes format.
|
|
* `resolvedFromValues` supplies the "from" state for `to()` tweens or
|
|
* the "to" state for `from()` tweens (the values the DOM would resolve to).
|
|
*/
|
|
export function convertToKeyframesInScript(
|
|
script: string,
|
|
animationId: string,
|
|
resolvedFromValues?: Record<string, number | string>,
|
|
setDuration = 1,
|
|
): string {
|
|
let loc = locateAnimationWithFallback(script, animationId);
|
|
if (!loc) return script;
|
|
|
|
const anim = loc.target.animation;
|
|
if (anim.keyframes) return script;
|
|
|
|
const { fromProps, toProps } = resolveConversionProps(anim, resolvedFromValues);
|
|
const varsArg = loc.target.call.varsArg;
|
|
const originalEase = anim.ease;
|
|
|
|
stripEditableAndEase(varsArg);
|
|
insertKeyframesProp(varsArg, fromProps, toProps, originalEase || undefined);
|
|
|
|
if (originalEase) {
|
|
setVarsKey(varsArg, "ease", "none");
|
|
}
|
|
|
|
// For from() or fromTo(), convert to to()
|
|
if (anim.method === "from" || anim.method === "fromTo") {
|
|
loc.target.call.node.callee.property.name = "to";
|
|
if (anim.method === "fromTo") loc.target.call.node.arguments.splice(1, 1);
|
|
}
|
|
|
|
// A static `set` becomes an animatable `to`: flip the method, drop the
|
|
// immediateRender hold marker, and give it a real duration so the keyframes
|
|
// span time. This is what makes a static 3D transform keyframeable.
|
|
if (anim.method === "set") {
|
|
// A GLOBAL `gsap.set(...)` is off-timeline; flipping only the method would
|
|
// emit `gsap.to(...)`, which fires once at load and is NOT on the paused
|
|
// master timeline (the engine can't seek/render it). Re-root it onto the
|
|
// timeline var and add the position arg (a gsap.set has none) so the
|
|
// converted tween is seekable. A `tl.set` already has the right object.
|
|
const calleeObj = loc.target.call.node.callee.object;
|
|
if (anim.global && calleeObj?.type === "Identifier") {
|
|
calleeObj.name = loc.parsed.timelineVar;
|
|
if (loc.target.call.node.arguments.length < 3) {
|
|
loc.target.call.node.arguments.push(parseExpr("0"));
|
|
}
|
|
}
|
|
loc.target.call.node.callee.property.name = "to";
|
|
removeVarsKey(varsArg, "immediateRender");
|
|
setVarsKey(varsArg, "duration", Math.max(0.001, setDuration));
|
|
}
|
|
|
|
return recast.print(loc.parsed.ast).code;
|
|
}
|
|
|
|
/**
|
|
* Remove all keyframes from a tween, collapsing to a flat tween with the
|
|
* last keyframe's properties.
|
|
*/
|
|
export function removeAllKeyframesFromScript(script: string, animationId: string): string {
|
|
let loc = locateAnimationWithFallback(script, animationId);
|
|
if (!loc) return script;
|
|
// Array-form keyframes have no percentage-keyed props for
|
|
// filterPercentageProps to read — normalize to object form first (mirrors
|
|
// addKeyframeToScript/moveKeyframeInScript), otherwise this silently no-ops
|
|
// on every array-form tween.
|
|
const kfNode =
|
|
findKeyframesObjectNode(loc.target.call.varsArg) ??
|
|
convertArrayKeyframesToObjectNode(loc.target.call.varsArg);
|
|
if (!kfNode) return script;
|
|
|
|
const kfEntries = filterPercentageProps(kfNode)
|
|
.map((p: AstNode) => ({ pct: percentageFromKey(propKeyName(p)!), prop: p }))
|
|
.filter((e) => !Number.isNaN(e.pct))
|
|
.sort((a, b) => a.pct - b.pct);
|
|
if (kfEntries.length === 0) return script;
|
|
|
|
// For to()/set(): collapse to last keyframe (the destination = visible state).
|
|
// For from(): collapse to first keyframe (the starting state).
|
|
const method = loc.target.call.method;
|
|
const collapseEntry = method === "from" ? kfEntries[0]! : kfEntries[kfEntries.length - 1]!;
|
|
const record = objectExpressionToRecord(collapseEntry.prop.value, loc.parsed.scope);
|
|
collapseKeyframesToFlat(loc.target.call.varsArg, record);
|
|
// Removing ALL keyframes HOLDS the element statically — collapse to a
|
|
// zero-duration immediateRender tween (a `gsap.set` equivalent), dropping the
|
|
// original duration/ease so the element does not re-animate from its base
|
|
// toward the collapsed value (which moved it out from under the selection).
|
|
removeVarsKey(loc.target.call.varsArg, "ease");
|
|
setVarsKey(loc.target.call.varsArg, "duration", 0);
|
|
setVarsKey(loc.target.call.varsArg, "immediateRender", true);
|
|
|
|
// Removing all keyframes of a POSITION tween leaves exactly ONE held state:
|
|
// strip every sibling position write for the same selector (stray gsap.set or a
|
|
// second tl.to/tl.set) so the collapsed hold is the lone position source. Only
|
|
// position siblings are stripped; rotation/opacity/etc. for the selector remain.
|
|
if (loc.target.animation.propertyGroup === "position") {
|
|
for (const l of loc.parsed.located) {
|
|
if (l === loc.target) continue;
|
|
if (
|
|
l.animation.targetSelector === loc.target.animation.targetSelector &&
|
|
l.animation.propertyGroup === "position"
|
|
) {
|
|
removeCallFromAst(l.call);
|
|
}
|
|
}
|
|
}
|
|
|
|
return recast.print(loc.parsed.ast).code;
|
|
}
|
|
|
|
/**
|
|
* Replace a dynamic `keyframes: <expr>` with a static percentage-keyframes object.
|
|
* Called when the user first edits a dynamically-generated keyframe in the studio.
|
|
*/
|
|
export function materializeKeyframesInScript(
|
|
script: string,
|
|
animationId: string,
|
|
keyframes: Array<{
|
|
percentage: number;
|
|
properties: Record<string, number | string>;
|
|
ease?: string;
|
|
}>,
|
|
easeEach?: string,
|
|
resolvedSelector?: string,
|
|
): string {
|
|
let loc = locateAnimationWithFallback(script, animationId);
|
|
if (!loc) return script;
|
|
|
|
const varsArg = loc.target.call.varsArg;
|
|
|
|
// Replace dynamic selector with resolved static string
|
|
if (resolvedSelector && loc.target.call.node.arguments[0]) {
|
|
loc.target.call.node.arguments[0] = parseExpr(JSON.stringify(resolvedSelector));
|
|
}
|
|
|
|
const kfObjCode = buildKeyframeObjectCode(sortedKeyframes(keyframes), { easeEach });
|
|
const kfParent = varsArg.properties.find(
|
|
(p: AstNode) => isObjectProperty(p) && propKeyName(p) === "keyframes",
|
|
);
|
|
if (kfParent) {
|
|
kfParent.value = parseExpr(kfObjCode);
|
|
} else {
|
|
const kfProp = parseExpr(`{ keyframes: ${kfObjCode} }`).properties[0];
|
|
varsArg.properties.unshift(kfProp);
|
|
}
|
|
|
|
removeVarsKey(varsArg, "easeEach");
|
|
|
|
return recast.print(loc.parsed.ast).code;
|
|
}
|
|
|
|
// ── Arc Path (motionPath) AST Mutations ──────────────────────────────────
|
|
|
|
function numericXY(props: Record<string, number | string>): { x: number; y: number } | null {
|
|
const x = props.x;
|
|
const y = props.y;
|
|
return typeof x === "number" && typeof y === "number" ? { x, y } : null;
|
|
}
|
|
|
|
function extractArcWaypoints(anim: GsapAnimation): Array<{ x: number; y: number }> {
|
|
const kfs = anim.keyframes?.keyframes ?? [];
|
|
const waypoints = kfs.map((kf) => numericXY(kf.properties)).filter((p) => p !== null);
|
|
if (waypoints.length >= 2) return waypoints;
|
|
const px = anim.properties.x;
|
|
const py = anim.properties.y;
|
|
if (typeof px !== "number" && typeof py !== "number") return waypoints;
|
|
return [
|
|
{ x: 0, y: 0 },
|
|
{ x: typeof px === "number" ? px : 0, y: typeof py === "number" ? py : 0 },
|
|
];
|
|
}
|
|
|
|
function buildMotionPathObjectCode(config: {
|
|
waypoints: Array<{ x: number; y: number }>;
|
|
segments: ArcPathSegment[];
|
|
autoRotate: boolean | number;
|
|
}): string {
|
|
const { waypoints, segments, autoRotate } = config;
|
|
const hasExplicitControlPoints = segments.some((s) => s.cp1 && s.cp2);
|
|
// The simple `path` array supports only one scalar curviness for the whole
|
|
// path, so per-segment curviness must use the cubic form (curviness baked into
|
|
// each segment's control points). Without this, the simple branch serializes
|
|
// only segments[0].curviness and silently drops every other segment's curve.
|
|
const curvinessVaries = segments.some(
|
|
(s) => (s.curviness ?? 1) !== (segments[0]?.curviness ?? 1),
|
|
);
|
|
|
|
let pathEntries: string[];
|
|
if ((hasExplicitControlPoints || curvinessVaries) && waypoints.length >= 2) {
|
|
// type: "cubic" — interleave control points: [anchor, cp1, cp2, anchor, ...]
|
|
pathEntries = [`{x: ${waypoints[0]!.x}, y: ${waypoints[0]!.y}}`];
|
|
for (let i = 0; i < segments.length; i++) {
|
|
const seg = segments[i]!;
|
|
const nextWp = waypoints[i + 1]!;
|
|
if (seg.cp1 && seg.cp2) {
|
|
pathEntries.push(`{x: ${seg.cp1.x}, y: ${seg.cp1.y}}`);
|
|
pathEntries.push(`{x: ${seg.cp2.x}, y: ${seg.cp2.y}}`);
|
|
} else {
|
|
// Auto-generate simple midpoint control points from curviness
|
|
const wp = waypoints[i]!;
|
|
const dx = nextWp.x - wp.x;
|
|
const dy = nextWp.y - wp.y;
|
|
const c = seg.curviness ?? 1;
|
|
pathEntries.push(
|
|
`{x: ${wp.x + dx * 0.33}, y: ${wp.y + dy * 0.33 - c * Math.abs(dx) * 0.25}}`,
|
|
);
|
|
pathEntries.push(
|
|
`{x: ${wp.x + dx * 0.66}, y: ${wp.y + dy * 0.66 - c * Math.abs(dx) * 0.25}}`,
|
|
);
|
|
}
|
|
pathEntries.push(`{x: ${nextWp.x}, y: ${nextWp.y}}`);
|
|
}
|
|
const pathStr = pathEntries.join(", ");
|
|
const parts = [`path: [${pathStr}]`, `type: "cubic"`];
|
|
if (autoRotate === true) parts.push("autoRotate: true");
|
|
else if (typeof autoRotate === "number") parts.push(`autoRotate: ${autoRotate}`);
|
|
return `{ ${parts.join(", ")} }`;
|
|
}
|
|
|
|
// Simple waypoint array with curviness
|
|
pathEntries = waypoints.map((wp) => `{x: ${wp.x}, y: ${wp.y}}`);
|
|
const curviness = segments[0]?.curviness ?? 1;
|
|
const parts = [`path: [${pathEntries.join(", ")}]`];
|
|
if (curviness !== 1) parts.push(`curviness: ${curviness}`);
|
|
if (autoRotate === true) parts.push("autoRotate: true");
|
|
else if (typeof autoRotate === "number") parts.push(`autoRotate: ${autoRotate}`);
|
|
return `{ ${parts.join(", ")} }`;
|
|
}
|
|
|
|
export function setArcPathInScript(
|
|
script: string,
|
|
animationId: string,
|
|
config: ArcPathConfig,
|
|
): string {
|
|
const loc = locateAnimation(script, animationId);
|
|
if (!loc) return script;
|
|
|
|
const varsArg = loc.target.call.varsArg;
|
|
const anim = loc.target.animation;
|
|
|
|
if (!config.enabled) {
|
|
// Disable arc: restore x/y from motionPath's last waypoint, then remove motionPath
|
|
const motionPathProp = varsArg.properties.find(
|
|
(p: AstNode) => isObjectProperty(p) && propKeyName(p) === "motionPath",
|
|
);
|
|
if (motionPathProp) {
|
|
const mpVal = motionPathProp.value;
|
|
let pathArr: AstNode[] | undefined;
|
|
if (mpVal?.type === "ObjectExpression") {
|
|
const pathProp = mpVal.properties.find(
|
|
(p: AstNode) => isObjectProperty(p) && propKeyName(p) === "path",
|
|
);
|
|
if (pathProp?.value?.type === "ArrayExpression") pathArr = pathProp.value.elements;
|
|
}
|
|
if (pathArr && pathArr.length > 0) {
|
|
const last = pathArr[pathArr.length - 1];
|
|
if (last?.type === "ObjectExpression") {
|
|
for (const p of last.properties) {
|
|
const k = propKeyName(p);
|
|
if (k === "x" || k === "y") {
|
|
const v = p.value?.value;
|
|
if (typeof v === "number") setVarsKey(varsArg, k, v);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
removeVarsKey(varsArg, "motionPath");
|
|
return recast.print(loc.parsed.ast).code;
|
|
}
|
|
|
|
const waypoints = extractArcWaypoints(anim);
|
|
if (waypoints.length < 2) return script;
|
|
|
|
// Build segments — use provided segments or create defaults
|
|
const segments: ArcPathSegment[] =
|
|
config.segments.length === waypoints.length - 1
|
|
? config.segments
|
|
: Array.from({ length: waypoints.length - 1 }, () => ({ curviness: 1 }));
|
|
|
|
const motionPathCode = buildMotionPathObjectCode({
|
|
waypoints,
|
|
segments,
|
|
autoRotate: config.autoRotate,
|
|
});
|
|
|
|
// Set motionPath on the vars
|
|
const motionPathNode = parseExpr(motionPathCode);
|
|
const existingProp = varsArg.properties.find(
|
|
(p: AstNode) => isObjectProperty(p) && propKeyName(p) === "motionPath",
|
|
);
|
|
if (existingProp) {
|
|
existingProp.value = motionPathNode;
|
|
} else {
|
|
const prop = parseExpr(`{ motionPath: ${motionPathCode} }`).properties[0];
|
|
varsArg.properties.push(prop);
|
|
}
|
|
|
|
// Strip x/y from keyframes (they're now in motionPath)
|
|
const kfNode = findKeyframesObjectNode(varsArg);
|
|
if (kfNode) {
|
|
for (const pctProp of filterPercentageProps(kfNode)) {
|
|
if (pctProp.value?.type === "ObjectExpression") {
|
|
pctProp.value.properties = pctProp.value.properties.filter((p: AstNode) => {
|
|
const k = propKeyName(p);
|
|
return k !== "x" && k !== "y";
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Strip flat x/y from vars (they're now in motionPath)
|
|
removeVarsKey(varsArg, "x");
|
|
removeVarsKey(varsArg, "y");
|
|
|
|
return recast.print(loc.parsed.ast).code;
|
|
}
|
|
|
|
export function updateArcSegmentInScript(
|
|
script: string,
|
|
animationId: string,
|
|
segmentIndex: number,
|
|
update: Partial<ArcPathSegment>,
|
|
): string {
|
|
const loc = locateAnimation(script, animationId);
|
|
if (!loc) return script;
|
|
|
|
const anim = loc.target.animation;
|
|
if (!anim.arcPath?.enabled) return script;
|
|
|
|
const segments = [...anim.arcPath.segments];
|
|
if (segmentIndex < 0 || segmentIndex >= segments.length) return script;
|
|
|
|
segments[segmentIndex] = { ...segments[segmentIndex]!, ...update };
|
|
|
|
const waypoints = extractArcWaypoints(anim);
|
|
if (waypoints.length < 2) return script;
|
|
|
|
const motionPathCode = buildMotionPathObjectCode({
|
|
waypoints,
|
|
segments,
|
|
autoRotate: anim.arcPath.autoRotate,
|
|
});
|
|
|
|
const varsArg = loc.target.call.varsArg;
|
|
const existingProp = varsArg.properties.find(
|
|
(p: AstNode) => isObjectProperty(p) && propKeyName(p) === "motionPath",
|
|
);
|
|
if (existingProp) {
|
|
existingProp.value = parseExpr(motionPathCode);
|
|
}
|
|
|
|
return recast.print(loc.parsed.ast).code;
|
|
}
|
|
|
|
/**
|
|
* Move a single motionPath waypoint (anchor) to a new position. The waypoint
|
|
* list is normalized to anchors for both straight and cubic paths, so
|
|
* `pointIndex` matches the node order the studio overlay renders; cubic control
|
|
* points are preserved. No-op when the animation/arc is missing or the index is
|
|
* out of range.
|
|
*/
|
|
export function updateMotionPathPointInScript(
|
|
script: string,
|
|
animationId: string,
|
|
pointIndex: number,
|
|
point: { x: number; y: number },
|
|
): string {
|
|
const loc = locateAnimation(script, animationId);
|
|
if (!loc) return script;
|
|
|
|
const anim = loc.target.animation;
|
|
if (!anim.arcPath?.enabled) return script;
|
|
|
|
const waypoints = extractArcWaypoints(anim);
|
|
if (pointIndex < 0 || pointIndex >= waypoints.length || waypoints.length < 2) return script;
|
|
|
|
const nextWaypoints = waypoints.map((wp, i) =>
|
|
i === pointIndex ? { x: point.x, y: point.y } : wp,
|
|
);
|
|
|
|
const motionPathCode = buildMotionPathObjectCode({
|
|
waypoints: nextWaypoints,
|
|
segments: anim.arcPath.segments,
|
|
autoRotate: anim.arcPath.autoRotate,
|
|
});
|
|
|
|
const varsArg = loc.target.call.varsArg;
|
|
const existingProp = varsArg.properties.find(
|
|
(p: AstNode) => isObjectProperty(p) && propKeyName(p) === "motionPath",
|
|
);
|
|
if (existingProp) {
|
|
existingProp.value = parseExpr(motionPathCode);
|
|
}
|
|
|
|
return recast.print(loc.parsed.ast).code;
|
|
}
|
|
|
|
/** True when any segment carries explicit cubic control points. Add/remove are
|
|
* restricted to curviness (non-cubic) paths — synthesizing control points for
|
|
* an inserted cubic anchor is out of scope. */
|
|
function hasCubicSegments(segments: ArcPathSegment[]): boolean {
|
|
return segments.some((s) => s.cp1 != null || s.cp2 != null);
|
|
}
|
|
|
|
function writeMotionPathValue(
|
|
loc: NonNullable<ReturnType<typeof locateAnimation>>,
|
|
waypoints: Array<{ x: number; y: number }>,
|
|
segments: ArcPathSegment[],
|
|
autoRotate: boolean | number,
|
|
): string {
|
|
const motionPathCode = buildMotionPathObjectCode({ waypoints, segments, autoRotate });
|
|
const varsArg = loc.target.call.varsArg;
|
|
const existingProp = varsArg.properties.find(
|
|
(p: AstNode) => isObjectProperty(p) && propKeyName(p) === "motionPath",
|
|
);
|
|
if (existingProp) existingProp.value = parseExpr(motionPathCode);
|
|
return recast.print(loc.parsed.ast).code;
|
|
}
|
|
|
|
/**
|
|
* Insert a waypoint at `index` (between existing anchors), splitting the segment
|
|
* it lands on so the new neighbor inherits its curviness. Non-cubic paths only.
|
|
* No-op for missing animation/arc, out-of-range index, or cubic paths.
|
|
*/
|
|
export function addMotionPathPointInScript(
|
|
script: string,
|
|
animationId: string,
|
|
index: number,
|
|
point: { x: number; y: number },
|
|
): string {
|
|
const loc = locateAnimation(script, animationId);
|
|
if (!loc) return script;
|
|
const anim = loc.target.animation;
|
|
if (!anim.arcPath?.enabled || hasCubicSegments(anim.arcPath.segments)) return script;
|
|
|
|
const waypoints = extractArcWaypoints(anim);
|
|
// Insert strictly between two anchors: index 1..length-1.
|
|
if (index < 1 || index > waypoints.length - 1) return script;
|
|
|
|
const segments = [...anim.arcPath.segments];
|
|
waypoints.splice(index, 0, { x: point.x, y: point.y });
|
|
const splitCurviness = segments[index - 1]?.curviness ?? 1;
|
|
segments.splice(index - 1, 0, { curviness: splitCurviness });
|
|
|
|
return writeMotionPathValue(loc, waypoints, segments, anim.arcPath.autoRotate);
|
|
}
|
|
|
|
/**
|
|
* Remove the waypoint at `index`. Refuses to drop below two anchors (a path
|
|
* can't have fewer). Non-cubic paths only. No-op for missing animation/arc,
|
|
* out-of-range index, cubic paths, or a 2-point path.
|
|
*/
|
|
export function removeMotionPathPointInScript(
|
|
script: string,
|
|
animationId: string,
|
|
index: number,
|
|
): string {
|
|
const loc = locateAnimation(script, animationId);
|
|
if (!loc) return script;
|
|
const anim = loc.target.animation;
|
|
if (!anim.arcPath?.enabled || hasCubicSegments(anim.arcPath.segments)) return script;
|
|
|
|
const waypoints = extractArcWaypoints(anim);
|
|
if (waypoints.length <= 2 || index < 0 || index >= waypoints.length) return script;
|
|
|
|
const segments = [...anim.arcPath.segments];
|
|
waypoints.splice(index, 1);
|
|
// Drop the segment on the side that still exists (last anchor → preceding segment).
|
|
segments.splice(Math.min(index, segments.length - 1), 1);
|
|
|
|
return writeMotionPathValue(loc, waypoints, segments, anim.arcPath.autoRotate);
|
|
}
|
|
|
|
/**
|
|
* Author a fresh 2-anchor motionPath tween on a target element: a straight line
|
|
* from the element's home (0,0) to `point`, gentle ease, ready for waypoint
|
|
* editing. Mirrors `addAnimationWithKeyframesToScript`.
|
|
*/
|
|
export function addMotionPathToScript(
|
|
script: string,
|
|
targetSelector: string,
|
|
position: number,
|
|
duration: number,
|
|
point: { x: number; y: number },
|
|
ease = "power1.inOut",
|
|
): { script: string; id: string | null } {
|
|
// `id: null` on the failure paths is a deliberate sentinel: callers must
|
|
// null-check before chaining (e.g. locating the new tween). An empty string
|
|
// would silently flow into selector/locate calls and match nothing.
|
|
let parsed: ParsedGsapAst;
|
|
try {
|
|
parsed = parseGsapAst(script);
|
|
} catch (e) {
|
|
console.warn("[gsap-parser] addMotionPathToScript parse failed:", e);
|
|
return { script, id: null };
|
|
}
|
|
if (parsed.located.length === 0 && parsed.detection.ref === null) {
|
|
return { script, id: null };
|
|
}
|
|
|
|
const motionPathCode = buildMotionPathObjectCode({
|
|
waypoints: [
|
|
{ x: 0, y: 0 },
|
|
{ x: point.x, y: point.y },
|
|
],
|
|
segments: [{ curviness: 1 }],
|
|
autoRotate: false,
|
|
});
|
|
const selector = JSON.stringify(targetSelector);
|
|
const varEntries = [
|
|
`motionPath: ${motionPathCode}`,
|
|
`duration: ${valueToCode(duration)}`,
|
|
`ease: ${JSON.stringify(ease)}`,
|
|
];
|
|
const stmtCode = `${parsed.timelineVar}.to(${selector}, { ${varEntries.join(", ")} }, ${valueToCode(position)});`;
|
|
const newStatement = parseScript(stmtCode).program.body[0];
|
|
insertAfterAnchor(parsed, newStatement);
|
|
|
|
const result = recast.print(parsed.ast).code;
|
|
const reParsed = parseGsapAst(result);
|
|
const newId = reParsed.located[reParsed.located.length - 1]?.id ?? null;
|
|
return { script: result, id: newId };
|
|
}
|
|
|
|
export function removeArcPathFromScript(script: string, animationId: string): string {
|
|
return setArcPathInScript(script, animationId, {
|
|
enabled: false,
|
|
autoRotate: false,
|
|
segments: [],
|
|
});
|
|
}
|
|
|
|
// ── Split Into Property Groups ────────────────────────────────────────────
|
|
|
|
/**
|
|
* Split a multi-group tween into separate per-group tweens. Each resulting
|
|
* tween contains only properties belonging to one property group (position,
|
|
* scale, rotation, visual, etc.). `transformOrigin` stays with the group that
|
|
* has the most properties. If the tween already belongs to a single group,
|
|
* returns the script unchanged with the original ID.
|
|
*/
|
|
// fallow-ignore-next-line complexity
|
|
export function splitIntoPropertyGroups(
|
|
script: string,
|
|
animationId: string,
|
|
): { script: string; ids: string[] } {
|
|
let loc = locateAnimationWithFallback(script, animationId);
|
|
if (!loc) return { script, ids: [animationId] };
|
|
|
|
const anim = loc.target.animation;
|
|
|
|
// Collect the properties to partition. For keyframed tweens, gather the
|
|
// union of all properties across all keyframes. For flat tweens, use the
|
|
// tween's own properties map.
|
|
const allPropKeys = new Set<string>();
|
|
if (anim.keyframes) {
|
|
for (const kf of anim.keyframes.keyframes) {
|
|
for (const k of Object.keys(kf.properties)) allPropKeys.add(k);
|
|
}
|
|
} else {
|
|
for (const k of Object.keys(anim.properties)) allPropKeys.add(k);
|
|
}
|
|
|
|
// Partition properties into groups (excluding transformOrigin — handled below).
|
|
const groupProps = new Map<PropertyGroupName, string[]>();
|
|
for (const key of allPropKeys) {
|
|
if (key === "transformOrigin") continue;
|
|
const group = classifyPropertyGroup(key);
|
|
let arr = groupProps.get(group);
|
|
if (!arr) {
|
|
arr = [];
|
|
groupProps.set(group, arr);
|
|
}
|
|
arr.push(key);
|
|
}
|
|
|
|
// Only one group (or zero) — no split needed.
|
|
if (groupProps.size <= 1) return { script, ids: [anim.id] };
|
|
|
|
// Assign transformOrigin to the group with the most properties.
|
|
if (allPropKeys.has("transformOrigin")) {
|
|
let largestGroup: PropertyGroupName | undefined;
|
|
let largestCount = 0;
|
|
for (const [group, props] of groupProps) {
|
|
if (props.length > largestCount) {
|
|
largestCount = props.length;
|
|
largestGroup = group;
|
|
}
|
|
}
|
|
if (largestGroup) {
|
|
groupProps.get(largestGroup)!.push("transformOrigin");
|
|
}
|
|
}
|
|
|
|
// Build per-group tweens and insert them, then remove the original.
|
|
let result = script;
|
|
|
|
// Remove the original tween first.
|
|
result = removeAnimationFromScript(result, anim.id);
|
|
|
|
// Insert one tween per group. Iteration order of the Map follows insertion
|
|
// order, which mirrors the order properties were encountered.
|
|
for (const [, props] of groupProps) {
|
|
const propSet = new Set(props);
|
|
|
|
if (anim.keyframes) {
|
|
// Build keyframes containing only this group's properties per keyframe.
|
|
const groupKeyframes: Array<{
|
|
percentage: number;
|
|
properties: Record<string, number | string>;
|
|
ease?: string;
|
|
auto?: boolean;
|
|
}> = [];
|
|
|
|
for (const kf of anim.keyframes.keyframes) {
|
|
const filtered: Record<string, number | string> = {};
|
|
for (const [k, v] of Object.entries(kf.properties)) {
|
|
if (propSet.has(k)) filtered[k] = v;
|
|
}
|
|
// Skip keyframes where this group has zero properties.
|
|
if (Object.keys(filtered).length === 0) continue;
|
|
groupKeyframes.push({
|
|
percentage: kf.percentage,
|
|
properties: filtered,
|
|
...(kf.ease ? { ease: kf.ease } : {}),
|
|
});
|
|
}
|
|
|
|
if (groupKeyframes.length === 0) continue;
|
|
|
|
const addResult = addAnimationWithKeyframesToScript(
|
|
result,
|
|
anim.targetSelector,
|
|
typeof anim.position === "number" ? anim.position : 0,
|
|
anim.duration ?? 0.5,
|
|
groupKeyframes,
|
|
anim.keyframes.easeEach ?? anim.ease,
|
|
);
|
|
result = addResult.script;
|
|
} else {
|
|
// Flat tween — filter properties to this group.
|
|
const groupProperties: Record<string, number | string> = {};
|
|
for (const [k, v] of Object.entries(anim.properties)) {
|
|
if (propSet.has(k)) groupProperties[k] = v;
|
|
}
|
|
if (Object.keys(groupProperties).length === 0) continue;
|
|
|
|
let fromProperties: Record<string, number | string> | undefined;
|
|
if (anim.method === "fromTo" && anim.fromProperties) {
|
|
fromProperties = {};
|
|
for (const [k, v] of Object.entries(anim.fromProperties)) {
|
|
if (propSet.has(k)) fromProperties[k] = v;
|
|
}
|
|
}
|
|
|
|
const addResult = addAnimationToScript(result, {
|
|
targetSelector: anim.targetSelector,
|
|
method: anim.method,
|
|
position: anim.position,
|
|
duration: anim.duration,
|
|
ease: anim.ease,
|
|
properties: groupProperties,
|
|
fromProperties,
|
|
extras: anim.extras,
|
|
});
|
|
result = addResult.script;
|
|
}
|
|
}
|
|
|
|
// Re-parse to collect the new IDs.
|
|
const reParsed = parseGsapAst(result);
|
|
const newIds = reParsed.located
|
|
.filter((l) => l.animation.targetSelector === anim.targetSelector)
|
|
.map((l) => l.id);
|
|
|
|
return { script: result, ids: newIds };
|
|
}
|
|
|
|
/**
|
|
* Replace a dynamic loop that generates multiple tween calls with individual
|
|
* static `tl.to()` calls — one per element. Finds the loop containing the
|
|
* animation and replaces the entire loop body with unrolled static calls.
|
|
*/
|
|
export function unrollDynamicAnimations(
|
|
script: string,
|
|
animationId: string,
|
|
elements: Array<{
|
|
selector: string;
|
|
keyframes: Array<{ percentage: number; properties: Record<string, number | string> }>;
|
|
easeEach?: string;
|
|
}>,
|
|
): string {
|
|
const loc = locateAnimation(script, animationId);
|
|
if (!loc) return script;
|
|
|
|
const varsArg = loc.target.call.varsArg;
|
|
|
|
// Read duration and ease from the original tween vars
|
|
const durationVal = extractLiteralValue(findPropertyNode(varsArg, "duration"), loc.parsed.scope);
|
|
const easeVal = extractLiteralValue(findPropertyNode(varsArg, "ease"), loc.parsed.scope);
|
|
const duration = typeof durationVal === "number" ? durationVal : 8;
|
|
const ease = typeof easeVal === "string" ? easeVal : "none";
|
|
const posArg = loc.target.call.positionArg;
|
|
const position = posArg ? extractLiteralValue(posArg, loc.parsed.scope) : 0;
|
|
const posCode =
|
|
typeof position === "number"
|
|
? String(position)
|
|
: typeof position === "string"
|
|
? JSON.stringify(position)
|
|
: "0";
|
|
|
|
// Find the enclosing loop (for/forEach) by walking up the AST path
|
|
let loopNode: AstNode | null = null;
|
|
let current = loc.target.call.path;
|
|
while (current) {
|
|
const node = current.node ?? current.value;
|
|
if (
|
|
node?.type === "ForStatement" ||
|
|
node?.type === "ForInStatement" ||
|
|
node?.type === "ForOfStatement" ||
|
|
node?.type === "WhileStatement"
|
|
) {
|
|
loopNode = node;
|
|
break;
|
|
}
|
|
if (
|
|
node?.type === "ExpressionStatement" &&
|
|
node.expression?.type === "CallExpression" &&
|
|
node.expression.callee?.property?.name === "forEach"
|
|
) {
|
|
loopNode = node;
|
|
break;
|
|
}
|
|
current = current.parent ?? current.parentPath;
|
|
}
|
|
|
|
// Build replacement code: individual tl.to() calls for each element
|
|
const calls: string[] = [];
|
|
for (const el of elements) {
|
|
const kfCode = buildKeyframeObjectCode(sortedKeyframes(el.keyframes), {
|
|
easeEach: el.easeEach,
|
|
});
|
|
calls.push(
|
|
`${loc.parsed.timelineVar}.to(${JSON.stringify(el.selector)}, { keyframes: ${kfCode}, duration: ${duration}, ease: ${JSON.stringify(ease)} }, ${posCode});`,
|
|
);
|
|
}
|
|
|
|
const replacement = calls.join("\n ");
|
|
|
|
if (loopNode) {
|
|
// Replace the entire loop with the unrolled calls
|
|
const start = loopNode.start ?? loopNode.range?.[0];
|
|
const end = loopNode.end ?? loopNode.range?.[1];
|
|
if (typeof start === "number" && typeof end === "number") {
|
|
return script.slice(0, start) + replacement + script.slice(end);
|
|
}
|
|
}
|
|
|
|
// Fallback: replace just the tween call's enclosing expression statement
|
|
const stmtNode = loc.target.call.path?.parent?.node ?? loc.target.call.path?.parentPath?.node;
|
|
if (stmtNode?.type === "ExpressionStatement") {
|
|
const start = stmtNode.start ?? stmtNode.range?.[0];
|
|
const end = stmtNode.end ?? stmtNode.range?.[1];
|
|
if (typeof start === "number" && typeof end === "number") {
|
|
return script.slice(0, start) + replacement + script.slice(end);
|
|
}
|
|
}
|
|
|
|
return script;
|
|
}
|