mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 06:30:03 +00:00
refactor(core): simplify packages/core — dead code, dedup, type safety (#1413)
- Delete unused mediaPreloader module, 5 dead RuntimeState fields, emitPerformanceMetric, lintScriptUrls, 5 variable type guards - Consolidate compiler utilities: unify CSS URL regex, relative URL predicate, MIME map, @import regex, bulk asset rewrite delegation - Cache extractGsapWindows per script (eliminates 2 redundant recast parses per lint run), share stripJsComments and script extraction - Deduplicate GSAP parser: share serializeValue/safeJsKey, centralize converted-id fallback (6 sites), keyframe codegen (3 sites), waypoint extraction, insert-after-anchor, script hoisting - Replace 88 bare any annotations with typed AstNode/AstPath interfaces - Derive RuntimeBridgeControlAction from HyperframeControlAction, alias RuntimePickerElementInfo, share macOS font profiler - Gate generateHyperframesStyles on includeStyles, collapse 4 GSAP property mutation cases into 2 - Extract magic numbers into named constants, replace 5 double casts with type guards and typed accessors (runtime/globals.ts), reduce function complexity in htmlParser and files route
This commit is contained in:
@@ -18,6 +18,8 @@ import {
|
||||
type GsapMethod,
|
||||
type GsapPercentageKeyframe,
|
||||
type ParsedGsap,
|
||||
serializeValue as valueToCode,
|
||||
safeJsKey as safeKey,
|
||||
} from "./gsapSerialize";
|
||||
|
||||
export type {
|
||||
@@ -52,6 +54,21 @@ 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>;
|
||||
@@ -66,10 +83,10 @@ function parseScript(script: string) {
|
||||
});
|
||||
}
|
||||
|
||||
function collectScopeBindings(ast: any): ScopeBindings {
|
||||
function collectScopeBindings(ast: AstNode): ScopeBindings {
|
||||
const bindings = new Map<string, number | string | boolean>();
|
||||
recast.types.visit(ast, {
|
||||
visitVariableDeclarator(path: any) {
|
||||
visitVariableDeclarator(path: AstPath) {
|
||||
const name = path.node.id?.name;
|
||||
const init = path.node.init;
|
||||
if (name && init) {
|
||||
@@ -83,7 +100,7 @@ function collectScopeBindings(ast: any): ScopeBindings {
|
||||
}
|
||||
|
||||
function resolveNode(
|
||||
node: any,
|
||||
node: AstNode | undefined,
|
||||
scope: ReadonlyMap<string, number | string | boolean>,
|
||||
): number | string | boolean | undefined {
|
||||
if (!node) return undefined;
|
||||
@@ -127,7 +144,7 @@ function resolveNode(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function extractLiteralValue(node: any, scope: ScopeBindings): unknown {
|
||||
function extractLiteralValue(node: AstNode | undefined, scope: ScopeBindings): unknown {
|
||||
return resolveNode(node, scope);
|
||||
}
|
||||
|
||||
@@ -156,7 +173,7 @@ const SCOPE_NODE_TYPES = new Set([
|
||||
* `gsap.utils.toArray(".sel")` — return the CSS selector it resolves to.
|
||||
* `getElementById("id")` maps to `#id`. Returns null for anything else.
|
||||
*/
|
||||
function selectorFromQueryCall(node: any, scope: ScopeBindings): string | null {
|
||||
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;
|
||||
@@ -169,7 +186,7 @@ function selectorFromQueryCall(node: any, scope: ScopeBindings): string | null {
|
||||
}
|
||||
|
||||
/** The nearest enclosing function/program node — the binding scope of `path`. */
|
||||
function enclosingScopeNode(path: any): any {
|
||||
function enclosingScopeNode(path: AstPath): AstNode | null {
|
||||
let p = path?.parentPath;
|
||||
while (p) {
|
||||
if (SCOPE_NODE_TYPES.has(p.node?.type)) return p.node;
|
||||
@@ -179,8 +196,8 @@ function enclosingScopeNode(path: any): any {
|
||||
}
|
||||
|
||||
/** Scope nodes enclosing `path`, innermost first. */
|
||||
function scopeChainOf(path: any): any[] {
|
||||
const chain: any[] = [];
|
||||
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);
|
||||
@@ -194,7 +211,7 @@ type TargetBindings = Map<any, Map<string, string>>;
|
||||
|
||||
function addBinding(
|
||||
bindings: TargetBindings,
|
||||
scopeNode: any,
|
||||
scopeNode: AstNode,
|
||||
name: string,
|
||||
selector: string,
|
||||
): void {
|
||||
@@ -212,21 +229,23 @@ function addBinding(
|
||||
* (2) iteration callback params (`coll.forEach(el => …)`), whose element type is
|
||||
* the collection's selector — resolved against the pass-1 bindings.
|
||||
*/
|
||||
function collectTargetBindings(ast: any, scope: ScopeBindings): TargetBindings {
|
||||
function collectTargetBindings(ast: AstNode, scope: ScopeBindings): TargetBindings {
|
||||
const bindings: TargetBindings = new Map();
|
||||
|
||||
recast.types.visit(ast, {
|
||||
visitVariableDeclarator(path: any) {
|
||||
visitVariableDeclarator(path: AstPath) {
|
||||
const name = path.node.id?.name;
|
||||
const selector = selectorFromQueryCall(path.node.init, scope);
|
||||
if (name && selector !== null) addBinding(bindings, enclosingScopeNode(path), name, selector);
|
||||
const scopeNode = enclosingScopeNode(path);
|
||||
if (name && selector !== null && scopeNode) addBinding(bindings, scopeNode, name, selector);
|
||||
this.traverse(path);
|
||||
},
|
||||
visitAssignmentExpression(path: any) {
|
||||
visitAssignmentExpression(path: AstPath) {
|
||||
const left = path.node.left;
|
||||
const selector = selectorFromQueryCall(path.node.right, scope);
|
||||
if (left?.type === "Identifier" && selector !== null) {
|
||||
addBinding(bindings, enclosingScopeNode(path), left.name, selector);
|
||||
const scopeNode = enclosingScopeNode(path);
|
||||
if (left?.type === "Identifier" && selector !== null && scopeNode) {
|
||||
addBinding(bindings, scopeNode, left.name, selector);
|
||||
}
|
||||
this.traverse(path);
|
||||
},
|
||||
@@ -234,7 +253,7 @@ function collectTargetBindings(ast: any, scope: ScopeBindings): TargetBindings {
|
||||
|
||||
// Pass 2: forEach/map callback params take the collection's selector.
|
||||
recast.types.visit(ast, {
|
||||
visitCallExpression(path: any) {
|
||||
visitCallExpression(path: AstPath) {
|
||||
const node = path.node;
|
||||
const callee = node.callee;
|
||||
if (
|
||||
@@ -256,7 +275,7 @@ function collectTargetBindings(ast: any, scope: ScopeBindings): TargetBindings {
|
||||
return bindings;
|
||||
}
|
||||
|
||||
function isFunctionNode(node: any): boolean {
|
||||
function isFunctionNode(node: AstNode): boolean {
|
||||
return (
|
||||
node?.type === "ArrowFunctionExpression" ||
|
||||
node?.type === "FunctionExpression" ||
|
||||
@@ -266,8 +285,8 @@ function isFunctionNode(node: any): boolean {
|
||||
|
||||
/** Resolve the selector a `.forEach`/`.map` is iterating over (variable or inline call). */
|
||||
function resolveCollectionSelector(
|
||||
node: any,
|
||||
callPath: any,
|
||||
node: AstNode,
|
||||
callPath: AstPath,
|
||||
scope: ScopeBindings,
|
||||
bindings: TargetBindings,
|
||||
): string | null {
|
||||
@@ -277,7 +296,7 @@ function resolveCollectionSelector(
|
||||
}
|
||||
|
||||
/** Resolve a variable name to its selector using the lexical scope chain of `path`. */
|
||||
function lookupBinding(name: string, path: any, bindings: TargetBindings): string | null {
|
||||
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;
|
||||
@@ -294,8 +313,8 @@ function lookupBinding(name: string, path: any, bindings: TargetBindings): strin
|
||||
* runtime-computed selector).
|
||||
*/
|
||||
function resolveTargetSelector(
|
||||
node: any,
|
||||
path: any,
|
||||
node: AstNode,
|
||||
path: AstPath,
|
||||
scope: ScopeBindings,
|
||||
bindings: TargetBindings,
|
||||
): string | null {
|
||||
@@ -311,7 +330,7 @@ function resolveTargetSelector(
|
||||
}
|
||||
if (node.type === "ArrayExpression") {
|
||||
const parts = node.elements
|
||||
.map((el: any) => resolveTargetSelector(el, path, scope, bindings))
|
||||
.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;
|
||||
}
|
||||
@@ -322,7 +341,7 @@ function resolveTargetSelector(
|
||||
return null;
|
||||
}
|
||||
|
||||
function objectExpressionToRecord(node: any, scope: ScopeBindings): Record<string, unknown> {
|
||||
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 ?? []) {
|
||||
@@ -342,7 +361,7 @@ function objectExpressionToRecord(node: any, scope: ScopeBindings): Record<strin
|
||||
|
||||
// ── Timeline Variable Detection ─────────────────────────────────────────────
|
||||
|
||||
function isGsapTimelineCall(node: any): boolean {
|
||||
function isGsapTimelineCall(node: AstNode): boolean {
|
||||
return (
|
||||
node?.type === "CallExpression" &&
|
||||
node.callee?.type === "MemberExpression" &&
|
||||
@@ -363,13 +382,13 @@ interface TimelineDetection {
|
||||
}
|
||||
|
||||
function extractTimelineDefaults(
|
||||
callNode: any,
|
||||
callNode: AstNode,
|
||||
scope: ScopeBindings,
|
||||
): TimelineDefaults | undefined {
|
||||
const arg = callNode.arguments?.[0];
|
||||
if (!arg || arg.type !== "ObjectExpression") return undefined;
|
||||
const defaultsProp = arg.properties?.find(
|
||||
(p: any) => isObjectProperty(p) && propKeyName(p) === "defaults",
|
||||
(p: AstNode) => isObjectProperty(p) && propKeyName(p) === "defaults",
|
||||
);
|
||||
if (!defaultsProp?.value || defaultsProp.value.type !== "ObjectExpression") return undefined;
|
||||
const record = objectExpressionToRecord(defaultsProp.value, scope);
|
||||
@@ -379,13 +398,13 @@ function extractTimelineDefaults(
|
||||
return Object.keys(result).length > 0 ? result : undefined;
|
||||
}
|
||||
|
||||
function findTimelineVar(ast: any, scope?: ScopeBindings): TimelineDetection {
|
||||
function findTimelineVar(ast: AstNode, scope?: ScopeBindings): TimelineDetection {
|
||||
let timelineVar: string | null = null;
|
||||
let timelineCount = 0;
|
||||
let defaults: TimelineDefaults | undefined;
|
||||
const emptyScope: ScopeBindings = scope ?? new Map();
|
||||
recast.types.visit(ast, {
|
||||
visitVariableDeclarator(path: any) {
|
||||
visitVariableDeclarator(path: AstPath) {
|
||||
if (isGsapTimelineCall(path.node.init)) {
|
||||
timelineCount += 1;
|
||||
if (!timelineVar) {
|
||||
@@ -395,7 +414,7 @@ function findTimelineVar(ast: any, scope?: ScopeBindings): TimelineDetection {
|
||||
}
|
||||
this.traverse(path);
|
||||
},
|
||||
visitAssignmentExpression(path: any) {
|
||||
visitAssignmentExpression(path: AstPath) {
|
||||
if (isGsapTimelineCall(path.node.right)) {
|
||||
timelineCount += 1;
|
||||
if (!timelineVar) {
|
||||
@@ -413,20 +432,20 @@ function findTimelineVar(ast: any, scope?: ScopeBindings): TimelineDetection {
|
||||
// ── Find All Tween Calls ────────────────────────────────────────────────────
|
||||
|
||||
interface TweenCallInfo {
|
||||
path: any;
|
||||
node: any;
|
||||
path: AstPath;
|
||||
node: AstNode;
|
||||
method: GsapMethod;
|
||||
selector: string;
|
||||
varsArg: any;
|
||||
fromArg?: any;
|
||||
positionArg?: any;
|
||||
varsArg: AstNode;
|
||||
fromArg?: AstNode;
|
||||
positionArg?: AstNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: any, timelineVar: string): boolean {
|
||||
function isTimelineRootedCall(callNode: AstNode, timelineVar: string): boolean {
|
||||
let obj = callNode.callee?.object;
|
||||
while (obj?.type === "CallExpression") {
|
||||
obj = obj.callee?.object;
|
||||
@@ -435,14 +454,14 @@ function isTimelineRootedCall(callNode: any, timelineVar: string): boolean {
|
||||
}
|
||||
|
||||
function findAllTweenCalls(
|
||||
ast: any,
|
||||
ast: AstNode,
|
||||
timelineVar: string,
|
||||
scope: ScopeBindings,
|
||||
targetBindings: TargetBindings,
|
||||
): TweenCallInfo[] {
|
||||
const results: TweenCallInfo[] = [];
|
||||
recast.types.visit(ast, {
|
||||
visitCallExpression(path: any) {
|
||||
visitCallExpression(path: AstPath) {
|
||||
const node = path.node;
|
||||
const callee = node.callee;
|
||||
if (
|
||||
@@ -511,13 +530,13 @@ const EXTRAS_KEYS = new Set([
|
||||
* 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: any, key: string): string | undefined {
|
||||
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: any, key: string): any | undefined {
|
||||
function findPropertyNode(varsArgNode: AstNode, key: string): AstNode | undefined {
|
||||
if (varsArgNode?.type !== "ObjectExpression") return undefined;
|
||||
for (const prop of varsArgNode.properties ?? []) {
|
||||
if (!isObjectProperty(prop)) continue;
|
||||
@@ -531,7 +550,7 @@ function findPropertyNode(varsArgNode: any, key: string): any | undefined {
|
||||
const PERCENTAGE_KEY_RE = /^(\d+(?:\.\d+)?)%$/;
|
||||
|
||||
/** Extract a string-valued ease or easeEach from an AST property node. */
|
||||
function tryResolveStringProp(propValue: any, scope: ScopeBindings): string | undefined {
|
||||
function tryResolveStringProp(propValue: AstNode, scope: ScopeBindings): string | undefined {
|
||||
const val = resolveNode(propValue, scope);
|
||||
return typeof val === "string" ? val : undefined;
|
||||
}
|
||||
@@ -542,7 +561,10 @@ function tryResolveStringProp(propValue: any, scope: ScopeBindings): string | un
|
||||
* percentage objects, object arrays, and simple (property-array) objects.
|
||||
*/
|
||||
// fallow-ignore-next-line complexity
|
||||
function parseKeyframesNode(node: any, scope: ScopeBindings): GsapKeyframesData | undefined {
|
||||
function parseKeyframesNode(
|
||||
node: AstNode | undefined,
|
||||
scope: ScopeBindings,
|
||||
): GsapKeyframesData | undefined {
|
||||
if (!node) return undefined;
|
||||
|
||||
// ── Object array format: keyframes: [ { x: 0, duration: 0.5 }, ... ] ──
|
||||
@@ -576,7 +598,7 @@ function parseKeyframesNode(node: any, scope: ScopeBindings): GsapKeyframesData
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
function parsePercentageKeyframes(node: any, scope: ScopeBindings): GsapKeyframesData {
|
||||
function parsePercentageKeyframes(node: AstNode, scope: ScopeBindings): GsapKeyframesData {
|
||||
const keyframes: GsapPercentageKeyframe[] = [];
|
||||
let ease: string | undefined;
|
||||
let easeEach: string | undefined;
|
||||
@@ -617,9 +639,12 @@ function parsePercentageKeyframes(node: any, scope: ScopeBindings): GsapKeyframe
|
||||
};
|
||||
}
|
||||
|
||||
function computeKeyframesTotalDuration(varsNode: any, scope: ScopeBindings): number | undefined {
|
||||
function computeKeyframesTotalDuration(
|
||||
varsNode: AstNode,
|
||||
scope: ScopeBindings,
|
||||
): number | undefined {
|
||||
const kfNode = (varsNode.properties ?? []).find(
|
||||
(p: any) => (p.key?.name ?? p.key?.value) === "keyframes",
|
||||
(p: AstNode) => (p.key?.name ?? p.key?.value) === "keyframes",
|
||||
)?.value;
|
||||
if (!kfNode || kfNode.type !== "ArrayExpression") return undefined;
|
||||
let total = 0;
|
||||
@@ -632,7 +657,7 @@ function computeKeyframesTotalDuration(varsNode: any, scope: ScopeBindings): num
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
function parseObjectArrayKeyframes(node: any, scope: ScopeBindings): GsapKeyframesData {
|
||||
function parseObjectArrayKeyframes(node: AstNode, scope: ScopeBindings): GsapKeyframesData {
|
||||
const elements = node.elements ?? [];
|
||||
const raw: Array<{
|
||||
properties: Record<string, number | string>;
|
||||
@@ -693,7 +718,7 @@ function parseObjectArrayKeyframes(node: any, scope: ScopeBindings): GsapKeyfram
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
function parseSimpleArrayKeyframes(node: any, scope: ScopeBindings): GsapKeyframesData {
|
||||
function parseSimpleArrayKeyframes(node: AstNode, scope: ScopeBindings): GsapKeyframesData {
|
||||
const arrayProps: Map<string, (number | string)[]> = new Map();
|
||||
let ease: string | undefined;
|
||||
let easeEach: string | undefined;
|
||||
@@ -747,10 +772,13 @@ interface MotionPathParseResult {
|
||||
waypoints: Array<{ x: number; y: number }>;
|
||||
}
|
||||
|
||||
function parseMotionPathNode(node: any, scope: ScopeBindings): MotionPathParseResult | undefined {
|
||||
function parseMotionPathNode(
|
||||
node: AstNode | undefined,
|
||||
scope: ScopeBindings,
|
||||
): MotionPathParseResult | undefined {
|
||||
if (!node) return undefined;
|
||||
|
||||
let pathNode: any;
|
||||
let pathNode: AstNode | undefined;
|
||||
let autoRotate: boolean | number = false;
|
||||
let curviness = 1;
|
||||
let isCubic = false;
|
||||
@@ -910,7 +938,7 @@ function tweenCallToAnimation(
|
||||
}
|
||||
|
||||
const hasPositionArg = !!call.positionArg;
|
||||
const posVal = hasPositionArg ? extractLiteralValue(call.positionArg, scope) : 0;
|
||||
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;
|
||||
@@ -1056,7 +1084,7 @@ function assignStableIds(anims: Omit<GsapAnimation, "id">[]): GsapAnimation[] {
|
||||
// ── Shared parse (AST + located tween calls) ────────────────────────────────
|
||||
|
||||
interface ParsedGsapAst {
|
||||
ast: any;
|
||||
ast: AstNode;
|
||||
scope: ScopeBindings;
|
||||
timelineVar: string;
|
||||
detection: TimelineDetection;
|
||||
@@ -1132,32 +1160,21 @@ export function parseGsapScript(script: string): ParsedGsap {
|
||||
// in real compositions (variable targets, interleaved `gsap.set`, IIFE wrapper)
|
||||
// without regenerating — and discarding — the surrounding code.
|
||||
|
||||
/** Render a model value to the JS source it should emit as. Mirrors gsapSerialize. */
|
||||
function valueToCode(value: number | string): string {
|
||||
if (typeof value === "string" && value.startsWith("__raw:")) return value.slice(6);
|
||||
if (typeof value === "string") return JSON.stringify(value);
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function safeKey(key: string): string {
|
||||
return /^[A-Za-z_$][\w$]*$/.test(key) ? key : JSON.stringify(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a value/expression snippet into a standalone AST expression node.
|
||||
* Uses an assignment (`__hf__ = <code>`) rather than wrapping in parens so an
|
||||
* object literal parses as an expression without recast re-emitting the
|
||||
* surrounding parentheses.
|
||||
*/
|
||||
function parseExpr(code: string): any {
|
||||
function parseExpr(code: string): AstNode {
|
||||
return parseScript(`__hf__ = ${code};`).program.body[0].expression.right;
|
||||
}
|
||||
|
||||
function propKeyName(prop: any): string | undefined {
|
||||
function propKeyName(prop: AstNode): string | undefined {
|
||||
return prop?.key?.name ?? prop?.key?.value;
|
||||
}
|
||||
|
||||
function isObjectProperty(prop: any): boolean {
|
||||
function isObjectProperty(prop: AstNode): boolean {
|
||||
return prop?.type === "ObjectProperty" || prop?.type === "Property";
|
||||
}
|
||||
|
||||
@@ -1166,16 +1183,16 @@ function isEditablePropertyKey(key: string): boolean {
|
||||
return !BUILTIN_VAR_KEYS.has(key) && !DROPPED_VAR_KEYS.has(key) && !EXTRAS_KEYS.has(key);
|
||||
}
|
||||
|
||||
function makeObjectProperty(key: string, value: number | string): any {
|
||||
function makeObjectProperty(key: string, value: number | string): 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: any, key: string, value: number | string): void {
|
||||
function setVarsKey(varsArg: AstNode, key: string, value: number | string): void {
|
||||
if (varsArg?.type !== "ObjectExpression") return;
|
||||
const existing = varsArg.properties.find(
|
||||
(p: any) => isObjectProperty(p) && propKeyName(p) === key,
|
||||
(p: AstNode) => isObjectProperty(p) && propKeyName(p) === key,
|
||||
);
|
||||
if (existing) {
|
||||
existing.value = parseExpr(valueToCode(value));
|
||||
@@ -1188,9 +1205,9 @@ function setVarsKey(varsArg: any, key: string, value: number | string): void {
|
||||
* Filter an ObjectExpression's properties, keeping non-editable keys
|
||||
* and delegating the keep/drop decision for editable keys to `shouldKeep`.
|
||||
*/
|
||||
function filterEditableKeys(varsArg: any, shouldKeep: (key: string) => boolean): void {
|
||||
function filterEditableKeys(varsArg: AstNode, shouldKeep: (key: string) => boolean): void {
|
||||
if (varsArg?.type !== "ObjectExpression") return;
|
||||
varsArg.properties = varsArg.properties.filter((p: any) => {
|
||||
varsArg.properties = varsArg.properties.filter((p: AstNode) => {
|
||||
if (!isObjectProperty(p)) return true;
|
||||
const key = propKeyName(p);
|
||||
if (typeof key !== "string") return true;
|
||||
@@ -1205,7 +1222,7 @@ function filterEditableKeys(varsArg: any, shouldKeep: (key: string) => boolean):
|
||||
* untouched.
|
||||
*/
|
||||
function reconcileEditableProperties(
|
||||
varsArg: any,
|
||||
varsArg: AstNode,
|
||||
newProps: Record<string, number | string>,
|
||||
): void {
|
||||
filterEditableKeys(varsArg, (key) => key in newProps);
|
||||
@@ -1215,21 +1232,23 @@ function reconcileEditableProperties(
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
function applyUpdatesToCall(call: TweenCallInfo, updates: Partial<GsapAnimation>): void {
|
||||
if (updates.properties) reconcileEditableProperties(call.varsArg, updates.properties);
|
||||
if (updates.fromProperties && call.method === "fromTo") {
|
||||
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.ease !== undefined) {
|
||||
const kfNode = findKeyframesObjectNode(call.varsArg);
|
||||
if (kfNode) {
|
||||
setVarsKey(kfNode, "easeEach", updates.ease);
|
||||
removeVarsKey(call.varsArg, "ease");
|
||||
} else {
|
||||
setVarsKey(call.varsArg, "ease", updates.ease);
|
||||
}
|
||||
}
|
||||
if (updates.ease !== undefined) applyEaseUpdate(call.varsArg, updates.ease);
|
||||
if (updates.position !== undefined) {
|
||||
const posIdx = call.method === "fromTo" ? 3 : 2;
|
||||
call.node.arguments[posIdx] = parseExpr(valueToCode(updates.position));
|
||||
@@ -1237,7 +1256,7 @@ function applyUpdatesToCall(call: TweenCallInfo, updates: Partial<GsapAnimation>
|
||||
}
|
||||
|
||||
/** Walk up to the enclosing ExpressionStatement path (for prune / insertAfter). */
|
||||
function findStatementPath(path: any): any {
|
||||
function findStatementPath(path: AstPath): AstPath | null {
|
||||
let p = path;
|
||||
while (p) {
|
||||
if (p.node?.type === "ExpressionStatement") return p;
|
||||
@@ -1246,6 +1265,18 @@ function findStatementPath(path: any): any {
|
||||
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);
|
||||
@@ -1328,17 +1359,7 @@ export function addAnimationToScript(
|
||||
const id = `anim-${Date.now()}`;
|
||||
const statementCode = buildTweenStatementCode(parsed.timelineVar, animation);
|
||||
const newStatement = parseScript(statementCode).program.body[0];
|
||||
|
||||
const lastCall = parsed.located[parsed.located.length - 1]?.call;
|
||||
const anchorPath = lastCall
|
||||
? findStatementPath(lastCall.path)
|
||||
: findTimelineDeclarationPath(parsed.ast, parsed.timelineVar);
|
||||
|
||||
if (anchorPath) {
|
||||
anchorPath.insertAfter(newStatement);
|
||||
} else {
|
||||
parsed.ast.program.body.push(newStatement);
|
||||
}
|
||||
insertAfterAnchor(parsed, newStatement);
|
||||
return { script: recast.print(parsed.ast).code, id };
|
||||
}
|
||||
|
||||
@@ -1367,31 +1388,14 @@ export function addAnimationWithKeyframesToScript(
|
||||
}
|
||||
|
||||
const selector = JSON.stringify(targetSelector);
|
||||
const kfEntries = keyframes.map((kf) => {
|
||||
const propEntries = Object.entries(kf.properties).map(
|
||||
([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`,
|
||||
);
|
||||
if (kf.ease) propEntries.push(`ease: ${JSON.stringify(kf.ease)}`);
|
||||
if (kf.auto) propEntries.push(`_auto: 1`);
|
||||
return `${JSON.stringify(`${kf.percentage}%`)}: { ${propEntries.join(", ")} }`;
|
||||
});
|
||||
const kfCode = `{ ${kfEntries.join(", ")} }`;
|
||||
const kfCode = buildKeyframeObjectCode(keyframes);
|
||||
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];
|
||||
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);
|
||||
}
|
||||
insertAfterAnchor(parsed, newStatement);
|
||||
|
||||
const result = recast.print(parsed.ast).code;
|
||||
const reParsed = parseGsapAst(result);
|
||||
@@ -1400,10 +1404,10 @@ export function addAnimationWithKeyframesToScript(
|
||||
}
|
||||
|
||||
/** Find the statement path of `const <timelineVar> = gsap.timeline(...)`. */
|
||||
function findTimelineDeclarationPath(ast: any, timelineVar: string): any {
|
||||
let found: any = null;
|
||||
function findTimelineDeclarationPath(ast: AstNode, timelineVar: string): AstPath | null {
|
||||
let found: AstPath | null = null;
|
||||
recast.types.visit(ast, {
|
||||
visitVariableDeclaration(path: any) {
|
||||
visitVariableDeclaration(path: AstPath) {
|
||||
if (found) return false;
|
||||
for (const decl of path.node.declarations ?? []) {
|
||||
if (decl.id?.name === timelineVar && isGsapTimelineCall(decl.init)) {
|
||||
@@ -1418,10 +1422,10 @@ function findTimelineDeclarationPath(ast: any, timelineVar: string): any {
|
||||
}
|
||||
|
||||
/** Find the call that chains off `targetNode` (i.e. whose callee object IS it). */
|
||||
function findChainParentCall(stmtNode: any, targetNode: any): any {
|
||||
let found: any = null;
|
||||
function findChainParentCall(stmtNode: AstNode, targetNode: AstNode): AstNode | null {
|
||||
let found: AstNode | null = null;
|
||||
recast.types.visit(stmtNode, {
|
||||
visitCallExpression(p: any) {
|
||||
visitCallExpression(p: AstPath) {
|
||||
if (found) return false;
|
||||
if (p.node.callee?.type === "MemberExpression" && p.node.callee.object === targetNode) {
|
||||
found = p.node;
|
||||
@@ -1645,11 +1649,30 @@ function keyframePropsToCode(kf: { properties: Record<string, number | 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: any, key: string): void {
|
||||
function removeVarsKey(varsArg: AstNode, key: string): void {
|
||||
if (varsArg?.type !== "ObjectExpression") return;
|
||||
varsArg.properties = varsArg.properties.filter(
|
||||
(p: any) => !(isObjectProperty(p) && propKeyName(p) === key),
|
||||
(p: AstNode) => !(isObjectProperty(p) && propKeyName(p) === key),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1661,7 +1684,10 @@ function percentageFromKey(key: string): number {
|
||||
|
||||
const PCT_TOLERANCE = 2;
|
||||
|
||||
function findKeyframePropByPct(kfNode: any, percentage: number): { idx: number; prop: any } | null {
|
||||
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;
|
||||
@@ -1675,7 +1701,10 @@ function findKeyframePropByPct(kfNode: any, percentage: number): { idx: number;
|
||||
}
|
||||
|
||||
/** Build a keyframe value AST node from properties and optional ease. */
|
||||
function buildKeyframeValueNode(properties: Record<string, number | string>, ease?: string): any {
|
||||
function buildKeyframeValueNode(
|
||||
properties: Record<string, number | string>,
|
||||
ease?: string,
|
||||
): AstNode {
|
||||
const entries = Object.entries(properties).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`);
|
||||
if (ease) entries.push(`ease: ${JSON.stringify(ease)}`);
|
||||
return parseExpr(`{ ${entries.join(", ")} }`);
|
||||
@@ -1696,15 +1725,26 @@ function locateAnimation(
|
||||
return target ? { parsed, target } : null;
|
||||
}
|
||||
|
||||
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) return null;
|
||||
return locateAnimation(script, convertedId);
|
||||
}
|
||||
|
||||
/** Find the keyframes ObjectExpression node on a tween's varsArg, or null. */
|
||||
function findKeyframesObjectNode(varsArg: any): any | null {
|
||||
function findKeyframesObjectNode(varsArg: AstNode): AstNode | null {
|
||||
const node = findPropertyNode(varsArg, "keyframes");
|
||||
return node?.type === "ObjectExpression" ? node : null;
|
||||
}
|
||||
|
||||
/** Filter percentage-keyed properties from a keyframes ObjectExpression. */
|
||||
function filterPercentageProps(kfNode: any): any[] {
|
||||
return kfNode.properties.filter((p: any) => {
|
||||
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);
|
||||
@@ -1716,7 +1756,7 @@ function filterPercentageProps(kfNode: any): any[] {
|
||||
* then remove `keyframes` and `easeEach` from varsArg. Skips the `ease` key
|
||||
* from the record (per-keyframe ease, not a tween ease).
|
||||
*/
|
||||
function collapseKeyframesToFlat(varsArg: any, record: Record<string, unknown>): void {
|
||||
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);
|
||||
@@ -1731,11 +1771,7 @@ function collapseKeyframesToFlat(varsArg: any, record: Record<string, unknown>):
|
||||
* updateKeyframeInScript.
|
||||
*/
|
||||
function locateKeyframeCtx(script: string, animationId: string, percentage: number) {
|
||||
let loc = locateAnimation(script, animationId);
|
||||
if (!loc) {
|
||||
const convertedId = animationId.replace(/-from-|-fromTo-/, "-to-");
|
||||
loc = locateAnimation(script, convertedId);
|
||||
}
|
||||
const loc = locateAnimationWithFallback(script, animationId);
|
||||
if (!loc) return null;
|
||||
const kfNode = findKeyframesObjectNode(loc.target.call.varsArg);
|
||||
if (!kfNode) return null;
|
||||
@@ -1754,21 +1790,13 @@ export function addKeyframeToScript(
|
||||
ease?: string,
|
||||
backfillDefaults?: Record<string, number | string>,
|
||||
): string {
|
||||
let loc = locateAnimation(script, animationId);
|
||||
if (!loc) {
|
||||
const convertedId = animationId.replace(/-from-|-fromTo-/, "-to-");
|
||||
loc = locateAnimation(script, convertedId);
|
||||
}
|
||||
let loc = locateAnimationWithFallback(script, animationId);
|
||||
if (!loc) return script;
|
||||
let kfNode = findKeyframesObjectNode(loc.target.call.varsArg);
|
||||
|
||||
if (!kfNode) {
|
||||
script = convertToKeyframesInScript(script, animationId);
|
||||
loc = locateAnimation(script, animationId);
|
||||
if (!loc) {
|
||||
const convertedId = animationId.replace(/-from-|-fromTo-/, "-to-");
|
||||
loc = locateAnimation(script, convertedId);
|
||||
}
|
||||
loc = locateAnimationWithFallback(script, animationId);
|
||||
if (!loc) return script;
|
||||
kfNode = findKeyframesObjectNode(loc.target.call.varsArg);
|
||||
if (!kfNode) return script;
|
||||
@@ -1818,7 +1846,7 @@ export function addKeyframeToScript(
|
||||
if (percentage > 0 && percentage < 100) {
|
||||
const pctProps = filterPercentageProps(kfNode);
|
||||
const allPcts = pctProps
|
||||
.map((p: any) => percentageFromKey(propKeyName(p) ?? ""))
|
||||
.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();
|
||||
@@ -1826,10 +1854,12 @@ export function addKeyframeToScript(
|
||||
for (const endPct of [0, 100]) {
|
||||
const isNeighbor = endPct === 0 ? leftNeighbor === 0 : rightNeighbor === 100;
|
||||
if (!isNeighbor) continue;
|
||||
const endProp = pctProps.find((p: any) => percentageFromKey(propKeyName(p) ?? "") === endPct);
|
||||
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: any) => isObjectProperty(p) && propKeyName(p) === "_auto",
|
||||
(p: AstNode) => isObjectProperty(p) && propKeyName(p) === "_auto",
|
||||
);
|
||||
if (!hasAuto) continue;
|
||||
const updatedProps = { ...properties, _auto: 1 as number | string };
|
||||
@@ -1848,7 +1878,9 @@ export function addKeyframeToScript(
|
||||
const valObj = prop.value;
|
||||
if (!valObj || valObj.type !== "ObjectExpression") continue;
|
||||
const existingKeys = new Set(
|
||||
valObj.properties.filter((p: any) => isObjectProperty(p)).map((p: any) => propKeyName(p)),
|
||||
valObj.properties
|
||||
.filter((p: AstNode) => isObjectProperty(p))
|
||||
.map((p: AstNode) => propKeyName(p)),
|
||||
);
|
||||
for (const pk of newPropKeys) {
|
||||
if (existingKeys.has(pk)) continue;
|
||||
@@ -1887,7 +1919,7 @@ export function removeKeyframeFromScript(
|
||||
if (remainingKfs.length < 2) {
|
||||
const record =
|
||||
remainingKfs.length === 1
|
||||
? objectExpressionToRecord(remainingKfs[0].value, loc.parsed.scope)
|
||||
? objectExpressionToRecord(remainingKfs[0]!.value, loc.parsed.scope)
|
||||
: {};
|
||||
collapseKeyframesToFlat(loc.target.call.varsArg, record);
|
||||
}
|
||||
@@ -1973,11 +2005,11 @@ function resolveConversionProps(
|
||||
}
|
||||
|
||||
/** Strip editable properties and ease/keyframes keys from a varsArg. */
|
||||
function stripEditableAndEase(varsArg: any): void {
|
||||
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: any) => {
|
||||
varsArg.properties = varsArg.properties.filter((p: AstNode) => {
|
||||
if (!isObjectProperty(p)) return true;
|
||||
const key = propKeyName(p);
|
||||
return key !== "ease" && key !== "keyframes";
|
||||
@@ -1987,7 +2019,7 @@ function stripEditableAndEase(varsArg: any): void {
|
||||
|
||||
/** Build and prepend a keyframes property node onto varsArg. */
|
||||
function insertKeyframesProp(
|
||||
varsArg: any,
|
||||
varsArg: AstNode,
|
||||
fromProps: Record<string, number | string>,
|
||||
toProps: Record<string, number | string>,
|
||||
easeEach?: string,
|
||||
@@ -2011,11 +2043,7 @@ export function convertToKeyframesInScript(
|
||||
animationId: string,
|
||||
resolvedFromValues?: Record<string, number | string>,
|
||||
): string {
|
||||
let loc = locateAnimation(script, animationId);
|
||||
if (!loc) {
|
||||
const convertedId = animationId.replace(/-from-|-fromTo-/, "-to-");
|
||||
loc = locateAnimation(script, convertedId);
|
||||
}
|
||||
let loc = locateAnimationWithFallback(script, animationId);
|
||||
if (!loc) return script;
|
||||
|
||||
const anim = loc.target.animation;
|
||||
@@ -2046,17 +2074,13 @@ export function convertToKeyframesInScript(
|
||||
* last keyframe's properties.
|
||||
*/
|
||||
export function removeAllKeyframesFromScript(script: string, animationId: string): string {
|
||||
let loc = locateAnimation(script, animationId);
|
||||
if (!loc) {
|
||||
const convertedId = animationId.replace(/-from-|-fromTo-/, "-to-");
|
||||
loc = locateAnimation(script, convertedId);
|
||||
}
|
||||
let loc = locateAnimationWithFallback(script, animationId);
|
||||
if (!loc) return script;
|
||||
const kfNode = findKeyframesObjectNode(loc.target.call.varsArg);
|
||||
if (!kfNode) return script;
|
||||
|
||||
const kfEntries = filterPercentageProps(kfNode)
|
||||
.map((p: any) => ({ pct: percentageFromKey(propKeyName(p)!), prop: p }))
|
||||
.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;
|
||||
@@ -2086,11 +2110,7 @@ export function materializeKeyframesInScript(
|
||||
easeEach?: string,
|
||||
resolvedSelector?: string,
|
||||
): string {
|
||||
let loc = locateAnimation(script, animationId);
|
||||
if (!loc) {
|
||||
const convertedId = animationId.replace(/-from-|-fromTo-/, "-to-");
|
||||
loc = locateAnimation(script, convertedId);
|
||||
}
|
||||
let loc = locateAnimationWithFallback(script, animationId);
|
||||
if (!loc) return script;
|
||||
|
||||
const varsArg = loc.target.call.varsArg;
|
||||
@@ -2100,19 +2120,9 @@ export function materializeKeyframesInScript(
|
||||
loc.target.call.node.arguments[0] = parseExpr(JSON.stringify(resolvedSelector));
|
||||
}
|
||||
|
||||
const entries: string[] = [];
|
||||
for (const kf of sortedKeyframes(keyframes)) {
|
||||
const propEntries = keyframePropsToCode(kf);
|
||||
if (kf.ease) propEntries.push(`ease: ${JSON.stringify(kf.ease)}`);
|
||||
entries.push(`${JSON.stringify(kf.percentage + "%")}: { ${propEntries.join(", ")} }`);
|
||||
}
|
||||
if (easeEach) {
|
||||
entries.push(`easeEach: ${JSON.stringify(easeEach)}`);
|
||||
}
|
||||
|
||||
const kfObjCode = `{ ${entries.join(", ")} }`;
|
||||
const kfObjCode = buildKeyframeObjectCode(sortedKeyframes(keyframes), { easeEach });
|
||||
const kfParent = varsArg.properties.find(
|
||||
(p: any) => isObjectProperty(p) && propKeyName(p) === "keyframes",
|
||||
(p: AstNode) => isObjectProperty(p) && propKeyName(p) === "keyframes",
|
||||
);
|
||||
if (kfParent) {
|
||||
kfParent.value = parseExpr(kfObjCode);
|
||||
@@ -2128,6 +2138,25 @@ export function materializeKeyframesInScript(
|
||||
|
||||
// ── 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[];
|
||||
@@ -2192,14 +2221,14 @@ export function setArcPathInScript(
|
||||
if (!config.enabled) {
|
||||
// Disable arc: restore x/y from motionPath's last waypoint, then remove motionPath
|
||||
const motionPathProp = varsArg.properties.find(
|
||||
(p: any) => isObjectProperty(p) && propKeyName(p) === "motionPath",
|
||||
(p: AstNode) => isObjectProperty(p) && propKeyName(p) === "motionPath",
|
||||
);
|
||||
if (motionPathProp) {
|
||||
const mpVal = motionPathProp.value;
|
||||
let pathArr: any[] | undefined;
|
||||
let pathArr: AstNode[] | undefined;
|
||||
if (mpVal?.type === "ObjectExpression") {
|
||||
const pathProp = mpVal.properties.find(
|
||||
(p: any) => isObjectProperty(p) && propKeyName(p) === "path",
|
||||
(p: AstNode) => isObjectProperty(p) && propKeyName(p) === "path",
|
||||
);
|
||||
if (pathProp?.value?.type === "ArrayExpression") pathArr = pathProp.value.elements;
|
||||
}
|
||||
@@ -2220,26 +2249,7 @@ export function setArcPathInScript(
|
||||
return recast.print(loc.parsed.ast).code;
|
||||
}
|
||||
|
||||
// Extract x/y waypoints from keyframes or flat tween properties
|
||||
const kfs = anim.keyframes?.keyframes ?? [];
|
||||
const waypoints: Array<{ x: number; y: number }> = [];
|
||||
for (const kf of kfs) {
|
||||
const x = typeof kf.properties.x === "number" ? kf.properties.x : undefined;
|
||||
const y = typeof kf.properties.y === "number" ? kf.properties.y : undefined;
|
||||
if (x !== undefined && y !== undefined) waypoints.push({ x, y });
|
||||
}
|
||||
|
||||
// For flat tweens with x/y in properties, synthesize start → end waypoints
|
||||
if (waypoints.length < 2) {
|
||||
const px = anim.properties.x;
|
||||
const py = anim.properties.y;
|
||||
if (typeof px === "number" || typeof py === "number") {
|
||||
waypoints.length = 0;
|
||||
waypoints.push({ x: 0, y: 0 });
|
||||
waypoints.push({ x: typeof px === "number" ? px : 0, y: typeof py === "number" ? py : 0 });
|
||||
}
|
||||
}
|
||||
|
||||
const waypoints = extractArcWaypoints(anim);
|
||||
if (waypoints.length < 2) return script;
|
||||
|
||||
// Build segments — use provided segments or create defaults
|
||||
@@ -2257,7 +2267,7 @@ export function setArcPathInScript(
|
||||
// Set motionPath on the vars
|
||||
const motionPathNode = parseExpr(motionPathCode);
|
||||
const existingProp = varsArg.properties.find(
|
||||
(p: any) => isObjectProperty(p) && propKeyName(p) === "motionPath",
|
||||
(p: AstNode) => isObjectProperty(p) && propKeyName(p) === "motionPath",
|
||||
);
|
||||
if (existingProp) {
|
||||
existingProp.value = motionPathNode;
|
||||
@@ -2271,7 +2281,7 @@ export function setArcPathInScript(
|
||||
if (kfNode) {
|
||||
for (const pctProp of filterPercentageProps(kfNode)) {
|
||||
if (pctProp.value?.type === "ObjectExpression") {
|
||||
pctProp.value.properties = pctProp.value.properties.filter((p: any) => {
|
||||
pctProp.value.properties = pctProp.value.properties.filter((p: AstNode) => {
|
||||
const k = propKeyName(p);
|
||||
return k !== "x" && k !== "y";
|
||||
});
|
||||
@@ -2303,15 +2313,7 @@ export function updateArcSegmentInScript(
|
||||
|
||||
segments[segmentIndex] = { ...segments[segmentIndex]!, ...update };
|
||||
|
||||
// Rebuild the full motionPath with updated segments
|
||||
const kfs = anim.keyframes?.keyframes ?? [];
|
||||
const waypoints: Array<{ x: number; y: number }> = [];
|
||||
for (const kf of kfs) {
|
||||
const x = typeof kf.properties.x === "number" ? kf.properties.x : undefined;
|
||||
const y = typeof kf.properties.y === "number" ? kf.properties.y : undefined;
|
||||
if (x !== undefined && y !== undefined) waypoints.push({ x, y });
|
||||
}
|
||||
|
||||
const waypoints = extractArcWaypoints(anim);
|
||||
if (waypoints.length < 2) return script;
|
||||
|
||||
const motionPathCode = buildMotionPathObjectCode({
|
||||
@@ -2322,7 +2324,7 @@ export function updateArcSegmentInScript(
|
||||
|
||||
const varsArg = loc.target.call.varsArg;
|
||||
const existingProp = varsArg.properties.find(
|
||||
(p: any) => isObjectProperty(p) && propKeyName(p) === "motionPath",
|
||||
(p: AstNode) => isObjectProperty(p) && propKeyName(p) === "motionPath",
|
||||
);
|
||||
if (existingProp) {
|
||||
existingProp.value = parseExpr(motionPathCode);
|
||||
@@ -2353,11 +2355,7 @@ export function splitIntoPropertyGroups(
|
||||
script: string,
|
||||
animationId: string,
|
||||
): { script: string; ids: string[] } {
|
||||
let loc = locateAnimation(script, animationId);
|
||||
if (!loc) {
|
||||
const convertedId = animationId.replace(/-from-|-fromTo-/, "-to-");
|
||||
loc = locateAnimation(script, convertedId);
|
||||
}
|
||||
let loc = locateAnimationWithFallback(script, animationId);
|
||||
if (!loc) return { script, ids: [animationId] };
|
||||
|
||||
const anim = loc.target.animation;
|
||||
@@ -2523,7 +2521,7 @@ export function unrollDynamicAnimations(
|
||||
: "0";
|
||||
|
||||
// Find the enclosing loop (for/forEach) by walking up the AST path
|
||||
let loopNode: any = null;
|
||||
let loopNode: AstNode | null = null;
|
||||
let current = loc.target.call.path;
|
||||
while (current) {
|
||||
const node = current.node ?? current.value;
|
||||
@@ -2550,16 +2548,11 @@ export function unrollDynamicAnimations(
|
||||
// Build replacement code: individual tl.to() calls for each element
|
||||
const calls: string[] = [];
|
||||
for (const el of elements) {
|
||||
const kfEntries: string[] = [];
|
||||
for (const kf of sortedKeyframes(el.keyframes)) {
|
||||
const propEntries = keyframePropsToCode(kf);
|
||||
kfEntries.push(`${JSON.stringify(kf.percentage + "%")}: { ${propEntries.join(", ")} }`);
|
||||
}
|
||||
if (el.easeEach) {
|
||||
kfEntries.push(`easeEach: ${JSON.stringify(el.easeEach)}`);
|
||||
}
|
||||
const kfCode = buildKeyframeObjectCode(sortedKeyframes(el.keyframes), {
|
||||
easeEach: el.easeEach,
|
||||
});
|
||||
calls.push(
|
||||
`${loc.parsed.timelineVar}.to(${JSON.stringify(el.selector)}, { keyframes: { ${kfEntries.join(", ")} }, duration: ${duration}, ease: ${JSON.stringify(ease)} }, ${posCode});`,
|
||||
`${loc.parsed.timelineVar}.to(${JSON.stringify(el.selector)}, { keyframes: ${kfCode}, duration: ${duration}, ease: ${JSON.stringify(ease)} }, ${posCode});`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -153,7 +153,7 @@ ${lines.join("\n")}${mediaSync}${postamble}
|
||||
`;
|
||||
}
|
||||
|
||||
function serializeValue(value: unknown): string {
|
||||
export function serializeValue(value: unknown): string {
|
||||
if (typeof value === "string" && value.startsWith("__raw:")) {
|
||||
return value.slice(6);
|
||||
}
|
||||
@@ -161,10 +161,13 @@ function serializeValue(value: unknown): string {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function safeJsKey(key: string): string {
|
||||
return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key) ? key : JSON.stringify(key);
|
||||
}
|
||||
|
||||
function serializeObject(obj: Record<string, number | string>): string {
|
||||
const entries = Object.entries(obj).map(([key, value]) => {
|
||||
const safeKey = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key) ? key : JSON.stringify(key);
|
||||
return `${safeKey}: ${serializeValue(value)}`;
|
||||
return `${safeJsKey(key)}: ${serializeValue(value)}`;
|
||||
});
|
||||
return `{ ${entries.join(", ")} }`;
|
||||
}
|
||||
@@ -172,8 +175,7 @@ function serializeObject(obj: Record<string, number | string>): string {
|
||||
function serializeExtras(extras: Record<string, unknown>): string {
|
||||
return Object.entries(extras)
|
||||
.map(([key, value]) => {
|
||||
const safeKey = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key) ? key : JSON.stringify(key);
|
||||
return `${safeKey}: ${serializeValue(value)}`;
|
||||
return `${safeJsKey(key)}: ${serializeValue(value)}`;
|
||||
})
|
||||
.join(", ");
|
||||
}
|
||||
@@ -296,9 +298,12 @@ export function gsapAnimationsToKeyframes(
|
||||
const baseValueEpsilon = 0.00001;
|
||||
|
||||
return animations
|
||||
.filter((a) => validMethods.includes(a.method) && typeof a.position === "number")
|
||||
.filter(
|
||||
(a): a is GsapAnimation & { position: number } =>
|
||||
validMethods.includes(a.method) && typeof a.position === "number",
|
||||
)
|
||||
.map((a) => {
|
||||
const relativeTimeRaw = (a.position as number) - elementStartTime;
|
||||
const relativeTimeRaw = a.position - elementStartTime;
|
||||
const time = clampTimeToZero ? Math.max(0, relativeTimeRaw) : relativeTimeRaw;
|
||||
|
||||
const properties: Partial<KeyframeProperties> = {};
|
||||
@@ -331,5 +336,5 @@ export function gsapAnimationsToKeyframes(
|
||||
ease: a.ease,
|
||||
};
|
||||
})
|
||||
.filter((kf): kf is NonNullable<typeof kf> => kf !== null) as Keyframe[];
|
||||
.filter((kf): kf is NonNullable<typeof kf> => kf !== null);
|
||||
}
|
||||
|
||||
@@ -139,19 +139,16 @@ function parseResolutionFromHtml(doc: Document): CanvasResolution | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
const UHD_SQUARE_MIN = 2160;
|
||||
const UHD_RECT_MIN = 3840;
|
||||
|
||||
function resolveResolutionFromDimensions(width: number, height: number): CanvasResolution {
|
||||
const longSide = Math.max(width, height);
|
||||
// UHD cutoff is the long side of the 4K presets (3840 for `landscape-4k` /
|
||||
// `portrait-4k`, 2160 for `square-4k`). A looser threshold (e.g. >= 2560)
|
||||
// would silently misclassify QHD/1440p (2560x1440) as 4K, which is the
|
||||
// wrong default for a common authoring resolution closer to 1080p than to
|
||||
// UHD. Authors who genuinely want the 4K preset can still set
|
||||
// `data-resolution="..."` explicitly.
|
||||
if (width === height) {
|
||||
return longSide >= 2160 ? "square-4k" : "square";
|
||||
return longSide >= UHD_SQUARE_MIN ? "square-4k" : "square";
|
||||
}
|
||||
const isLandscape = width > height;
|
||||
const isUhd = longSide >= 3840;
|
||||
const isUhd = longSide >= UHD_RECT_MIN;
|
||||
if (isLandscape) return isUhd ? "landscape-4k" : "landscape";
|
||||
return isUhd ? "portrait-4k" : "portrait";
|
||||
}
|
||||
@@ -612,16 +609,20 @@ export function addElementToHtml(
|
||||
|
||||
let newEl: Element;
|
||||
|
||||
function applyMediaAttrs(el: Element, mediaEl: TimelineMediaElement): void {
|
||||
if (mediaEl.src) el.setAttribute("src", mediaEl.src);
|
||||
if (mediaEl.volume !== undefined && mediaEl.volume !== 1) {
|
||||
el.setAttribute("data-volume", String(mediaEl.volume));
|
||||
}
|
||||
}
|
||||
|
||||
switch (element.type) {
|
||||
case "video": {
|
||||
const mediaEl = element as TimelineMediaElement;
|
||||
newEl = doc.createElement("video");
|
||||
newEl.setAttribute("muted", "");
|
||||
newEl.setAttribute("playsinline", "");
|
||||
if (mediaEl.src) newEl.setAttribute("src", mediaEl.src);
|
||||
if (mediaEl.volume !== undefined && mediaEl.volume !== 1) {
|
||||
newEl.setAttribute("data-volume", String(mediaEl.volume));
|
||||
}
|
||||
applyMediaAttrs(newEl, mediaEl);
|
||||
if (mediaEl.hasAudio) {
|
||||
newEl.setAttribute("data-has-audio", "true");
|
||||
}
|
||||
@@ -637,10 +638,7 @@ export function addElementToHtml(
|
||||
case "audio": {
|
||||
const mediaEl = element as TimelineMediaElement;
|
||||
newEl = doc.createElement("audio");
|
||||
if (mediaEl.src) newEl.setAttribute("src", mediaEl.src);
|
||||
if (mediaEl.volume !== undefined && mediaEl.volume !== 1) {
|
||||
newEl.setAttribute("data-volume", String(mediaEl.volume));
|
||||
}
|
||||
applyMediaAttrs(newEl, mediaEl);
|
||||
break;
|
||||
}
|
||||
case "text":
|
||||
|
||||
Reference in New Issue
Block a user