@hyperframes/core 0.7.14 → 0.7.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (240) hide show
  1. package/dist/compiler/staticGuard.js +1 -1
  2. package/dist/compiler/staticGuard.js.map +1 -1
  3. package/dist/core.types.d.ts +2 -369
  4. package/dist/core.types.d.ts.map +1 -1
  5. package/dist/core.types.js +1 -84
  6. package/dist/core.types.js.map +1 -1
  7. package/dist/generated/runtime-inline.js.map +1 -1
  8. package/dist/generators/hyperframes.d.ts +1 -1
  9. package/dist/generators/hyperframes.d.ts.map +1 -1
  10. package/dist/generators/hyperframes.js +1 -1
  11. package/dist/generators/hyperframes.js.map +1 -1
  12. package/dist/hyperframe.runtime.iife.js +20 -20
  13. package/dist/index.d.ts +5 -7
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +7 -4
  16. package/dist/index.js.map +1 -1
  17. package/dist/lint/index.d.ts +2 -2
  18. package/dist/lint/index.d.ts.map +1 -1
  19. package/dist/lint/index.js +2 -1
  20. package/dist/lint/index.js.map +1 -1
  21. package/dist/parsers/gsapConstants.d.ts +2 -12
  22. package/dist/parsers/gsapConstants.d.ts.map +1 -1
  23. package/dist/parsers/gsapConstants.js +2 -110
  24. package/dist/parsers/gsapConstants.js.map +1 -1
  25. package/dist/parsers/gsapParserAcorn.d.ts +2 -48
  26. package/dist/parsers/gsapParserAcorn.d.ts.map +1 -1
  27. package/dist/parsers/gsapParserAcorn.js +2 -1057
  28. package/dist/parsers/gsapParserAcorn.js.map +1 -1
  29. package/dist/parsers/gsapParserExports.d.ts +2 -20
  30. package/dist/parsers/gsapParserExports.d.ts.map +1 -1
  31. package/dist/parsers/gsapParserExports.js +2 -8
  32. package/dist/parsers/gsapParserExports.js.map +1 -1
  33. package/dist/parsers/gsapWriterAcorn.d.ts +2 -90
  34. package/dist/parsers/gsapWriterAcorn.d.ts.map +1 -1
  35. package/dist/parsers/gsapWriterAcorn.js +2 -2016
  36. package/dist/parsers/gsapWriterAcorn.js.map +1 -1
  37. package/dist/parsers/hfIds.d.ts +2 -16
  38. package/dist/parsers/hfIds.d.ts.map +1 -1
  39. package/dist/parsers/hfIds.js +2 -127
  40. package/dist/parsers/hfIds.js.map +1 -1
  41. package/dist/parsers/springEase.d.ts +2 -28
  42. package/dist/parsers/springEase.d.ts.map +1 -1
  43. package/dist/parsers/springEase.js +2 -69
  44. package/dist/parsers/springEase.js.map +1 -1
  45. package/dist/slideshow/index.d.ts +1 -0
  46. package/dist/slideshow/index.d.ts.map +1 -1
  47. package/dist/slideshow/index.js +1 -0
  48. package/dist/slideshow/index.js.map +1 -1
  49. package/dist/studio-api/helpers/draftMarkers.d.ts +2 -10
  50. package/dist/studio-api/helpers/draftMarkers.d.ts.map +1 -1
  51. package/dist/studio-api/helpers/draftMarkers.js +2 -10
  52. package/dist/studio-api/helpers/draftMarkers.js.map +1 -1
  53. package/dist/studio-api/helpers/finiteMutation.d.ts +2 -10
  54. package/dist/studio-api/helpers/finiteMutation.d.ts.map +1 -1
  55. package/dist/studio-api/helpers/finiteMutation.js +2 -20
  56. package/dist/studio-api/helpers/finiteMutation.js.map +1 -1
  57. package/dist/studio-api/helpers/manualEditsRenderScript.d.ts +2 -12
  58. package/dist/studio-api/helpers/manualEditsRenderScript.d.ts.map +1 -1
  59. package/dist/studio-api/helpers/manualEditsRenderScript.js +2 -632
  60. package/dist/studio-api/helpers/manualEditsRenderScript.js.map +1 -1
  61. package/dist/studio-api/helpers/screenshotClip.d.ts +2 -7
  62. package/dist/studio-api/helpers/screenshotClip.d.ts.map +1 -1
  63. package/dist/studio-api/helpers/screenshotClip.js +2 -21
  64. package/dist/studio-api/helpers/screenshotClip.js.map +1 -1
  65. package/dist/studio-api/helpers/studioMotionRenderScript.d.ts +2 -8
  66. package/dist/studio-api/helpers/studioMotionRenderScript.d.ts.map +1 -1
  67. package/dist/studio-api/helpers/studioMotionRenderScript.js +2 -207
  68. package/dist/studio-api/helpers/studioMotionRenderScript.js.map +1 -1
  69. package/dist/studio-api/index.d.ts +2 -9
  70. package/dist/studio-api/index.d.ts.map +1 -1
  71. package/dist/studio-api/index.js +2 -8
  72. package/dist/studio-api/index.js.map +1 -1
  73. package/package.json +16 -18
  74. package/dist/generated/runtime-inline.js +0 -11
  75. package/dist/hyperframe.manifest.json +0 -22
  76. package/dist/hyperframe.runtime.mjs +0 -325
  77. package/dist/lint/context.d.ts +0 -17
  78. package/dist/lint/context.d.ts.map +0 -1
  79. package/dist/lint/context.js +0 -37
  80. package/dist/lint/context.js.map +0 -1
  81. package/dist/lint/hyperframeLinter.d.ts +0 -14
  82. package/dist/lint/hyperframeLinter.d.ts.map +0 -1
  83. package/dist/lint/hyperframeLinter.js +0 -132
  84. package/dist/lint/hyperframeLinter.js.map +0 -1
  85. package/dist/lint/rules/adapters.d.ts +0 -3
  86. package/dist/lint/rules/adapters.d.ts.map +0 -1
  87. package/dist/lint/rules/adapters.js +0 -40
  88. package/dist/lint/rules/adapters.js.map +0 -1
  89. package/dist/lint/rules/captions.d.ts +0 -3
  90. package/dist/lint/rules/captions.d.ts.map +0 -1
  91. package/dist/lint/rules/captions.js +0 -242
  92. package/dist/lint/rules/captions.js.map +0 -1
  93. package/dist/lint/rules/composition.d.ts +0 -5
  94. package/dist/lint/rules/composition.d.ts.map +0 -1
  95. package/dist/lint/rules/composition.js +0 -694
  96. package/dist/lint/rules/composition.js.map +0 -1
  97. package/dist/lint/rules/core.d.ts +0 -3
  98. package/dist/lint/rules/core.d.ts.map +0 -1
  99. package/dist/lint/rules/core.js +0 -432
  100. package/dist/lint/rules/core.js.map +0 -1
  101. package/dist/lint/rules/fonts.d.ts +0 -3
  102. package/dist/lint/rules/fonts.d.ts.map +0 -1
  103. package/dist/lint/rules/fonts.js +0 -205
  104. package/dist/lint/rules/fonts.js.map +0 -1
  105. package/dist/lint/rules/gsap.d.ts +0 -4
  106. package/dist/lint/rules/gsap.d.ts.map +0 -1
  107. package/dist/lint/rules/gsap.js +0 -1039
  108. package/dist/lint/rules/gsap.js.map +0 -1
  109. package/dist/lint/rules/media.d.ts +0 -3
  110. package/dist/lint/rules/media.d.ts.map +0 -1
  111. package/dist/lint/rules/media.js +0 -491
  112. package/dist/lint/rules/media.js.map +0 -1
  113. package/dist/lint/rules/slideshow.d.ts +0 -4
  114. package/dist/lint/rules/slideshow.d.ts.map +0 -1
  115. package/dist/lint/rules/slideshow.js +0 -74
  116. package/dist/lint/rules/slideshow.js.map +0 -1
  117. package/dist/lint/rules/textures.d.ts +0 -3
  118. package/dist/lint/rules/textures.d.ts.map +0 -1
  119. package/dist/lint/rules/textures.js +0 -192
  120. package/dist/lint/rules/textures.js.map +0 -1
  121. package/dist/lint/types.d.ts +0 -36
  122. package/dist/lint/types.d.ts.map +0 -1
  123. package/dist/lint/types.js +0 -2
  124. package/dist/lint/types.js.map +0 -1
  125. package/dist/lint/utils.d.ts +0 -55
  126. package/dist/lint/utils.d.ts.map +0 -1
  127. package/dist/lint/utils.js +0 -264
  128. package/dist/lint/utils.js.map +0 -1
  129. package/dist/parsers/gsapInline.d.ts +0 -47
  130. package/dist/parsers/gsapInline.d.ts.map +0 -1
  131. package/dist/parsers/gsapInline.js +0 -513
  132. package/dist/parsers/gsapInline.js.map +0 -1
  133. package/dist/parsers/gsapParser.d.ts +0 -158
  134. package/dist/parsers/gsapParser.d.ts.map +0 -1
  135. package/dist/parsers/gsapParser.js +0 -2634
  136. package/dist/parsers/gsapParser.js.map +0 -1
  137. package/dist/parsers/gsapParser.test-helpers.d.ts +0 -49
  138. package/dist/parsers/gsapParser.test-helpers.d.ts.map +0 -1
  139. package/dist/parsers/gsapParser.test-helpers.js +0 -97
  140. package/dist/parsers/gsapParser.test-helpers.js.map +0 -1
  141. package/dist/parsers/gsapSerialize.d.ts +0 -191
  142. package/dist/parsers/gsapSerialize.d.ts.map +0 -1
  143. package/dist/parsers/gsapSerialize.js +0 -410
  144. package/dist/parsers/gsapSerialize.js.map +0 -1
  145. package/dist/parsers/gsapUnroll.d.ts +0 -7
  146. package/dist/parsers/gsapUnroll.d.ts.map +0 -1
  147. package/dist/parsers/gsapUnroll.js +0 -137
  148. package/dist/parsers/gsapUnroll.js.map +0 -1
  149. package/dist/parsers/htmlParser.d.ts +0 -27
  150. package/dist/parsers/htmlParser.d.ts.map +0 -1
  151. package/dist/parsers/htmlParser.js +0 -731
  152. package/dist/parsers/htmlParser.js.map +0 -1
  153. package/dist/studio-api/createStudioApi.d.ts +0 -10
  154. package/dist/studio-api/createStudioApi.d.ts.map +0 -1
  155. package/dist/studio-api/createStudioApi.js +0 -32
  156. package/dist/studio-api/createStudioApi.js.map +0 -1
  157. package/dist/studio-api/helpers/backupJournal.d.ts +0 -9
  158. package/dist/studio-api/helpers/backupJournal.d.ts.map +0 -1
  159. package/dist/studio-api/helpers/backupJournal.js +0 -81
  160. package/dist/studio-api/helpers/backupJournal.js.map +0 -1
  161. package/dist/studio-api/helpers/hfIdPersist.d.ts +0 -11
  162. package/dist/studio-api/helpers/hfIdPersist.d.ts.map +0 -1
  163. package/dist/studio-api/helpers/hfIdPersist.js +0 -39
  164. package/dist/studio-api/helpers/hfIdPersist.js.map +0 -1
  165. package/dist/studio-api/helpers/mediaValidation.d.ts +0 -20
  166. package/dist/studio-api/helpers/mediaValidation.d.ts.map +0 -1
  167. package/dist/studio-api/helpers/mediaValidation.js +0 -56
  168. package/dist/studio-api/helpers/mediaValidation.js.map +0 -1
  169. package/dist/studio-api/helpers/mime.d.ts +0 -4
  170. package/dist/studio-api/helpers/mime.d.ts.map +0 -1
  171. package/dist/studio-api/helpers/mime.js +0 -39
  172. package/dist/studio-api/helpers/mime.js.map +0 -1
  173. package/dist/studio-api/helpers/previewAdapter.d.ts +0 -43
  174. package/dist/studio-api/helpers/previewAdapter.d.ts.map +0 -1
  175. package/dist/studio-api/helpers/previewAdapter.js +0 -118
  176. package/dist/studio-api/helpers/previewAdapter.js.map +0 -1
  177. package/dist/studio-api/helpers/projectSignature.d.ts +0 -5
  178. package/dist/studio-api/helpers/projectSignature.d.ts.map +0 -1
  179. package/dist/studio-api/helpers/projectSignature.js +0 -154
  180. package/dist/studio-api/helpers/projectSignature.js.map +0 -1
  181. package/dist/studio-api/helpers/safePath.d.ts +0 -12
  182. package/dist/studio-api/helpers/safePath.d.ts.map +0 -1
  183. package/dist/studio-api/helpers/safePath.js +0 -38
  184. package/dist/studio-api/helpers/safePath.js.map +0 -1
  185. package/dist/studio-api/helpers/sourceMutation.d.ts +0 -28
  186. package/dist/studio-api/helpers/sourceMutation.d.ts.map +0 -1
  187. package/dist/studio-api/helpers/sourceMutation.js +0 -339
  188. package/dist/studio-api/helpers/sourceMutation.js.map +0 -1
  189. package/dist/studio-api/helpers/subComposition.d.ts +0 -20
  190. package/dist/studio-api/helpers/subComposition.d.ts.map +0 -1
  191. package/dist/studio-api/helpers/subComposition.js +0 -289
  192. package/dist/studio-api/helpers/subComposition.js.map +0 -1
  193. package/dist/studio-api/helpers/waveform.d.ts +0 -4
  194. package/dist/studio-api/helpers/waveform.d.ts.map +0 -1
  195. package/dist/studio-api/helpers/waveform.js +0 -80
  196. package/dist/studio-api/helpers/waveform.js.map +0 -1
  197. package/dist/studio-api/routes/files.d.ts +0 -4
  198. package/dist/studio-api/routes/files.d.ts.map +0 -1
  199. package/dist/studio-api/routes/files.js +0 -1200
  200. package/dist/studio-api/routes/files.js.map +0 -1
  201. package/dist/studio-api/routes/fonts.d.ts +0 -3
  202. package/dist/studio-api/routes/fonts.d.ts.map +0 -1
  203. package/dist/studio-api/routes/fonts.js +0 -157
  204. package/dist/studio-api/routes/fonts.js.map +0 -1
  205. package/dist/studio-api/routes/lint.d.ts +0 -4
  206. package/dist/studio-api/routes/lint.d.ts.map +0 -1
  207. package/dist/studio-api/routes/lint.js +0 -29
  208. package/dist/studio-api/routes/lint.js.map +0 -1
  209. package/dist/studio-api/routes/preview.d.ts +0 -4
  210. package/dist/studio-api/routes/preview.d.ts.map +0 -1
  211. package/dist/studio-api/routes/preview.js +0 -331
  212. package/dist/studio-api/routes/preview.js.map +0 -1
  213. package/dist/studio-api/routes/projects.d.ts +0 -4
  214. package/dist/studio-api/routes/projects.d.ts.map +0 -1
  215. package/dist/studio-api/routes/projects.js +0 -45
  216. package/dist/studio-api/routes/projects.js.map +0 -1
  217. package/dist/studio-api/routes/registry.d.ts +0 -4
  218. package/dist/studio-api/routes/registry.d.ts.map +0 -1
  219. package/dist/studio-api/routes/registry.js +0 -31
  220. package/dist/studio-api/routes/registry.js.map +0 -1
  221. package/dist/studio-api/routes/render.d.ts +0 -4
  222. package/dist/studio-api/routes/render.d.ts.map +0 -1
  223. package/dist/studio-api/routes/render.js +0 -269
  224. package/dist/studio-api/routes/render.js.map +0 -1
  225. package/dist/studio-api/routes/storyboard.d.ts +0 -4
  226. package/dist/studio-api/routes/storyboard.d.ts.map +0 -1
  227. package/dist/studio-api/routes/storyboard.js +0 -65
  228. package/dist/studio-api/routes/storyboard.js.map +0 -1
  229. package/dist/studio-api/routes/thumbnail.d.ts +0 -4
  230. package/dist/studio-api/routes/thumbnail.d.ts.map +0 -1
  231. package/dist/studio-api/routes/thumbnail.js +0 -107
  232. package/dist/studio-api/routes/thumbnail.js.map +0 -1
  233. package/dist/studio-api/routes/waveform.d.ts +0 -4
  234. package/dist/studio-api/routes/waveform.d.ts.map +0 -1
  235. package/dist/studio-api/routes/waveform.js +0 -41
  236. package/dist/studio-api/routes/waveform.js.map +0 -1
  237. package/dist/studio-api/types.d.ts +0 -120
  238. package/dist/studio-api/types.d.ts.map +0 -1
  239. package/dist/studio-api/types.js +0 -2
  240. package/dist/studio-api/types.js.map +0 -1
@@ -1,2634 +0,0 @@
1
- /**
2
- * Node-only GSAP AST parser. Depends on recast / @babel/parser, which compile
3
- * to CommonJS that calls `require("fs")` — so this module must never be in the
4
- * static import graph of isomorphic/browser code. It is reachable only via the
5
- * `@hyperframes/core/gsap-parser` subpath (studio-api mutations + the linter).
6
- *
7
- * Recast-free helpers (serialization, keyframe conversion, validation, types)
8
- * live in `./gsapSerialize` and are re-exported here so this subpath exposes the
9
- * full surface for tests and server-side consumers.
10
- */
11
- import * as recast from "recast";
12
- import { parse as babelParse } from "@babel/parser";
13
- import { serializeValue as valueToCode, safeJsKey as safeKey, resolveConversionProps, } from "./gsapSerialize.js";
14
- export { serializeGsapAnimations, getAnimationsForElementId, validateCompositionGsap, keyframesToGsapAnimations, gsapAnimationsToKeyframes, SUPPORTED_PROPS, SUPPORTED_EASES, } from "./gsapSerialize.js";
15
- export { PROPERTY_GROUPS, classifyPropertyGroup, classifyTweenPropertyGroup, } from "./gsapConstants.js";
16
- import { classifyPropertyGroup, classifyTweenPropertyGroup } from "./gsapConstants.js";
17
- export { generateSpringEaseData, SPRING_PRESETS } from "./springEase.js";
18
- const GSAP_METHODS = new Set(["set", "to", "from", "fromTo"]);
19
- function parseScript(script) {
20
- return recast.parse(script, {
21
- parser: {
22
- parse(source) {
23
- return babelParse(source, { sourceType: "script", plugins: [], tokens: true });
24
- },
25
- },
26
- });
27
- }
28
- function collectScopeBindings(ast) {
29
- const bindings = new Map();
30
- recast.types.visit(ast, {
31
- visitVariableDeclarator(path) {
32
- const name = path.node.id?.name;
33
- const init = path.node.init;
34
- if (name && init) {
35
- const val = resolveNode(init, bindings);
36
- if (val !== undefined)
37
- bindings.set(name, val);
38
- }
39
- this.traverse(path);
40
- },
41
- });
42
- return bindings;
43
- }
44
- function resolveNode(node, scope) {
45
- if (!node)
46
- return undefined;
47
- if (node.type === "NumericLiteral" || (node.type === "Literal" && typeof node.value === "number"))
48
- return node.value;
49
- if (node.type === "StringLiteral" || (node.type === "Literal" && typeof node.value === "string"))
50
- return node.value;
51
- if (node.type === "BooleanLiteral" ||
52
- (node.type === "Literal" && typeof node.value === "boolean"))
53
- return node.value;
54
- if (node.type === "UnaryExpression" && node.operator === "-" && node.argument) {
55
- const val = resolveNode(node.argument, scope);
56
- return typeof val === "number" ? -val : undefined;
57
- }
58
- if (node.type === "BinaryExpression") {
59
- const left = resolveNode(node.left, scope);
60
- const right = resolveNode(node.right, scope);
61
- if (typeof left === "number" && typeof right === "number") {
62
- switch (node.operator) {
63
- case "+":
64
- return left + right;
65
- case "-":
66
- return left - right;
67
- case "*":
68
- return left * right;
69
- case "/":
70
- return right !== 0 ? left / right : undefined;
71
- }
72
- }
73
- if (typeof left === "string" && node.operator === "+")
74
- return left + String(right ?? "");
75
- if (typeof right === "string" && node.operator === "+")
76
- return String(left ?? "") + right;
77
- }
78
- if (node.type === "Identifier" && scope.has(node.name)) {
79
- return scope.get(node.name);
80
- }
81
- if (node.type === "TemplateLiteral" && node.expressions?.length === 0) {
82
- return node.quasis?.[0]?.value?.cooked ?? undefined;
83
- }
84
- return undefined;
85
- }
86
- function extractLiteralValue(node, scope) {
87
- return resolveNode(node, scope);
88
- }
89
- // ── Element-target resolution ───────────────────────────────────────────────
90
- //
91
- // Real compositions target tweens through element variables resolved from the
92
- // DOM (`const kicker = root.querySelector(".kicker"); tl.to(kicker, …)`), arrays
93
- // of them (`tl.to([a, b], …)`), `gsap.utils.toArray(".sel")`, and per-element
94
- // loop variables (`items.forEach(el => tl.to(el, …))`) — not inline string
95
- // selectors. To make those tweens editable we resolve each target back to the
96
- // CSS selector(s) it addresses. Resolution is lexically scoped: the same
97
- // variable name can mean different elements in different IIFEs.
98
- const QUERY_METHODS = new Set(["querySelector", "querySelectorAll"]);
99
- const ITERATION_METHODS = new Set(["forEach", "map"]);
100
- const SCOPE_NODE_TYPES = new Set([
101
- "Program",
102
- "FunctionDeclaration",
103
- "FunctionExpression",
104
- "ArrowFunctionExpression",
105
- ]);
106
- /**
107
- * If `node` is a DOM lookup call — `x.querySelector(".sel")`,
108
- * `document.querySelectorAll(".sel")`, `document.getElementById("id")`, or
109
- * `gsap.utils.toArray(".sel")` — return the CSS selector it resolves to.
110
- * `getElementById("id")` maps to `#id`. Returns null for anything else.
111
- */
112
- function selectorFromQueryCall(node, scope) {
113
- if (node?.type !== "CallExpression")
114
- return null;
115
- const callee = node.callee;
116
- if (callee?.type !== "MemberExpression" || callee.property?.type !== "Identifier")
117
- return null;
118
- const method = callee.property.name;
119
- const argValue = resolveNode(node.arguments?.[0], scope);
120
- if (typeof argValue !== "string" || argValue.length === 0)
121
- return null;
122
- if (QUERY_METHODS.has(method) || method === "toArray")
123
- return argValue;
124
- if (method === "getElementById")
125
- return `#${argValue}`;
126
- return null;
127
- }
128
- /** The nearest enclosing function/program node — the binding scope of `path`. */
129
- function enclosingScopeNode(path) {
130
- let p = path?.parentPath;
131
- while (p) {
132
- if (SCOPE_NODE_TYPES.has(p.node?.type))
133
- return p.node;
134
- p = p.parentPath;
135
- }
136
- return null;
137
- }
138
- /** Scope nodes enclosing `path`, innermost first. */
139
- function scopeChainOf(path) {
140
- const chain = [];
141
- let p = path;
142
- while (p) {
143
- if (SCOPE_NODE_TYPES.has(p.node?.type))
144
- chain.push(p.node);
145
- p = p.parentPath;
146
- }
147
- return chain;
148
- }
149
- function addBinding(bindings, scopeNode, name, selector) {
150
- let scoped = bindings.get(scopeNode);
151
- if (!scoped) {
152
- scoped = new Map();
153
- bindings.set(scopeNode, scoped);
154
- }
155
- if (!scoped.has(name))
156
- scoped.set(name, selector);
157
- }
158
- /**
159
- * Build a lexically-scoped index of element variables → selector. Two passes:
160
- * (1) direct DOM-lookup assignments (`const x = root.querySelector(...)`), then
161
- * (2) iteration callback params (`coll.forEach(el => …)`), whose element type is
162
- * the collection's selector — resolved against the pass-1 bindings.
163
- */
164
- function collectTargetBindings(ast, scope) {
165
- const bindings = new Map();
166
- recast.types.visit(ast, {
167
- visitVariableDeclarator(path) {
168
- const name = path.node.id?.name;
169
- const selector = selectorFromQueryCall(path.node.init, scope);
170
- const scopeNode = enclosingScopeNode(path);
171
- if (name && selector !== null && scopeNode)
172
- addBinding(bindings, scopeNode, name, selector);
173
- this.traverse(path);
174
- },
175
- visitAssignmentExpression(path) {
176
- const left = path.node.left;
177
- const selector = selectorFromQueryCall(path.node.right, scope);
178
- const scopeNode = enclosingScopeNode(path);
179
- if (left?.type === "Identifier" && selector !== null && scopeNode) {
180
- addBinding(bindings, scopeNode, left.name, selector);
181
- }
182
- this.traverse(path);
183
- },
184
- });
185
- // Pass 2: forEach/map callback params take the collection's selector.
186
- recast.types.visit(ast, {
187
- visitCallExpression(path) {
188
- const node = path.node;
189
- const callee = node.callee;
190
- if (callee?.type === "MemberExpression" &&
191
- callee.property?.type === "Identifier" &&
192
- ITERATION_METHODS.has(callee.property.name)) {
193
- const collectionSelector = resolveCollectionSelector(callee.object, path, scope, bindings);
194
- const fn = node.arguments?.[0];
195
- const param = fn?.params?.[0];
196
- if (collectionSelector && param?.type === "Identifier" && isFunctionNode(fn)) {
197
- addBinding(bindings, fn, param.name, collectionSelector);
198
- }
199
- }
200
- this.traverse(path);
201
- },
202
- });
203
- return bindings;
204
- }
205
- function isFunctionNode(node) {
206
- return (node?.type === "ArrowFunctionExpression" ||
207
- node?.type === "FunctionExpression" ||
208
- node?.type === "FunctionDeclaration");
209
- }
210
- /** Resolve the selector a `.forEach`/`.map` is iterating over (variable or inline call). */
211
- function resolveCollectionSelector(node, callPath, scope, bindings) {
212
- if (node?.type === "Identifier")
213
- return lookupBinding(node.name, callPath, bindings);
214
- if (node?.type === "CallExpression")
215
- return selectorFromQueryCall(node, scope);
216
- return null;
217
- }
218
- /** Resolve a variable name to its selector using the lexical scope chain of `path`. */
219
- function lookupBinding(name, path, bindings) {
220
- for (const scopeNode of scopeChainOf(path)) {
221
- const selector = bindings.get(scopeNode)?.get(name);
222
- if (selector !== undefined)
223
- return selector;
224
- }
225
- return null;
226
- }
227
- /**
228
- * Resolve a tween's first argument to a CSS selector. Handles inline string
229
- * literals, element variables (lexically scoped), arrays of elements (joined
230
- * into a CSS group selector), inline DOM lookup / `toArray` calls, and indexed
231
- * access (`items[i]`). Returns null when the target can't be resolved
232
- * statically (e.g. an object-target duration anchor `tl.to({ _: 0 }, …)`, or a
233
- * runtime-computed selector).
234
- */
235
- function resolveTargetSelector(node, path, scope, bindings) {
236
- if (!node)
237
- return null;
238
- if (node.type === "StringLiteral" || node.type === "Literal") {
239
- return typeof node.value === "string" ? node.value : null;
240
- }
241
- if (node.type === "Identifier") {
242
- return lookupBinding(node.name, path, bindings);
243
- }
244
- if (node.type === "CallExpression") {
245
- return selectorFromQueryCall(node, scope);
246
- }
247
- if (node.type === "ArrayExpression") {
248
- const parts = node.elements
249
- .map((el) => resolveTargetSelector(el, path, scope, bindings))
250
- .filter((s) => typeof s === "string" && s.length > 0);
251
- return parts.length > 0 ? parts.join(", ") : null;
252
- }
253
- if (node.type === "MemberExpression" && node.object?.type === "Identifier") {
254
- // `items[i]` — the element type is the collection's selector.
255
- return lookupBinding(node.object.name, path, bindings);
256
- }
257
- return null;
258
- }
259
- function objectExpressionToRecord(node, scope) {
260
- const result = {};
261
- if (node?.type !== "ObjectExpression")
262
- return result;
263
- for (const prop of node.properties ?? []) {
264
- if (prop.type !== "ObjectProperty" && prop.type !== "Property")
265
- continue;
266
- const key = prop.key?.name ?? prop.key?.value;
267
- if (!key)
268
- continue;
269
- const resolved = resolveNode(prop.value, scope);
270
- if (resolved !== undefined) {
271
- result[key] = resolved;
272
- }
273
- else {
274
- // Preserve unresolvable values as raw source text so they survive round-trips
275
- result[key] = `__raw:${recast.print(prop.value).code}`;
276
- }
277
- }
278
- return result;
279
- }
280
- // ── Timeline Variable Detection ─────────────────────────────────────────────
281
- function isGsapTimelineCall(node) {
282
- return (node?.type === "CallExpression" &&
283
- node.callee?.type === "MemberExpression" &&
284
- node.callee.object?.name === "gsap" &&
285
- node.callee.property?.name === "timeline");
286
- }
287
- function extractTimelineDefaults(callNode, scope) {
288
- const arg = callNode.arguments?.[0];
289
- if (!arg || arg.type !== "ObjectExpression")
290
- return undefined;
291
- const defaultsProp = arg.properties?.find((p) => isObjectProperty(p) && propKeyName(p) === "defaults");
292
- if (!defaultsProp?.value || defaultsProp.value.type !== "ObjectExpression")
293
- return undefined;
294
- const record = objectExpressionToRecord(defaultsProp.value, scope);
295
- const result = {};
296
- if (typeof record.ease === "string")
297
- result.ease = record.ease;
298
- if (typeof record.duration === "number")
299
- result.duration = record.duration;
300
- return Object.keys(result).length > 0 ? result : undefined;
301
- }
302
- function findTimelineVar(ast, scope) {
303
- let timelineVar = null;
304
- let timelineCount = 0;
305
- let defaults;
306
- const emptyScope = scope ?? new Map();
307
- recast.types.visit(ast, {
308
- visitVariableDeclarator(path) {
309
- if (isGsapTimelineCall(path.node.init)) {
310
- timelineCount += 1;
311
- if (!timelineVar) {
312
- timelineVar = path.node.id?.name ?? null;
313
- defaults = extractTimelineDefaults(path.node.init, emptyScope);
314
- }
315
- }
316
- this.traverse(path);
317
- },
318
- visitAssignmentExpression(path) {
319
- if (isGsapTimelineCall(path.node.right)) {
320
- timelineCount += 1;
321
- if (!timelineVar) {
322
- const left = path.node.left;
323
- if (left?.type === "Identifier")
324
- timelineVar = left.name;
325
- defaults = extractTimelineDefaults(path.node.right, emptyScope);
326
- }
327
- }
328
- this.traverse(path);
329
- },
330
- });
331
- return { timelineVar, timelineCount, defaults };
332
- }
333
- /**
334
- * True when the member chain of `callNode.callee` is rooted at the timeline
335
- * variable — `tl.to(...)` and every link of a chain `tl.to(...).to(...)`.
336
- */
337
- function isTimelineRootedCall(callNode, timelineVar) {
338
- let obj = callNode.callee?.object;
339
- while (obj?.type === "CallExpression") {
340
- obj = obj.callee?.object;
341
- }
342
- return obj?.type === "Identifier" && obj.name === timelineVar;
343
- }
344
- function findAllTweenCalls(ast, timelineVar, scope, targetBindings) {
345
- const results = [];
346
- recast.types.visit(ast, {
347
- visitCallExpression(path) {
348
- const node = path.node;
349
- const callee = node.callee;
350
- // A base `gsap.set("#sel", props)` is an off-timeline static hold (no position,
351
- // no keyframe marker). Treat it as an editable `set` animation so a static
352
- // value (e.g. a 3D transform) round-trips and re-edits in place. Restricted to
353
- // a STRING-LITERAL selector: variable-target `gsap.set(el, ...)` holds stay
354
- // opaque surrounding source (editing them by selector would be ambiguous).
355
- const gsapSetArg = node.arguments?.[0];
356
- const isGlobalSet = callee?.type === "MemberExpression" &&
357
- callee.object?.type === "Identifier" &&
358
- callee.object.name === "gsap" &&
359
- callee.property?.type === "Identifier" &&
360
- callee.property.name === "set" &&
361
- (gsapSetArg?.type === "StringLiteral" ||
362
- (gsapSetArg?.type === "Literal" && typeof gsapSetArg.value === "string"));
363
- if (callee?.type === "MemberExpression" &&
364
- callee.property?.type === "Identifier" &&
365
- (isTimelineRootedCall(node, timelineVar) || isGlobalSet)) {
366
- const method = callee.property.name;
367
- if (!GSAP_METHODS.has(method)) {
368
- this.traverse(path);
369
- return;
370
- }
371
- const args = node.arguments;
372
- if (args.length < 2) {
373
- this.traverse(path);
374
- return;
375
- }
376
- const selectorValue = resolveTargetSelector(args[0], path, scope, targetBindings) ?? "__unresolved__";
377
- if (method === "fromTo") {
378
- results.push({
379
- path,
380
- node,
381
- method: "fromTo",
382
- selector: selectorValue,
383
- fromArg: args[1],
384
- varsArg: args[2],
385
- positionArg: args[3],
386
- });
387
- }
388
- else {
389
- results.push({
390
- path,
391
- node,
392
- method: method,
393
- selector: selectorValue,
394
- varsArg: args[1],
395
- positionArg: args[2],
396
- ...(isGlobalSet ? { global: true } : {}),
397
- });
398
- }
399
- }
400
- this.traverse(path);
401
- },
402
- });
403
- return results;
404
- }
405
- /** Keys that are stored on dedicated GsapAnimation fields (not in properties/extras). */
406
- const BUILTIN_VAR_KEYS = new Set(["duration", "ease", "delay"]);
407
- /** Keys that are never preserved (callbacks / advanced patterns). */
408
- const DROPPED_VAR_KEYS = new Set(["onComplete", "onStart", "onUpdate", "onRepeat"]);
409
- /** Keys that belong in `extras` — non-editable GSAP config that must survive round-trips. */
410
- const EXTRAS_KEYS = new Set([
411
- "stagger",
412
- "yoyo",
413
- "repeat",
414
- "repeatDelay",
415
- "snap",
416
- "overwrite",
417
- "immediateRender",
418
- ]);
419
- /**
420
- * Extract raw source text for a property in an ObjectExpression AST node.
421
- * Returns the printed source of the value node, suitable for verbatim re-emission.
422
- */
423
- function extractRawPropertySource(varsArgNode, key) {
424
- const node = findPropertyNode(varsArgNode, key);
425
- return node ? recast.print(node).code : undefined;
426
- }
427
- /** Find the raw AST node for a named property inside an ObjectExpression. */
428
- function findPropertyNode(varsArgNode, key) {
429
- if (varsArgNode?.type !== "ObjectExpression")
430
- return undefined;
431
- for (const prop of varsArgNode.properties ?? []) {
432
- if (!isObjectProperty(prop))
433
- continue;
434
- if (propKeyName(prop) === key)
435
- return prop.value;
436
- }
437
- return undefined;
438
- }
439
- // ── Native GSAP Keyframes Parsing ──────────────────────────────────────────
440
- const PERCENTAGE_KEY_RE = /^(\d+(?:\.\d+)?)%$/;
441
- /** Extract a string-valued ease or easeEach from an AST property node. */
442
- function tryResolveStringProp(propValue, scope) {
443
- const val = resolveNode(propValue, scope);
444
- return typeof val === "string" ? val : undefined;
445
- }
446
- /**
447
- * Parse a `keyframes` property value from a tween vars AST node into a
448
- * normalized `GsapKeyframesData` structure. Handles all three GSAP formats:
449
- * percentage objects, object arrays, and simple (property-array) objects.
450
- */
451
- // fallow-ignore-next-line complexity
452
- function parseKeyframesNode(node, scope) {
453
- if (!node)
454
- return undefined;
455
- // ── Object array format: keyframes: [ { x: 0, duration: 0.5 }, ... ] ──
456
- if (node.type === "ArrayExpression") {
457
- return parseObjectArrayKeyframes(node, scope);
458
- }
459
- if (node.type !== "ObjectExpression")
460
- return undefined;
461
- // Distinguish percentage vs simple-array by inspecting property keys/values.
462
- const props = node.properties ?? [];
463
- let hasPercentageKey = false;
464
- let hasArrayValue = false;
465
- for (const prop of props) {
466
- if (prop.type !== "ObjectProperty" && prop.type !== "Property")
467
- continue;
468
- const key = prop.key?.value ?? prop.key?.name;
469
- if (typeof key === "string" && PERCENTAGE_KEY_RE.test(key)) {
470
- hasPercentageKey = true;
471
- break;
472
- }
473
- if (prop.value?.type === "ArrayExpression") {
474
- hasArrayValue = true;
475
- }
476
- }
477
- if (hasPercentageKey)
478
- return parsePercentageKeyframes(node, scope);
479
- if (hasArrayValue)
480
- return parseSimpleArrayKeyframes(node, scope);
481
- return undefined;
482
- }
483
- // fallow-ignore-next-line complexity
484
- function parsePercentageKeyframes(node, scope) {
485
- const keyframes = [];
486
- let ease;
487
- let easeEach;
488
- for (const prop of node.properties ?? []) {
489
- if (prop.type !== "ObjectProperty" && prop.type !== "Property")
490
- continue;
491
- const key = prop.key?.value ?? prop.key?.name;
492
- if (typeof key !== "string")
493
- continue;
494
- const pctMatch = PERCENTAGE_KEY_RE.exec(key);
495
- if (pctMatch) {
496
- const percentage = Number.parseFloat(pctMatch[1]);
497
- const record = objectExpressionToRecord(prop.value, scope);
498
- const properties = {};
499
- let kfEase;
500
- for (const [k, v] of Object.entries(record)) {
501
- if (k === "ease" && typeof v === "string") {
502
- kfEase = v;
503
- }
504
- else if (typeof v === "number" || typeof v === "string") {
505
- properties[k] = v;
506
- }
507
- }
508
- keyframes.push({ percentage, properties, ...(kfEase ? { ease: kfEase } : {}) });
509
- }
510
- else if (key === "ease") {
511
- ease = tryResolveStringProp(prop.value, scope) ?? ease;
512
- }
513
- else if (key === "easeEach") {
514
- easeEach = tryResolveStringProp(prop.value, scope) ?? easeEach;
515
- }
516
- }
517
- keyframes.sort((a, b) => a.percentage - b.percentage);
518
- return {
519
- format: "percentage",
520
- keyframes,
521
- ...(ease ? { ease } : {}),
522
- ...(easeEach ? { easeEach } : {}),
523
- };
524
- }
525
- function computeKeyframesTotalDuration(varsNode, scope) {
526
- const kfNode = (varsNode.properties ?? []).find((p) => (p.key?.name ?? p.key?.value) === "keyframes")?.value;
527
- if (!kfNode || kfNode.type !== "ArrayExpression")
528
- return undefined;
529
- let total = 0;
530
- for (const el of kfNode.elements ?? []) {
531
- if (!el || el.type !== "ObjectExpression")
532
- continue;
533
- const r = objectExpressionToRecord(el, scope);
534
- if (typeof r.duration === "number")
535
- total += r.duration;
536
- }
537
- return total > 0 ? total : undefined;
538
- }
539
- // fallow-ignore-next-line complexity
540
- function parseObjectArrayKeyframes(node, scope) {
541
- const elements = node.elements ?? [];
542
- const raw = [];
543
- for (const el of elements) {
544
- if (!el || (el.type !== "ObjectExpression" && el.type !== "ObjectProperty")) {
545
- // Skip non-object elements
546
- if (el?.type !== "ObjectExpression")
547
- continue;
548
- }
549
- const record = objectExpressionToRecord(el, scope);
550
- const properties = {};
551
- let duration;
552
- let ease;
553
- for (const [k, v] of Object.entries(record)) {
554
- if (k === "duration" && typeof v === "number") {
555
- duration = v;
556
- }
557
- else if (k === "ease" && typeof v === "string") {
558
- ease = v;
559
- }
560
- else if (typeof v === "number" || typeof v === "string") {
561
- properties[k] = v;
562
- }
563
- }
564
- raw.push({ properties, duration, ease });
565
- }
566
- // Convert durations to percentage positions. If durations are present, use
567
- // cumulative ratios; otherwise distribute evenly.
568
- const totalDuration = raw.reduce((sum, r) => sum + (r.duration ?? 0), 0);
569
- const keyframes = [];
570
- if (totalDuration > 0) {
571
- let cumulative = 0;
572
- for (const entry of raw) {
573
- cumulative += entry.duration ?? 0;
574
- const percentage = Math.round((cumulative / totalDuration) * 100);
575
- keyframes.push({
576
- percentage,
577
- properties: entry.properties,
578
- ...(entry.ease ? { ease: entry.ease } : {}),
579
- });
580
- }
581
- }
582
- else {
583
- for (let i = 0; i < raw.length; i++) {
584
- const entry = raw[i];
585
- const percentage = raw.length > 1 ? Math.round((i / (raw.length - 1)) * 100) : 0;
586
- keyframes.push({
587
- percentage,
588
- properties: entry.properties,
589
- ...(entry.ease ? { ease: entry.ease } : {}),
590
- });
591
- }
592
- }
593
- return { format: "object-array", keyframes };
594
- }
595
- // fallow-ignore-next-line complexity
596
- function parseSimpleArrayKeyframes(node, scope) {
597
- const arrayProps = new Map();
598
- let ease;
599
- let easeEach;
600
- for (const prop of node.properties ?? []) {
601
- if (prop.type !== "ObjectProperty" && prop.type !== "Property")
602
- continue;
603
- const key = prop.key?.name ?? prop.key?.value;
604
- if (typeof key !== "string")
605
- continue;
606
- if (prop.value?.type === "ArrayExpression") {
607
- const values = [];
608
- for (const el of prop.value.elements ?? []) {
609
- const val = resolveNode(el, scope);
610
- if (typeof val === "number" || typeof val === "string") {
611
- values.push(val);
612
- }
613
- }
614
- if (values.length > 0)
615
- arrayProps.set(key, values);
616
- }
617
- else if (key === "ease") {
618
- ease = tryResolveStringProp(prop.value, scope) ?? ease;
619
- }
620
- else if (key === "easeEach") {
621
- easeEach = tryResolveStringProp(prop.value, scope) ?? easeEach;
622
- }
623
- }
624
- // Zip arrays into percentage keyframes (evenly spaced).
625
- const maxLen = Math.max(...[...arrayProps.values()].map((a) => a.length), 0);
626
- const keyframes = [];
627
- for (let i = 0; i < maxLen; i++) {
628
- const percentage = maxLen > 1 ? Math.round((i / (maxLen - 1)) * 100) : 0;
629
- const properties = {};
630
- for (const [key, values] of arrayProps) {
631
- if (i < values.length)
632
- properties[key] = values[i];
633
- }
634
- keyframes.push({ percentage, properties });
635
- }
636
- return {
637
- format: "simple-array",
638
- keyframes,
639
- ...(ease ? { ease } : {}),
640
- ...(easeEach ? { easeEach } : {}),
641
- };
642
- }
643
- function parseMotionPathNode(node, scope) {
644
- if (!node)
645
- return undefined;
646
- let pathNode;
647
- let autoRotate = false;
648
- let curviness = 1;
649
- let isCubic = false;
650
- if (node.type === "ObjectExpression") {
651
- for (const prop of node.properties ?? []) {
652
- if (!isObjectProperty(prop))
653
- continue;
654
- const key = propKeyName(prop);
655
- if (key === "path")
656
- pathNode = prop.value;
657
- else if (key === "autoRotate") {
658
- const val = resolveNode(prop.value, scope);
659
- autoRotate = typeof val === "number" ? val : val === true;
660
- }
661
- else if (key === "curviness") {
662
- const val = resolveNode(prop.value, scope);
663
- if (typeof val === "number")
664
- curviness = val;
665
- }
666
- else if (key === "type") {
667
- const val = resolveNode(prop.value, scope);
668
- if (val === "cubic")
669
- isCubic = true;
670
- }
671
- }
672
- }
673
- else if (node.type === "ArrayExpression") {
674
- pathNode = node;
675
- }
676
- if (!pathNode || pathNode.type !== "ArrayExpression")
677
- return undefined;
678
- const elements = pathNode.elements ?? [];
679
- const coords = [];
680
- for (const elem of elements) {
681
- if (!elem || elem.type !== "ObjectExpression")
682
- continue;
683
- const rec = objectExpressionToRecord(elem, scope);
684
- const x = typeof rec.x === "number" ? rec.x : undefined;
685
- const y = typeof rec.y === "number" ? rec.y : undefined;
686
- if (x !== undefined && y !== undefined)
687
- coords.push({ x, y });
688
- }
689
- if (coords.length < 2)
690
- return undefined;
691
- let waypoints;
692
- const segments = [];
693
- if (isCubic && coords.length >= 4) {
694
- // type: "cubic" — coords are [anchor, cp1, cp2, anchor, cp1, cp2, anchor, ...]
695
- // Every 3rd coord starting from 0 is an anchor, the two between are control points.
696
- waypoints = [];
697
- waypoints.push(coords[0]);
698
- for (let i = 1; i + 2 < coords.length; i += 3) {
699
- const cp1 = coords[i];
700
- const cp2 = coords[i + 1];
701
- const anchor = coords[i + 2];
702
- waypoints.push(anchor);
703
- segments.push({ curviness, cp1, cp2 });
704
- }
705
- }
706
- else {
707
- // Waypoint array with global curviness
708
- waypoints = coords;
709
- for (let i = 0; i < waypoints.length - 1; i++) {
710
- segments.push({ curviness });
711
- }
712
- }
713
- return {
714
- arcPath: { enabled: true, autoRotate, segments },
715
- waypoints,
716
- };
717
- }
718
- // fallow-ignore-next-line complexity
719
- function tweenCallToAnimation(call, scope) {
720
- const vars = objectExpressionToRecord(call.varsArg, scope);
721
- const properties = {};
722
- const extras = {};
723
- let keyframesData;
724
- let hasUnresolvedKeyframes = false;
725
- let motionPathResult;
726
- for (const [key, val] of Object.entries(vars)) {
727
- if (BUILTIN_VAR_KEYS.has(key))
728
- continue;
729
- if (DROPPED_VAR_KEYS.has(key))
730
- continue;
731
- if (key === "keyframes") {
732
- const kfNode = findPropertyNode(call.varsArg, "keyframes");
733
- keyframesData = parseKeyframesNode(kfNode, scope);
734
- if (!keyframesData && kfNode)
735
- hasUnresolvedKeyframes = true;
736
- continue;
737
- }
738
- if (key === "motionPath") {
739
- const mpNode = findPropertyNode(call.varsArg, "motionPath");
740
- motionPathResult = parseMotionPathNode(mpNode, scope);
741
- continue;
742
- }
743
- if (key === "easeEach") {
744
- // easeEach is only meaningful alongside keyframes — handled below.
745
- continue;
746
- }
747
- if (EXTRAS_KEYS.has(key)) {
748
- // For extras, prefer the raw AST source so complex objects like
749
- // `stagger: { each: 0.15, from: "start" }` survive verbatim.
750
- const rawSource = extractRawPropertySource(call.varsArg, key);
751
- if (rawSource !== undefined) {
752
- extras[key] = `__raw:${rawSource}`;
753
- }
754
- else if (val !== undefined) {
755
- extras[key] = val;
756
- }
757
- continue;
758
- }
759
- if (typeof val === "number" || typeof val === "string") {
760
- properties[key] = val;
761
- }
762
- }
763
- // Apply tween-level easeEach to keyframes data.
764
- if (keyframesData && typeof vars.easeEach === "string") {
765
- keyframesData.easeEach = vars.easeEach;
766
- }
767
- // When motionPath is present, reconstruct x/y as keyframe waypoints.
768
- if (motionPathResult) {
769
- const { waypoints } = motionPathResult;
770
- if (!keyframesData) {
771
- // No explicit keyframes — create synthetic percentage keyframes from waypoints.
772
- const kf = waypoints.map((wp, i) => ({
773
- percentage: waypoints.length > 1 ? Math.round((i / (waypoints.length - 1)) * 100) : 0,
774
- properties: { x: wp.x, y: wp.y },
775
- }));
776
- keyframesData = { format: "percentage", keyframes: kf };
777
- }
778
- else {
779
- // Merge waypoint positions into existing keyframes at matching percentages.
780
- // If keyframe count matches waypoint count, assign positionally.
781
- const kfs = keyframesData.keyframes;
782
- if (kfs.length === waypoints.length) {
783
- for (let i = 0; i < kfs.length; i++) {
784
- kfs[i].properties.x = waypoints[i].x;
785
- kfs[i].properties.y = waypoints[i].y;
786
- }
787
- }
788
- }
789
- // arcPath is attached below on the animation result.
790
- }
791
- let fromProperties;
792
- if (call.method === "fromTo" && call.fromArg) {
793
- fromProperties = {};
794
- const fromVars = objectExpressionToRecord(call.fromArg, scope);
795
- for (const [key, val] of Object.entries(fromVars)) {
796
- if (typeof val === "number" || typeof val === "string") {
797
- fromProperties[key] = val;
798
- }
799
- }
800
- }
801
- const hasPositionArg = !!call.positionArg;
802
- const posVal = call.positionArg ? extractLiteralValue(call.positionArg, scope) : 0;
803
- const position = typeof posVal === "number" ? posVal : typeof posVal === "string" ? posVal : 0;
804
- let duration = typeof vars.duration === "number" ? vars.duration : undefined;
805
- const ease = typeof vars.ease === "string" ? vars.ease : undefined;
806
- if (duration === undefined && keyframesData) {
807
- duration = computeKeyframesTotalDuration(call.varsArg, scope);
808
- }
809
- const anim = {
810
- targetSelector: call.selector,
811
- method: call.method,
812
- position,
813
- properties,
814
- fromProperties,
815
- duration,
816
- ease,
817
- };
818
- if (!hasPositionArg)
819
- anim.implicitPosition = true;
820
- let group = classifyTweenPropertyGroup(properties);
821
- if (!group && keyframesData) {
822
- const kfProps = {};
823
- for (const kf of keyframesData.keyframes) {
824
- for (const k of Object.keys(kf.properties))
825
- kfProps[k] = true;
826
- }
827
- group = classifyTweenPropertyGroup(kfProps);
828
- }
829
- if (group)
830
- anim.propertyGroup = group;
831
- if (call.global)
832
- anim.global = true;
833
- if (Object.keys(extras).length > 0)
834
- anim.extras = extras;
835
- if (keyframesData)
836
- anim.keyframes = keyframesData;
837
- if (motionPathResult)
838
- anim.arcPath = motionPathResult.arcPath;
839
- if (hasUnresolvedKeyframes)
840
- anim.hasUnresolvedKeyframes = true;
841
- if (call.selector === "__unresolved__")
842
- anim.hasUnresolvedSelector = true;
843
- return anim;
844
- }
845
- // ── Timeline Position Resolution ──────────────────────────────────────────
846
- const GSAP_DEFAULT_DURATION = 0.5;
847
- // NOTE: Label-based positions (e.g. "myLabel+=0.5") are not yet resolved —
848
- // they fall through to parseFloat which returns null for non-numeric strings.
849
- function resolvePositionString(pos, cursor, prevStart) {
850
- const trimmed = pos.trim();
851
- if (trimmed === "")
852
- return cursor;
853
- if (trimmed.startsWith("+=")) {
854
- const n = Number.parseFloat(trimmed.slice(2));
855
- return Number.isFinite(n) ? cursor + n : null;
856
- }
857
- if (trimmed.startsWith("-=")) {
858
- const n = Number.parseFloat(trimmed.slice(2));
859
- return Number.isFinite(n) ? cursor - n : null;
860
- }
861
- if (trimmed === "<")
862
- return prevStart;
863
- if (trimmed === ">")
864
- return cursor;
865
- if (trimmed.startsWith("<")) {
866
- const n = Number.parseFloat(trimmed.slice(1));
867
- return Number.isFinite(n) ? prevStart + n : null;
868
- }
869
- if (trimmed.startsWith(">")) {
870
- const n = Number.parseFloat(trimmed.slice(1));
871
- return Number.isFinite(n) ? cursor + n : null;
872
- }
873
- const n = Number.parseFloat(trimmed);
874
- return Number.isFinite(n) ? n : null;
875
- }
876
- function applyTimelineDefaults(anims, defaults) {
877
- if (!defaults)
878
- return;
879
- for (const anim of anims) {
880
- if (anim.method === "set")
881
- continue;
882
- if (anim.duration === undefined && defaults.duration !== undefined) {
883
- anim.duration = defaults.duration;
884
- }
885
- if (anim.ease === undefined && defaults.ease !== undefined) {
886
- anim.ease = defaults.ease;
887
- }
888
- }
889
- }
890
- function resolveTimelinePositions(anims) {
891
- let cursor = 0;
892
- let prevStart = 0;
893
- for (const anim of anims) {
894
- // A global `gsap.set(...)` is off-timeline — it's applied once at load, not
895
- // sequenced on the master timeline. It carries no position arg, so the
896
- // cursor-based fallback below would otherwise hand it the comp-end time
897
- // (every prior tween's duration summed). Pin it to 0 (its load-time start)
898
- // and don't let it advance the cursor/prevStart for following tweens.
899
- if (anim.method === "set" && anim.global) {
900
- anim.resolvedStart = 0;
901
- continue;
902
- }
903
- const duration = anim.method === "set" ? 0 : (anim.duration ?? GSAP_DEFAULT_DURATION);
904
- let start;
905
- if (anim.implicitPosition) {
906
- start = cursor;
907
- }
908
- else if (typeof anim.position === "number") {
909
- start = anim.position;
910
- }
911
- else if (typeof anim.position === "string") {
912
- start = resolvePositionString(anim.position, cursor, prevStart);
913
- }
914
- else {
915
- start = cursor;
916
- }
917
- if (start != null) {
918
- anim.resolvedStart = Math.max(0, start);
919
- prevStart = anim.resolvedStart;
920
- cursor = Math.max(cursor, anim.resolvedStart + duration);
921
- }
922
- }
923
- }
924
- function sortBySourcePosition(calls) {
925
- calls.sort((a, b) => {
926
- const aLoc = a.node.callee?.property?.loc?.start;
927
- const bLoc = b.node.callee?.property?.loc?.start;
928
- if (!aLoc || !bLoc)
929
- return 0;
930
- return aLoc.line - bLoc.line || aLoc.column - bLoc.column;
931
- });
932
- }
933
- // ── Stable ID Generation ───────────────────────────────────────────────────
934
- /**
935
- * IDs are transient — recomputed on every parse, never persisted across sessions.
936
- * They exist only in ephemeral request/response payloads, React component state,
937
- * and the in-memory keyframe cache (rebuilt on every page load). No database,
938
- * localStorage, or file stores animation IDs, so changing the ID format (e.g.
939
- * adding a `-scale`/`-position` suffix) is safe.
940
- */
941
- function assignStableIds(anims) {
942
- const counts = new Map();
943
- return anims.map((anim) => {
944
- const posKey = typeof anim.position === "number"
945
- ? String(Math.round(anim.position * 1000))
946
- : String(anim.position);
947
- const groupSuffix = anim.propertyGroup ? `-${anim.propertyGroup}` : "";
948
- const base = `${anim.targetSelector}-${anim.method}-${posKey}${groupSuffix}`;
949
- const count = (counts.get(base) ?? 0) + 1;
950
- counts.set(base, count);
951
- const id = count === 1 ? base : `${base}-${count}`;
952
- return { ...anim, id };
953
- });
954
- }
955
- /**
956
- * Parse a script to its recast AST plus the located tween calls. The mutation
957
- * functions reuse this so they can edit the exact call node in place (recast
958
- * preserves all surrounding source — interleaved `gsap.set`, element variable
959
- * declarations, the IIFE wrapper, comments and formatting).
960
- */
961
- function parseGsapAst(script) {
962
- const ast = parseScript(script);
963
- const scope = collectScopeBindings(ast);
964
- const targetBindings = collectTargetBindings(ast, scope);
965
- const detection = findTimelineVar(ast, scope);
966
- const timelineVar = detection.timelineVar ?? "tl";
967
- const calls = findAllTweenCalls(ast, timelineVar, scope, targetBindings);
968
- sortBySourcePosition(calls);
969
- const rawAnims = calls.map((call) => tweenCallToAnimation(call, scope));
970
- applyTimelineDefaults(rawAnims, detection.defaults);
971
- resolveTimelinePositions(rawAnims);
972
- const animations = assignStableIds(rawAnims);
973
- const located = animations.map((animation, i) => ({
974
- id: animation.id,
975
- call: calls[i],
976
- animation,
977
- }));
978
- return { ast, scope, timelineVar, detection, located };
979
- }
980
- // ── Public API ──────────────────────────────────────────────────────────────
981
- export function parseGsapScript(script) {
982
- try {
983
- const { detection, timelineVar, located } = parseGsapAst(script);
984
- const animations = located.map((l) => l.animation);
985
- const timelineMatch = script.match(new RegExp(`^[\\s\\S]*?(?:const|let|var)\\s+${timelineVar}\\s*=\\s*gsap\\.timeline\\s*\\([^)]*\\)\\s*;?`));
986
- const preamble = timelineMatch?.[0] ?? `const ${timelineVar} = gsap.timeline({ paused: true });`;
987
- const lastCallIdx = script.lastIndexOf(`${timelineVar}.`);
988
- let postamble = "";
989
- if (lastCallIdx !== -1) {
990
- const afterLast = script.slice(lastCallIdx);
991
- const endOfCall = afterLast.indexOf(";");
992
- if (endOfCall !== -1) {
993
- postamble = script.slice(lastCallIdx + endOfCall + 1).trim();
994
- }
995
- }
996
- const result = { animations, timelineVar, preamble, postamble };
997
- if (detection.timelineCount > 1)
998
- result.multipleTimelines = true;
999
- if (detection.timelineCount > 0 && detection.timelineVar === null)
1000
- result.unsupportedTimelinePattern = true;
1001
- return result;
1002
- }
1003
- catch {
1004
- return { animations: [], timelineVar: "tl", preamble: "", postamble: "" };
1005
- }
1006
- }
1007
- // ── In-place AST mutation helpers ───────────────────────────────────────────
1008
- //
1009
- // Edits operate directly on the located call's AST node and reprint via recast,
1010
- // which preserves every untouched statement. This is what lets us edit tweens
1011
- // in real compositions (variable targets, interleaved `gsap.set`, IIFE wrapper)
1012
- // without regenerating — and discarding — the surrounding code.
1013
- /**
1014
- * Parse a value/expression snippet into a standalone AST expression node.
1015
- * Uses an assignment (`__hf__ = <code>`) rather than wrapping in parens so an
1016
- * object literal parses as an expression without recast re-emitting the
1017
- * surrounding parentheses.
1018
- */
1019
- function parseExpr(code) {
1020
- return parseScript(`__hf__ = ${code};`).program.body[0].expression.right;
1021
- }
1022
- function propKeyName(prop) {
1023
- return prop?.key?.name ?? prop?.key?.value;
1024
- }
1025
- function isObjectProperty(prop) {
1026
- return prop?.type === "ObjectProperty" || prop?.type === "Property";
1027
- }
1028
- /** A key the inspector treats as an editable transform/style property. */
1029
- function isEditablePropertyKey(key) {
1030
- return !BUILTIN_VAR_KEYS.has(key) && !DROPPED_VAR_KEYS.has(key) && !EXTRAS_KEYS.has(key);
1031
- }
1032
- function makeObjectProperty(key, value) {
1033
- const obj = parseExpr(`{ ${safeKey(key)}: ${valueToCode(value)} }`);
1034
- return obj.properties[0];
1035
- }
1036
- /** Set (or insert) a single key on an ObjectExpression, preserving sibling keys. */
1037
- function setVarsKey(varsArg, key, value) {
1038
- if (varsArg?.type !== "ObjectExpression")
1039
- return;
1040
- const existing = varsArg.properties.find((p) => isObjectProperty(p) && propKeyName(p) === key);
1041
- if (existing) {
1042
- existing.value = parseExpr(valueToCode(value));
1043
- }
1044
- else {
1045
- varsArg.properties.push(makeObjectProperty(key, value));
1046
- }
1047
- }
1048
- /**
1049
- * Filter an ObjectExpression's properties, keeping non-editable keys
1050
- * and delegating the keep/drop decision for editable keys to `shouldKeep`.
1051
- */
1052
- function filterEditableKeys(varsArg, shouldKeep) {
1053
- if (varsArg?.type !== "ObjectExpression")
1054
- return;
1055
- varsArg.properties = varsArg.properties.filter((p) => {
1056
- if (!isObjectProperty(p))
1057
- return true;
1058
- const key = propKeyName(p);
1059
- if (typeof key !== "string")
1060
- return true;
1061
- if (!isEditablePropertyKey(key))
1062
- return true;
1063
- return shouldKeep(key);
1064
- });
1065
- }
1066
- /**
1067
- * Replace the editable-property keys on an ObjectExpression with `newProps`,
1068
- * leaving `duration`, `ease`, `stagger`, callbacks and other non-editable keys
1069
- * untouched.
1070
- */
1071
- function reconcileEditableProperties(varsArg, newProps) {
1072
- filterEditableKeys(varsArg, (key) => key in newProps);
1073
- // Upsert each new prop, preserving the order keys first appeared.
1074
- for (const [key, value] of Object.entries(newProps)) {
1075
- setVarsKey(varsArg, key, value);
1076
- }
1077
- }
1078
- function applyEaseUpdate(varsArg, ease) {
1079
- const kfNode = findKeyframesObjectNode(varsArg);
1080
- if (kfNode) {
1081
- setVarsKey(kfNode, "easeEach", ease);
1082
- removeVarsKey(varsArg, "ease");
1083
- }
1084
- else {
1085
- setVarsKey(varsArg, "ease", ease);
1086
- }
1087
- }
1088
- /**
1089
- * "Apply to all segments": drop every per-keyframe `ease` override so the single
1090
- * `easeEach` governs all segments uniformly (AE select-all + F9). Mirrors the
1091
- * acorn writer's resetKeyframeEases branch.
1092
- */
1093
- function stripKeyframeEases(varsArg) {
1094
- const kfNode = findKeyframesObjectNode(varsArg);
1095
- const props = kfNode?.properties;
1096
- if (!Array.isArray(props))
1097
- return;
1098
- for (const entry of props) {
1099
- if (isObjectProperty(entry))
1100
- removeVarsKey(entry.value, "ease");
1101
- }
1102
- }
1103
- function applyUpdatesToCall(call, updates) {
1104
- if (updates.properties)
1105
- reconcileEditableProperties(call.varsArg, updates.properties);
1106
- if (updates.fromProperties && call.method === "fromTo" && call.fromArg) {
1107
- reconcileEditableProperties(call.fromArg, updates.fromProperties);
1108
- }
1109
- if (updates.duration !== undefined)
1110
- setVarsKey(call.varsArg, "duration", updates.duration);
1111
- if (updates.easeEach !== undefined)
1112
- applyEaseUpdate(call.varsArg, updates.easeEach);
1113
- else if (updates.ease !== undefined)
1114
- applyEaseUpdate(call.varsArg, updates.ease);
1115
- if (updates.resetKeyframeEases)
1116
- stripKeyframeEases(call.varsArg);
1117
- if (updates.position !== undefined) {
1118
- const posIdx = call.method === "fromTo" ? 3 : 2;
1119
- call.node.arguments[posIdx] = parseExpr(valueToCode(updates.position));
1120
- }
1121
- }
1122
- /** Walk up to the enclosing ExpressionStatement path (for prune / insertAfter). */
1123
- function findStatementPath(path) {
1124
- let p = path;
1125
- while (p) {
1126
- if (p.node?.type === "ExpressionStatement")
1127
- return p;
1128
- p = p.parentPath;
1129
- }
1130
- return null;
1131
- }
1132
- function insertAfterAnchor(parsed, newStatement) {
1133
- const lastCall = parsed.located[parsed.located.length - 1]?.call;
1134
- const anchorPath = lastCall
1135
- ? findStatementPath(lastCall.path)
1136
- : findTimelineDeclarationPath(parsed.ast, parsed.timelineVar);
1137
- if (anchorPath) {
1138
- anchorPath.insertAfter(newStatement);
1139
- }
1140
- else {
1141
- parsed.ast.program.body.push(newStatement);
1142
- }
1143
- }
1144
- /** Build the source for a single `tl.method(selector, vars, position)` call. */
1145
- function buildTweenStatementCode(timelineVar, anim) {
1146
- const selector = JSON.stringify(anim.targetSelector);
1147
- const props = { ...anim.properties };
1148
- if (anim.method !== "set" && anim.duration !== undefined)
1149
- props.duration = anim.duration;
1150
- if (anim.ease)
1151
- props.ease = anim.ease;
1152
- const entries = Object.entries(props).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`);
1153
- // immediateRender forces GSAP to apply the set when added to the timeline,
1154
- // not on the first seek — without it, tl.set at position 0 on a paused
1155
- // timeline is invisible until the playhead moves past 0. A base `gsap.set`
1156
- // already runs immediately, so it doesn't need (or get) the flag.
1157
- if (anim.method === "set" && !anim.global)
1158
- entries.push("immediateRender: true");
1159
- if (anim.extras) {
1160
- for (const [k, v] of Object.entries(anim.extras)) {
1161
- entries.push(`${safeKey(k)}: ${valueToCode(v)}`);
1162
- }
1163
- }
1164
- const objCode = `{ ${entries.join(", ")} }`;
1165
- const posCode = valueToCode(typeof anim.position === "number" ? anim.position : (anim.position ?? 0));
1166
- if (anim.method === "fromTo") {
1167
- const fromEntries = Object.entries(anim.fromProperties ?? {}).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`);
1168
- const fromCode = `{ ${fromEntries.join(", ")} }`;
1169
- return `${timelineVar}.fromTo(${selector}, ${fromCode}, ${objCode}, ${posCode});`;
1170
- }
1171
- // A base `gsap.set` is off the timeline: no timeline var, no position arg.
1172
- if (anim.method === "set" && anim.global) {
1173
- return `gsap.set(${selector}, ${objCode});`;
1174
- }
1175
- return `${timelineVar}.${anim.method}(${selector}, ${objCode}, ${posCode});`;
1176
- }
1177
- export function updateAnimationInScript(script, animationId, updates) {
1178
- let parsed;
1179
- try {
1180
- parsed = parseGsapAst(script);
1181
- }
1182
- catch (e) {
1183
- console.warn("[gsap-parser] updateAnimationInScript parse failed:", e);
1184
- return script;
1185
- }
1186
- const target = parsed.located.find((l) => l.id === animationId);
1187
- if (!target)
1188
- return script;
1189
- applyUpdatesToCall(target.call, updates);
1190
- return recast.print(parsed.ast).code;
1191
- }
1192
- export function shiftPositionsInScript(script, targetSelector, delta) {
1193
- let parsed;
1194
- try {
1195
- parsed = parseGsapAst(script);
1196
- }
1197
- catch (e) {
1198
- console.warn("[gsap-parser] shiftPositionsInScript parse failed:", e);
1199
- return script;
1200
- }
1201
- let changed = false;
1202
- for (const entry of parsed.located) {
1203
- if (entry.animation.targetSelector !== targetSelector)
1204
- continue;
1205
- if (typeof entry.animation.position !== "number")
1206
- continue;
1207
- const newPos = Math.max(0, Math.round((entry.animation.position + delta) * 1000) / 1000);
1208
- applyUpdatesToCall(entry.call, { position: newPos });
1209
- changed = true;
1210
- }
1211
- return changed ? recast.print(parsed.ast).code : script;
1212
- }
1213
- export function scalePositionsInScript(script, targetSelector, oldStart, oldDuration, newStart, newDuration) {
1214
- if (oldDuration <= 0 || newDuration <= 0)
1215
- return script;
1216
- const ratio = newDuration / oldDuration;
1217
- let parsed;
1218
- try {
1219
- parsed = parseGsapAst(script);
1220
- }
1221
- catch (e) {
1222
- console.warn("[gsap-parser] scalePositionsInScript parse failed:", e);
1223
- return script;
1224
- }
1225
- let changed = false;
1226
- for (const entry of parsed.located) {
1227
- if (entry.animation.targetSelector !== targetSelector)
1228
- continue;
1229
- if (typeof entry.animation.position !== "number")
1230
- continue;
1231
- const newPos = Math.max(0, Math.round((newStart + (entry.animation.position - oldStart) * ratio) * 1000) / 1000);
1232
- const updates = { position: newPos };
1233
- if (typeof entry.animation.duration === "number" && entry.animation.duration > 0) {
1234
- updates.duration = Math.max(0.001, Math.round(entry.animation.duration * ratio * 1000) / 1000);
1235
- }
1236
- applyUpdatesToCall(entry.call, updates);
1237
- changed = true;
1238
- }
1239
- return changed ? recast.print(parsed.ast).code : script;
1240
- }
1241
- function updateAnimationSelector(script, animationId, newSelector) {
1242
- let parsed;
1243
- try {
1244
- parsed = parseGsapAst(script);
1245
- }
1246
- catch {
1247
- return script;
1248
- }
1249
- const target = parsed.located.find((l) => l.id === animationId);
1250
- if (!target)
1251
- return script;
1252
- const selectorArg = target.call.path.node.arguments?.[0];
1253
- if (selectorArg?.type === "StringLiteral") {
1254
- selectorArg.value = newSelector;
1255
- }
1256
- else if (selectorArg?.type === "Identifier") {
1257
- target.call.path.node.arguments[0] = { type: "StringLiteral", value: newSelector };
1258
- }
1259
- return recast.print(parsed.ast).code;
1260
- }
1261
- export function addAnimationToScript(script, animation) {
1262
- let parsed;
1263
- try {
1264
- parsed = parseGsapAst(script);
1265
- }
1266
- catch (e) {
1267
- console.warn("[gsap-parser] addAnimationToScript parse failed:", e);
1268
- return { script, id: "" };
1269
- }
1270
- // Nothing to anchor against and no timeline to target — treat as parse failure.
1271
- if (parsed.located.length === 0 && parsed.detection.timelineVar === null) {
1272
- return { script, id: "" };
1273
- }
1274
- const id = `anim-${Date.now()}`;
1275
- const statementCode = buildTweenStatementCode(parsed.timelineVar, animation);
1276
- const newStatement = parseScript(statementCode).program.body[0];
1277
- insertAfterAnchor(parsed, newStatement);
1278
- return { script: recast.print(parsed.ast).code, id };
1279
- }
1280
- export function addAnimationWithKeyframesToScript(script, targetSelector, position, duration, keyframes, ease, easeEach) {
1281
- let parsed;
1282
- try {
1283
- parsed = parseGsapAst(script);
1284
- }
1285
- catch (e) {
1286
- console.warn("[gsap-parser] addAnimationWithKeyframesToScript parse failed:", e);
1287
- return { script, id: "" };
1288
- }
1289
- if (parsed.located.length === 0 && parsed.detection.timelineVar === null) {
1290
- return { script, id: "" };
1291
- }
1292
- const selector = JSON.stringify(targetSelector);
1293
- const kfCode = buildKeyframeObjectCode(keyframes, easeEach ? { easeEach } : undefined);
1294
- const varEntries = [`keyframes: ${kfCode}`, `duration: ${valueToCode(duration)}`];
1295
- if (ease)
1296
- varEntries.push(`ease: ${JSON.stringify(ease)}`);
1297
- const posCode = valueToCode(position);
1298
- const stmtCode = `${parsed.timelineVar}.to(${selector}, { ${varEntries.join(", ")} }, ${posCode});`;
1299
- const newStatement = parseScript(stmtCode).program.body[0];
1300
- insertAfterAnchor(parsed, newStatement);
1301
- const result = recast.print(parsed.ast).code;
1302
- const reParsed = parseGsapAst(result);
1303
- const newId = reParsed.located[reParsed.located.length - 1]?.id ?? "";
1304
- return { script: result, id: newId };
1305
- }
1306
- /** Find the statement path of `const <timelineVar> = gsap.timeline(...)`. */
1307
- function findTimelineDeclarationPath(ast, timelineVar) {
1308
- let found = null;
1309
- recast.types.visit(ast, {
1310
- visitVariableDeclaration(path) {
1311
- if (found)
1312
- return false;
1313
- for (const decl of path.node.declarations ?? []) {
1314
- if (decl.id?.name === timelineVar && isGsapTimelineCall(decl.init)) {
1315
- found = path;
1316
- return false;
1317
- }
1318
- }
1319
- this.traverse(path);
1320
- },
1321
- });
1322
- return found;
1323
- }
1324
- /** Find the call that chains off `targetNode` (i.e. whose callee object IS it). */
1325
- function findChainParentCall(stmtNode, targetNode) {
1326
- let found = null;
1327
- recast.types.visit(stmtNode, {
1328
- visitCallExpression(p) {
1329
- if (found)
1330
- return false;
1331
- if (p.node.callee?.type === "MemberExpression" && p.node.callee.object === targetNode) {
1332
- found = p.node;
1333
- return false;
1334
- }
1335
- this.traverse(p);
1336
- },
1337
- });
1338
- return found;
1339
- }
1340
- export function removeAnimationFromScript(script, animationId) {
1341
- let parsed;
1342
- try {
1343
- parsed = parseGsapAst(script);
1344
- }
1345
- catch (e) {
1346
- console.warn("[gsap-parser] removeAnimationFromScript parse failed:", e);
1347
- return script;
1348
- }
1349
- let target = parsed.located.find((l) => l.id === animationId);
1350
- if (!target) {
1351
- const convertedId = animationId.replace(/-from-|-fromTo-/, "-to-");
1352
- target = parsed.located.find((l) => l.id === convertedId);
1353
- }
1354
- if (!target)
1355
- return script;
1356
- const node = target.call.node;
1357
- const stmtPath = findStatementPath(target.call.path);
1358
- if (!stmtPath)
1359
- return script;
1360
- const parentCall = findChainParentCall(stmtPath.node, node);
1361
- if (parentCall) {
1362
- // Inner link of a chain — splice it out by re-pointing the next link.
1363
- parentCall.callee.object = node.callee.object;
1364
- }
1365
- else if (node.callee?.object?.type === "CallExpression") {
1366
- // Outermost link of a chain with earlier links — drop just this link.
1367
- stmtPath.node.expression = node.callee.object;
1368
- }
1369
- else {
1370
- // Standalone tween — remove the whole statement.
1371
- stmtPath.prune();
1372
- }
1373
- return recast.print(parsed.ast).code;
1374
- }
1375
- function insertInheritedStateSet(script, selector, position, properties) {
1376
- let parsed;
1377
- try {
1378
- parsed = parseGsapAst(script);
1379
- }
1380
- catch {
1381
- return script;
1382
- }
1383
- const tlVar = parsed.timelineVar;
1384
- const props = Object.entries(properties)
1385
- .map(([k, v]) => `${k}: ${typeof v === "string" ? JSON.stringify(v) : v}`)
1386
- .join(", ");
1387
- const code = `${tlVar}.set(${JSON.stringify(selector)}, { ${props} }, ${position});`;
1388
- const newStatement = parseScript(code).program.body[0];
1389
- const anchor = findTimelineDeclarationPath(parsed.ast, tlVar);
1390
- if (anchor) {
1391
- anchor.insertAfter(newStatement);
1392
- }
1393
- else if (parsed.located.length > 0) {
1394
- const firstTween = parsed.located[0].call;
1395
- const stmtPath = findStatementPath(firstTween.path);
1396
- if (stmtPath)
1397
- stmtPath.insertBefore(newStatement);
1398
- else
1399
- parsed.ast.program.body.unshift(newStatement);
1400
- }
1401
- else {
1402
- parsed.ast.program.body.push(newStatement);
1403
- }
1404
- return recast.print(parsed.ast).code;
1405
- }
1406
- /** Marker on Studio-emitted pre-keyframe hold `set`s. `data` is a GSAP-reserved
1407
- * config key (attached to the tween, never applied to the target), so it carries
1408
- * the tag without triggering GSAP's "Invalid property" warning. */
1409
- const STUDIO_HOLD_MARKER = "hf-hold";
1410
- /** True for a `tl.set(...)` this module emitted to hold a keyframe before its tween.
1411
- * The Studio filters these out so they never appear as user keyframes/diamonds. */
1412
- export function isStudioHoldSet(anim) {
1413
- return anim.method === "set" && anim.properties?.data === STUDIO_HOLD_MARKER;
1414
- }
1415
- /**
1416
- * Keep a `tl.set(selector, {x,y}, 0)` "hold" in front of every position-keyframed
1417
- * tween that starts after t=0, so the element holds its first keyframe's position
1418
- * BEFORE the tween plays instead of snapping to its CSS base (the universal NLE
1419
- * "hold before first keyframe" behavior). The set is tagged with `data: "hf-hold"`
1420
- * so this pass owns it: every call wipes the prior holds and recomputes from the
1421
- * current keyframes, keeping them in sync as keyframes are added/moved/deleted.
1422
- *
1423
- * Idempotent. Only position props (x/y/xPercent/yPercent) are held — opacity/scale
1424
- * keep their authored pre-tween behavior. A tween already starting at 0 needs no
1425
- * hold (no gap before it).
1426
- */
1427
- export function syncPositionHoldsBeforeKeyframes(script) {
1428
- let parsed;
1429
- try {
1430
- parsed = parseGsapScript(script);
1431
- }
1432
- catch {
1433
- return script;
1434
- }
1435
- // 1. Drop every hold this pass previously emitted, so we recompute fresh.
1436
- let result = script;
1437
- const staleHoldIds = parsed.animations.filter(isStudioHoldSet).map((a) => a.id);
1438
- for (const id of staleHoldIds)
1439
- result = removeAnimationFromScript(result, id);
1440
- // 2. Re-add a hold for each position-keyframed tween that starts after t=0.
1441
- let reparsed;
1442
- try {
1443
- reparsed = parseGsapScript(result);
1444
- }
1445
- catch {
1446
- return result;
1447
- }
1448
- for (const anim of reparsed.animations) {
1449
- if (!anim.keyframes)
1450
- continue;
1451
- const start = anim.resolvedStart ?? (typeof anim.position === "number" ? anim.position : 0);
1452
- if (!(start > 0.001))
1453
- continue;
1454
- const firstKf = [...anim.keyframes.keyframes].sort((a, b) => a.percentage - b.percentage)[0];
1455
- if (!firstKf)
1456
- continue;
1457
- const posProps = {};
1458
- for (const [k, v] of Object.entries(firstKf.properties)) {
1459
- if (classifyPropertyGroup(k) === "position" && typeof v === "number")
1460
- posProps[k] = v;
1461
- }
1462
- if (Object.keys(posProps).length === 0)
1463
- continue;
1464
- result = insertInheritedStateSet(result, anim.targetSelector, 0, {
1465
- ...posProps,
1466
- data: STUDIO_HOLD_MARKER,
1467
- });
1468
- }
1469
- return result;
1470
- }
1471
- // fallow-ignore-next-line complexity
1472
- export function splitAnimationsInScript(script, opts) {
1473
- const parsed = parseGsapScript(script);
1474
- const originalSelector = `#${opts.originalId}`;
1475
- const newSelector = `#${opts.newId}`;
1476
- const skippedSelectors = [];
1477
- for (const a of parsed.animations) {
1478
- if (a.targetSelector !== originalSelector && a.targetSelector.includes(opts.originalId)) {
1479
- skippedSelectors.push(a.targetSelector);
1480
- }
1481
- }
1482
- const matching = parsed.animations.filter((a) => a.targetSelector === originalSelector);
1483
- if (matching.length === 0)
1484
- return { script, skippedSelectors };
1485
- let result = script;
1486
- const newElementStart = opts.splitTime;
1487
- const inheritedProps = {};
1488
- // Reverse iteration: updateAnimationSelector mutates selectors in the source
1489
- // string, which can shift count-based ID suffixes (e.g. "#hero-1" → "#hero-2")
1490
- // for later animations. Processing last-to-first prevents stale ID collisions.
1491
- for (let i = matching.length - 1; i >= 0; i--) {
1492
- const anim = matching[i];
1493
- const pos = typeof anim.position === "number" ? anim.position : 0;
1494
- const dur = anim.duration ?? 0;
1495
- const animEnd = pos + dur;
1496
- if (anim.keyframes) {
1497
- if (pos >= opts.splitTime) {
1498
- result = updateAnimationSelector(result, anim.id, newSelector);
1499
- }
1500
- else if (animEnd > opts.splitTime) {
1501
- // Spanning keyframes can't be correctly split without renormalizing
1502
- // percentages and durations — leave on original, warn the caller.
1503
- skippedSelectors.push(`${originalSelector} (keyframes spanning split)`);
1504
- const kfs = anim.keyframes.keyframes;
1505
- for (const kf of kfs) {
1506
- const kfTime = pos + (kf.percentage / 100) * dur;
1507
- if (kfTime <= opts.splitTime) {
1508
- for (const [k, v] of Object.entries(kf.properties)) {
1509
- inheritedProps[k] = v;
1510
- }
1511
- }
1512
- }
1513
- }
1514
- else {
1515
- // Entirely before split — extract final keyframe properties
1516
- const kfs = anim.keyframes.keyframes;
1517
- if (kfs.length > 0) {
1518
- for (const [k, v] of Object.entries(kfs[kfs.length - 1].properties)) {
1519
- inheritedProps[k] = v;
1520
- }
1521
- }
1522
- }
1523
- continue;
1524
- }
1525
- // `<=` (not `<`) is deliberate: a tween whose end coincides exactly with
1526
- // the split boundary has fully played by splitTime, so it belongs to the
1527
- // first half and contributes its resting state to the clone. The spanning
1528
- // branch below handles only strictly-mid-flight tweens (pos < split < end).
1529
- if (animEnd <= opts.splitTime) {
1530
- // Only a completed .from() reverts the element to its natural state, so
1531
- // its recorded properties are the HIDDEN start (e.g. opacity:0), not the
1532
- // resting state — clearing them keeps the clone at its natural value
1533
- // instead of pinning it to the from-values (which made it invisible).
1534
- // .fromTo() and .to() both END at their to-values (no revert), so they
1535
- // fall through to `else` and inherit `anim.properties` (the to-values) —
1536
- // .fromTo() must NOT join the .from() clear-branch or the clone would
1537
- // drop the very state the fromTo just established.
1538
- if (anim.method === "from") {
1539
- for (const k of Object.keys(anim.properties))
1540
- delete inheritedProps[k];
1541
- }
1542
- else {
1543
- for (const [k, v] of Object.entries(anim.properties)) {
1544
- inheritedProps[k] = v;
1545
- }
1546
- }
1547
- continue;
1548
- }
1549
- if (pos >= opts.splitTime) {
1550
- result = updateAnimationSelector(result, anim.id, newSelector);
1551
- continue;
1552
- }
1553
- // Spans the split — use linear interpolation to compute mid-values,
1554
- // then .fromTo() on the clone so both halves play the correct range.
1555
- // For .fromTo() tweens we have explicit from-values; for .to() tweens
1556
- // we use accumulated state from prior animations, defaulting to 0 for
1557
- // unknown numeric properties (the standard GSAP transform initial state).
1558
- const progress = dur > 0 ? (opts.splitTime - pos) / dur : 0;
1559
- const fromSource = anim.fromProperties ?? inheritedProps;
1560
- const midProps = {};
1561
- for (const [k, v] of Object.entries(anim.properties)) {
1562
- if (typeof v !== "number") {
1563
- midProps[k] = v;
1564
- continue;
1565
- }
1566
- const fromVal = typeof fromSource[k] === "number" ? fromSource[k] : 0;
1567
- midProps[k] = fromVal + (v - fromVal) * progress;
1568
- }
1569
- const firstHalfDuration = opts.splitTime - pos;
1570
- result = updateAnimationInScript(result, anim.id, {
1571
- duration: firstHalfDuration,
1572
- properties: midProps,
1573
- });
1574
- const secondHalfDuration = animEnd - opts.splitTime;
1575
- const addResult = addAnimationToScript(result, {
1576
- targetSelector: newSelector,
1577
- method: "fromTo",
1578
- position: newElementStart,
1579
- duration: secondHalfDuration,
1580
- properties: { ...anim.properties },
1581
- fromProperties: { ...midProps },
1582
- ease: anim.ease,
1583
- extras: anim.extras,
1584
- });
1585
- result = addResult.script;
1586
- for (const [k, v] of Object.entries(midProps)) {
1587
- inheritedProps[k] = v;
1588
- }
1589
- }
1590
- if (Object.keys(inheritedProps).length > 0) {
1591
- result = insertInheritedStateSet(result, newSelector, newElementStart, inheritedProps);
1592
- }
1593
- return { script: result, skippedSelectors };
1594
- }
1595
- // ── Keyframe Mutation Functions ────────────────────────────────────────────
1596
- function sortedKeyframes(kfs) {
1597
- return kfs.slice().sort((a, b) => a.percentage - b.percentage);
1598
- }
1599
- function keyframePropsToCode(kf) {
1600
- return Object.entries(kf.properties).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`);
1601
- }
1602
- function buildKeyframeObjectCode(keyframes, options) {
1603
- const entries = keyframes.map((kf) => {
1604
- const props = keyframePropsToCode(kf);
1605
- if (kf.ease)
1606
- props.push(`ease: ${JSON.stringify(kf.ease)}`);
1607
- if (kf.auto)
1608
- props.push(`_auto: 1`);
1609
- return `${JSON.stringify(`${kf.percentage}%`)}: { ${props.join(", ")} }`;
1610
- });
1611
- if (options?.easeEach)
1612
- entries.push(`easeEach: ${JSON.stringify(options.easeEach)}`);
1613
- return `{ ${entries.join(", ")} }`;
1614
- }
1615
- /** Remove a named property from an ObjectExpression's properties array. */
1616
- function removeVarsKey(varsArg, key) {
1617
- if (varsArg?.type !== "ObjectExpression")
1618
- return;
1619
- varsArg.properties = varsArg.properties.filter((p) => !(isObjectProperty(p) && propKeyName(p) === key));
1620
- }
1621
- /** Extract the numeric percentage from a key like "50%". Returns NaN for non-percentage keys. */
1622
- function percentageFromKey(key) {
1623
- const m = PERCENTAGE_KEY_RE.exec(key);
1624
- return m ? Number.parseFloat(m[1]) : Number.NaN;
1625
- }
1626
- const PCT_TOLERANCE = 2;
1627
- function findKeyframePropByPct(kfNode, percentage) {
1628
- const props = kfNode.properties;
1629
- for (let i = 0; i < props.length; i++) {
1630
- if (!isObjectProperty(props[i]))
1631
- continue;
1632
- const key = propKeyName(props[i]);
1633
- if (typeof key !== "string")
1634
- continue;
1635
- const parsed = percentageFromKey(key);
1636
- if (Number.isNaN(parsed))
1637
- continue;
1638
- if (Math.abs(parsed - percentage) <= PCT_TOLERANCE)
1639
- return { idx: i, prop: props[i] };
1640
- }
1641
- return null;
1642
- }
1643
- /** Build a keyframe value AST node from properties and optional ease. */
1644
- function buildKeyframeValueNode(properties, ease) {
1645
- const entries = Object.entries(properties).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`);
1646
- if (ease)
1647
- entries.push(`ease: ${JSON.stringify(ease)}`);
1648
- return parseExpr(`{ ${entries.join(", ")} }`);
1649
- }
1650
- /** Parse + locate a target animation, returning null on failure. */
1651
- function locateAnimation(script, animationId) {
1652
- let parsed;
1653
- try {
1654
- parsed = parseGsapAst(script);
1655
- }
1656
- catch {
1657
- return null;
1658
- }
1659
- const target = parsed.located.find((l) => l.id === animationId);
1660
- return target ? { parsed, target } : null;
1661
- }
1662
- // Animation ids encode the tween's timeline position in ms
1663
- // (`#puck-a-to-1200-position`). A gesture/convert can re-emit a tween at a
1664
- // different position, changing its id — so a client that cached the old id (its
1665
- // selectedGsapAnimations hasn't refreshed) edits a now-nonexistent id and the op
1666
- // no-ops. Parse `{selector}-{method}-{posMs}-{group}` so we can fall back to the
1667
- // same selector+method+group tween nearest the requested position.
1668
- const ANIM_ID_RE = /^(.*)-(fromTo|from|to|set)-(\d+)-([a-z]+)$/;
1669
- function locateAnimationWithFallback(script, animationId) {
1670
- const loc = locateAnimation(script, animationId);
1671
- if (loc)
1672
- return loc;
1673
- const convertedId = animationId.replace(/-from-|-fromTo-/, "-to-");
1674
- if (convertedId !== animationId) {
1675
- const converted = locateAnimation(script, convertedId);
1676
- if (converted)
1677
- return converted;
1678
- }
1679
- // Position-drift fallback: match by stable identity (selector+method+group),
1680
- // disambiguating by the position closest to the one the caller asked for.
1681
- const want = ANIM_ID_RE.exec(animationId);
1682
- if (!want)
1683
- return null;
1684
- const [, sel, method, wantPosStr, group] = want;
1685
- const wantPos = Number(wantPosStr);
1686
- let parsed;
1687
- try {
1688
- parsed = parseGsapAst(script);
1689
- }
1690
- catch {
1691
- return null;
1692
- }
1693
- let best = null;
1694
- let bestDist = Number.POSITIVE_INFINITY;
1695
- for (const l of parsed.located) {
1696
- const m = ANIM_ID_RE.exec(l.id);
1697
- if (!m || m[1] !== sel || m[2] !== method || m[4] !== group)
1698
- continue;
1699
- const dist = Math.abs(Number(m[3]) - wantPos);
1700
- if (dist < bestDist) {
1701
- best = l;
1702
- bestDist = dist;
1703
- }
1704
- }
1705
- return best ? { parsed, target: best } : null;
1706
- }
1707
- /** Find the keyframes ObjectExpression node on a tween's varsArg, or null. */
1708
- function findKeyframesObjectNode(varsArg) {
1709
- const node = findPropertyNode(varsArg, "keyframes");
1710
- return node?.type === "ObjectExpression" ? node : null;
1711
- }
1712
- /**
1713
- * Convert array-form keyframes (`keyframes: [{x,y}, …]`) to even-percentage object
1714
- * form (`{ "0%": {…}, "33.3%": {…}, … }`) IN PLACE, returning the new object node
1715
- * (or null if not array-form). GSAP distributes an array evenly, so this is
1716
- * runtime-identical — but it gives the percentage-keyed write ops something to
1717
- * target. Needed before INSERTING a keyframe at an arbitrary percentage, which an
1718
- * even array can't host.
1719
- */
1720
- function convertArrayKeyframesToObjectNode(varsArg) {
1721
- if (varsArg?.type !== "ObjectExpression")
1722
- return null;
1723
- const prop = (varsArg.properties ?? []).find((p) => isObjectProperty(p) && propKeyName(p) === "keyframes");
1724
- if (!prop || prop.value?.type !== "ArrayExpression")
1725
- return null;
1726
- const els = (prop.value.elements ?? []).filter((e) => !!e && e.type === "ObjectExpression");
1727
- const n = els.length;
1728
- if (n === 0)
1729
- return null;
1730
- const entries = els.map((el, i) => {
1731
- const pct = n > 1 ? Math.round((i / (n - 1)) * 1000) / 10 : 0;
1732
- return `${JSON.stringify(`${pct}%`)}: ${recast.print(el).code}`;
1733
- });
1734
- prop.value = parseExpr(`{ ${entries.join(", ")} }`);
1735
- return prop.value;
1736
- }
1737
- /** Filter percentage-keyed properties from a keyframes ObjectExpression. */
1738
- function filterPercentageProps(kfNode) {
1739
- return kfNode.properties.filter((p) => {
1740
- if (!isObjectProperty(p))
1741
- return false;
1742
- const key = propKeyName(p);
1743
- return typeof key === "string" && PERCENTAGE_KEY_RE.test(key);
1744
- });
1745
- }
1746
- /**
1747
- * Collapse a keyframes node to flat tween: apply `record` entries as vars keys,
1748
- * then remove `keyframes` and `easeEach` from varsArg. Skips the `ease` key
1749
- * from the record (per-keyframe ease, not a tween ease).
1750
- */
1751
- function collapseKeyframesToFlat(varsArg, record) {
1752
- for (const [k, v] of Object.entries(record)) {
1753
- if (k === "ease")
1754
- continue;
1755
- if (typeof v === "number" || typeof v === "string")
1756
- setVarsKey(varsArg, k, v);
1757
- }
1758
- removeVarsKey(varsArg, "keyframes");
1759
- removeVarsKey(varsArg, "easeEach");
1760
- }
1761
- /**
1762
- * Locate an animation's keyframes ObjectExpression and build the percentage key.
1763
- * Shared preamble for addKeyframeToScript, removeKeyframeFromScript, and
1764
- * updateKeyframeInScript.
1765
- */
1766
- function locateKeyframeCtx(script, animationId, percentage) {
1767
- const loc = locateAnimationWithFallback(script, animationId);
1768
- if (!loc)
1769
- return null;
1770
- const kfNode = findKeyframesObjectNode(loc.target.call.varsArg);
1771
- if (!kfNode)
1772
- return null;
1773
- return { loc, kfNode, pctKey: `${percentage}%` };
1774
- }
1775
- /**
1776
- * Insert a keyframe at the given percentage in an existing percentage-keyframes
1777
- * object. If the percentage already exists, its value is replaced.
1778
- */
1779
- export function addKeyframeToScript(script, animationId, percentage, properties, ease, backfillDefaults) {
1780
- let loc = locateAnimationWithFallback(script, animationId);
1781
- if (!loc)
1782
- return script;
1783
- let kfNode = findKeyframesObjectNode(loc.target.call.varsArg);
1784
- // Array-form keyframes can't host an arbitrary new percentage — normalize to
1785
- // object form in place first. (convertToKeyframesInScript below only converts
1786
- // FLAT tweens; it early-returns when keyframes already exist.)
1787
- if (!kfNode)
1788
- kfNode = convertArrayKeyframesToObjectNode(loc.target.call.varsArg);
1789
- if (!kfNode) {
1790
- script = convertToKeyframesInScript(script, animationId);
1791
- loc = locateAnimationWithFallback(script, animationId);
1792
- if (!loc)
1793
- return script;
1794
- kfNode = findKeyframesObjectNode(loc.target.call.varsArg);
1795
- if (!kfNode)
1796
- return script;
1797
- }
1798
- const pctKey = `${percentage}%`;
1799
- const newValueNode = buildKeyframeValueNode(properties, ease);
1800
- // Merge into existing keyframe at this percentage, or insert new
1801
- const existing = findKeyframePropByPct(kfNode, percentage);
1802
- if (existing) {
1803
- if (existing.prop.value?.type === "ObjectExpression") {
1804
- const existingRecord = objectExpressionToRecord(existing.prop.value, loc.parsed.scope);
1805
- const merged = { ...existingRecord };
1806
- for (const [k, v] of Object.entries(properties))
1807
- merged[k] = v;
1808
- existing.prop.value = buildKeyframeValueNode(merged, ease ?? (typeof existingRecord.ease === "string" ? existingRecord.ease : undefined));
1809
- }
1810
- else {
1811
- existing.prop.value = newValueNode;
1812
- }
1813
- }
1814
- else {
1815
- // Build the new property node with a quoted percentage key
1816
- const newProp = parseExpr(`{ ${JSON.stringify(pctKey)}: {} }`).properties[0];
1817
- newProp.value = newValueNode;
1818
- // Insert in sorted order by percentage
1819
- let insertIdx = kfNode.properties.length;
1820
- for (let i = 0; i < kfNode.properties.length; i++) {
1821
- const key = isObjectProperty(kfNode.properties[i])
1822
- ? propKeyName(kfNode.properties[i])
1823
- : undefined;
1824
- if (typeof key === "string" && percentageFromKey(key) > percentage) {
1825
- insertIdx = i;
1826
- break;
1827
- }
1828
- }
1829
- kfNode.properties.splice(insertIdx, 0, newProp);
1830
- }
1831
- // Auto-update adjacent endpoints: only update an `_auto` 0% or 100%
1832
- // keyframe when the new keyframe is directly next to it (no other keyframe
1833
- // between them). This prevents a keyframe at 74% from clobbering 100% when
1834
- // 75% already exists, and a keyframe at 30% from clobbering 0% when 25%
1835
- // already exists.
1836
- if (percentage > 0 && percentage < 100) {
1837
- const pctProps = filterPercentageProps(kfNode);
1838
- const allPcts = pctProps
1839
- .map((p) => percentageFromKey(propKeyName(p) ?? ""))
1840
- .filter((n) => !Number.isNaN(n) && n !== percentage)
1841
- .sort((a, b) => a - b);
1842
- const leftNeighbor = allPcts.filter((p) => p < percentage).pop();
1843
- const rightNeighbor = allPcts.find((p) => p > percentage);
1844
- for (const endPct of [0, 100]) {
1845
- const isNeighbor = endPct === 0 ? leftNeighbor === 0 : rightNeighbor === 100;
1846
- if (!isNeighbor)
1847
- continue;
1848
- const endProp = pctProps.find((p) => percentageFromKey(propKeyName(p) ?? "") === endPct);
1849
- if (!endProp?.value || endProp.value.type !== "ObjectExpression")
1850
- continue;
1851
- const hasAuto = endProp.value.properties.some((p) => isObjectProperty(p) && propKeyName(p) === "_auto");
1852
- if (!hasAuto)
1853
- continue;
1854
- const updatedProps = { ...properties, _auto: 1 };
1855
- endProp.value = buildKeyframeValueNode(updatedProps, undefined);
1856
- }
1857
- }
1858
- // Backfill: when the new keyframe introduces properties absent from other
1859
- // keyframes, add default values so GSAP can interpolate them.
1860
- if (backfillDefaults) {
1861
- const newPropKeys = Object.keys(properties);
1862
- const pctProps = filterPercentageProps(kfNode);
1863
- for (const prop of pctProps) {
1864
- const key = propKeyName(prop);
1865
- if (key === pctKey)
1866
- continue;
1867
- const valObj = prop.value;
1868
- if (!valObj || valObj.type !== "ObjectExpression")
1869
- continue;
1870
- const existingKeys = new Set(valObj.properties
1871
- .filter((p) => isObjectProperty(p))
1872
- .map((p) => propKeyName(p)));
1873
- for (const pk of newPropKeys) {
1874
- if (existingKeys.has(pk))
1875
- continue;
1876
- const defaultVal = backfillDefaults[pk];
1877
- if (defaultVal == null)
1878
- continue;
1879
- const fillProp = parseExpr(`{ ${safeKey(pk)}: ${valueToCode(defaultVal)} }`).properties[0];
1880
- valObj.properties.push(fillProp);
1881
- }
1882
- }
1883
- }
1884
- return recast.print(loc.parsed.ast).code;
1885
- }
1886
- /**
1887
- * Remove a keyframe at the given percentage. If fewer than 2 keyframes remain
1888
- * after removal, collapse the keyframes object to a flat tween using the
1889
- * remaining keyframe's properties.
1890
- */
1891
- export function removeKeyframeFromScript(script, animationId, percentage) {
1892
- // Array-form keyframes (`keyframes: [{x,y}, …]`) have no explicit percentages —
1893
- // GSAP distributes them evenly. The object-form path below can't see them
1894
- // (findKeyframesObjectNode only matches ObjectExpression), so removing from an
1895
- // array-form tween silently no-op'd. Resolve the element by its implicit
1896
- // percentage and splice it; collapse to a flat tween when fewer than two remain.
1897
- const arrLoc = locateAnimationWithFallback(script, animationId);
1898
- // findPropertyNode here returns the property's VALUE node directly.
1899
- const arrVal = arrLoc && findPropertyNode(arrLoc.target.call.varsArg, "keyframes");
1900
- if (arrLoc && arrVal?.type === "ArrayExpression") {
1901
- const elements = (arrVal.elements ?? []).filter((e) => !!e && e.type === "ObjectExpression");
1902
- const n = elements.length;
1903
- if (n === 0)
1904
- return script;
1905
- let matchIdx = -1;
1906
- let bestDist = Number.POSITIVE_INFINITY;
1907
- for (let i = 0; i < n; i++) {
1908
- const pct = n > 1 ? (i / (n - 1)) * 100 : 0;
1909
- const dist = Math.abs(pct - percentage);
1910
- if (dist <= PCT_TOLERANCE && dist < bestDist) {
1911
- matchIdx = i;
1912
- bestDist = dist;
1913
- }
1914
- }
1915
- if (matchIdx === -1)
1916
- return script;
1917
- const remaining = elements.filter((_, i) => i !== matchIdx);
1918
- if (remaining.length < 2) {
1919
- const sole = remaining[0];
1920
- const record = sole ? objectExpressionToRecord(sole, arrLoc.parsed.scope) : {};
1921
- collapseKeyframesToFlat(arrLoc.target.call.varsArg, record);
1922
- }
1923
- else {
1924
- const realIdx = arrVal.elements.indexOf(elements[matchIdx]);
1925
- arrVal.elements.splice(realIdx, 1);
1926
- }
1927
- return recast.print(arrLoc.parsed.ast).code;
1928
- }
1929
- const ctx = locateKeyframeCtx(script, animationId, percentage);
1930
- if (!ctx)
1931
- return script;
1932
- const { loc, kfNode } = ctx;
1933
- const match = findKeyframePropByPct(kfNode, percentage);
1934
- if (!match)
1935
- return script;
1936
- const removeIdx = match.idx;
1937
- kfNode.properties.splice(removeIdx, 1);
1938
- const remainingKfs = filterPercentageProps(kfNode);
1939
- if (remainingKfs.length < 2) {
1940
- const record = remainingKfs.length === 1
1941
- ? objectExpressionToRecord(remainingKfs[0].value, loc.parsed.scope)
1942
- : {};
1943
- collapseKeyframesToFlat(loc.target.call.varsArg, record);
1944
- }
1945
- return recast.print(loc.parsed.ast).code;
1946
- }
1947
- /**
1948
- * Replace the properties (and optionally ease) at an existing keyframe percentage.
1949
- */
1950
- export function updateKeyframeInScript(script, animationId, percentage, properties, ease) {
1951
- // Array-form keyframes (`keyframes: [{x,y}, …]`) have no explicit percentages —
1952
- // GSAP distributes them evenly. The percentage-keyed object path below can't
1953
- // match them (findKeyframesObjectNode only matches ObjectExpression), so dragging
1954
- // a motion-path node on an array-authored tween silently no-op'd. Resolve the
1955
- // element by its implicit percentage and replace it in place. Mirrors the array
1956
- // branch in removeKeyframeFromScript.
1957
- const arrLoc = locateAnimationWithFallback(script, animationId);
1958
- const arrVal = arrLoc && findPropertyNode(arrLoc.target.call.varsArg, "keyframes");
1959
- if (arrLoc && arrVal?.type === "ArrayExpression") {
1960
- const elements = (arrVal.elements ?? []).filter((e) => !!e && e.type === "ObjectExpression");
1961
- const n = elements.length;
1962
- if (n === 0)
1963
- return script;
1964
- let matchIdx = -1;
1965
- let bestDist = Number.POSITIVE_INFINITY;
1966
- for (let i = 0; i < n; i++) {
1967
- const pct = n > 1 ? (i / (n - 1)) * 100 : 0;
1968
- const dist = Math.abs(pct - percentage);
1969
- if (dist <= PCT_TOLERANCE && dist < bestDist) {
1970
- matchIdx = i;
1971
- bestDist = dist;
1972
- }
1973
- }
1974
- if (matchIdx === -1)
1975
- return script;
1976
- const realIdx = arrVal.elements.indexOf(elements[matchIdx]);
1977
- arrVal.elements[realIdx] = buildKeyframeValueNode(properties, ease);
1978
- return recast.print(arrLoc.parsed.ast).code;
1979
- }
1980
- const ctx = locateKeyframeCtx(script, animationId, percentage);
1981
- if (!ctx)
1982
- return script;
1983
- const { loc, kfNode } = ctx;
1984
- const match = findKeyframePropByPct(kfNode, percentage);
1985
- if (!match)
1986
- return script;
1987
- if (Object.keys(properties).length === 0 && ease) {
1988
- // Ease-only update: preserve existing properties, just add/replace ease
1989
- const existing = match.prop.value;
1990
- if (existing?.type === "ObjectExpression") {
1991
- const props = (existing.properties ?? []);
1992
- const easeIdx = props.findIndex((p) => isObjectProperty(p) && propKeyName(p) === "ease");
1993
- const easeNode = parseExpr(`({ ease: ${JSON.stringify(ease)} })`).properties[0];
1994
- if (easeIdx >= 0) {
1995
- props[easeIdx] = easeNode;
1996
- }
1997
- else {
1998
- props.push(easeNode);
1999
- }
2000
- return recast.print(loc.parsed.ast).code;
2001
- }
2002
- // Non-object keyframe value (primitive shorthand, e.g. "50%": "0.5"): there
2003
- // is no property bag to merge the ease into. Rebuilding from empty
2004
- // `properties` would wipe the primitive — leave the keyframe untouched.
2005
- return script;
2006
- }
2007
- match.prop.value = buildKeyframeValueNode(properties, ease);
2008
- return recast.print(loc.parsed.ast).code;
2009
- }
2010
- /** Strip editable properties and ease/keyframes keys from a varsArg. */
2011
- function stripEditableAndEase(varsArg) {
2012
- // ease is a BUILTIN_VAR_KEY (not editable), so filterEditableKeys won't remove it —
2013
- // drop it explicitly before filtering, along with keyframes.
2014
- if (varsArg?.type !== "ObjectExpression")
2015
- return;
2016
- varsArg.properties = varsArg.properties.filter((p) => {
2017
- if (!isObjectProperty(p))
2018
- return true;
2019
- const key = propKeyName(p);
2020
- return key !== "ease" && key !== "keyframes";
2021
- });
2022
- filterEditableKeys(varsArg, () => false);
2023
- }
2024
- /** Build and prepend a keyframes property node onto varsArg. */
2025
- function insertKeyframesProp(varsArg, fromProps, toProps, easeEach) {
2026
- const fromEntries = Object.entries(fromProps).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`);
2027
- const toEntries = Object.entries(toProps).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`);
2028
- const easeEntry = easeEach ? `, easeEach: ${JSON.stringify(easeEach)}` : "";
2029
- const kfCode = `{ "0%": { ${fromEntries.join(", ")} }, "100%": { ${toEntries.join(", ")} }${easeEntry} }`;
2030
- const kfProp = parseExpr(`{ keyframes: {} }`).properties[0];
2031
- kfProp.value = parseExpr(kfCode);
2032
- if (varsArg?.type === "ObjectExpression")
2033
- varsArg.properties.unshift(kfProp);
2034
- }
2035
- /**
2036
- * Convert a flat tween (to/from/fromTo) to percentage-keyframes format.
2037
- * `resolvedFromValues` supplies the "from" state for `to()` tweens or
2038
- * the "to" state for `from()` tweens (the values the DOM would resolve to).
2039
- */
2040
- export function convertToKeyframesInScript(script, animationId, resolvedFromValues, setDuration = 1) {
2041
- let loc = locateAnimationWithFallback(script, animationId);
2042
- if (!loc)
2043
- return script;
2044
- const anim = loc.target.animation;
2045
- if (anim.keyframes)
2046
- return script;
2047
- const { fromProps, toProps } = resolveConversionProps(anim, resolvedFromValues);
2048
- const varsArg = loc.target.call.varsArg;
2049
- const originalEase = anim.ease;
2050
- stripEditableAndEase(varsArg);
2051
- insertKeyframesProp(varsArg, fromProps, toProps, originalEase || undefined);
2052
- if (originalEase) {
2053
- setVarsKey(varsArg, "ease", "none");
2054
- }
2055
- // For from() or fromTo(), convert to to()
2056
- if (anim.method === "from" || anim.method === "fromTo") {
2057
- loc.target.call.node.callee.property.name = "to";
2058
- if (anim.method === "fromTo")
2059
- loc.target.call.node.arguments.splice(1, 1);
2060
- }
2061
- // A static `set` becomes an animatable `to`: flip the method, drop the
2062
- // immediateRender hold marker, and give it a real duration so the keyframes
2063
- // span time. This is what makes a static 3D transform keyframeable.
2064
- if (anim.method === "set") {
2065
- // A GLOBAL `gsap.set(...)` is off-timeline; flipping only the method would
2066
- // emit `gsap.to(...)`, which fires once at load and is NOT on the paused
2067
- // master timeline (the engine can't seek/render it). Re-root it onto the
2068
- // timeline var and add the position arg (a gsap.set has none) so the
2069
- // converted tween is seekable. A `tl.set` already has the right object.
2070
- const calleeObj = loc.target.call.node.callee.object;
2071
- if (anim.global && calleeObj?.type === "Identifier") {
2072
- calleeObj.name = loc.parsed.timelineVar;
2073
- if (loc.target.call.node.arguments.length < 3) {
2074
- loc.target.call.node.arguments.push(parseExpr("0"));
2075
- }
2076
- }
2077
- loc.target.call.node.callee.property.name = "to";
2078
- removeVarsKey(varsArg, "immediateRender");
2079
- setVarsKey(varsArg, "duration", Math.max(0.001, setDuration));
2080
- }
2081
- return recast.print(loc.parsed.ast).code;
2082
- }
2083
- /**
2084
- * Remove all keyframes from a tween, collapsing to a flat tween with the
2085
- * last keyframe's properties.
2086
- */
2087
- export function removeAllKeyframesFromScript(script, animationId) {
2088
- let loc = locateAnimationWithFallback(script, animationId);
2089
- if (!loc)
2090
- return script;
2091
- const kfNode = findKeyframesObjectNode(loc.target.call.varsArg);
2092
- if (!kfNode)
2093
- return script;
2094
- const kfEntries = filterPercentageProps(kfNode)
2095
- .map((p) => ({ pct: percentageFromKey(propKeyName(p)), prop: p }))
2096
- .filter((e) => !Number.isNaN(e.pct))
2097
- .sort((a, b) => a.pct - b.pct);
2098
- if (kfEntries.length === 0)
2099
- return script;
2100
- // For to()/set(): collapse to last keyframe (the destination = visible state).
2101
- // For from(): collapse to first keyframe (the starting state).
2102
- const method = loc.target.call.method;
2103
- const collapseEntry = method === "from" ? kfEntries[0] : kfEntries[kfEntries.length - 1];
2104
- const record = objectExpressionToRecord(collapseEntry.prop.value, loc.parsed.scope);
2105
- collapseKeyframesToFlat(loc.target.call.varsArg, record);
2106
- return recast.print(loc.parsed.ast).code;
2107
- }
2108
- /**
2109
- * Replace a dynamic `keyframes: <expr>` with a static percentage-keyframes object.
2110
- * Called when the user first edits a dynamically-generated keyframe in the studio.
2111
- */
2112
- export function materializeKeyframesInScript(script, animationId, keyframes, easeEach, resolvedSelector) {
2113
- let loc = locateAnimationWithFallback(script, animationId);
2114
- if (!loc)
2115
- return script;
2116
- const varsArg = loc.target.call.varsArg;
2117
- // Replace dynamic selector with resolved static string
2118
- if (resolvedSelector && loc.target.call.node.arguments[0]) {
2119
- loc.target.call.node.arguments[0] = parseExpr(JSON.stringify(resolvedSelector));
2120
- }
2121
- const kfObjCode = buildKeyframeObjectCode(sortedKeyframes(keyframes), { easeEach });
2122
- const kfParent = varsArg.properties.find((p) => isObjectProperty(p) && propKeyName(p) === "keyframes");
2123
- if (kfParent) {
2124
- kfParent.value = parseExpr(kfObjCode);
2125
- }
2126
- else {
2127
- const kfProp = parseExpr(`{ keyframes: ${kfObjCode} }`).properties[0];
2128
- varsArg.properties.unshift(kfProp);
2129
- }
2130
- removeVarsKey(varsArg, "easeEach");
2131
- return recast.print(loc.parsed.ast).code;
2132
- }
2133
- // ── Arc Path (motionPath) AST Mutations ──────────────────────────────────
2134
- function numericXY(props) {
2135
- const x = props.x;
2136
- const y = props.y;
2137
- return typeof x === "number" && typeof y === "number" ? { x, y } : null;
2138
- }
2139
- function extractArcWaypoints(anim) {
2140
- const kfs = anim.keyframes?.keyframes ?? [];
2141
- const waypoints = kfs.map((kf) => numericXY(kf.properties)).filter((p) => p !== null);
2142
- if (waypoints.length >= 2)
2143
- return waypoints;
2144
- const px = anim.properties.x;
2145
- const py = anim.properties.y;
2146
- if (typeof px !== "number" && typeof py !== "number")
2147
- return waypoints;
2148
- return [
2149
- { x: 0, y: 0 },
2150
- { x: typeof px === "number" ? px : 0, y: typeof py === "number" ? py : 0 },
2151
- ];
2152
- }
2153
- function buildMotionPathObjectCode(config) {
2154
- const { waypoints, segments, autoRotate } = config;
2155
- const hasExplicitControlPoints = segments.some((s) => s.cp1 && s.cp2);
2156
- // The simple `path` array supports only one scalar curviness for the whole
2157
- // path, so per-segment curviness must use the cubic form (curviness baked into
2158
- // each segment's control points). Without this, the simple branch serializes
2159
- // only segments[0].curviness and silently drops every other segment's curve.
2160
- const curvinessVaries = segments.some((s) => (s.curviness ?? 1) !== (segments[0]?.curviness ?? 1));
2161
- let pathEntries;
2162
- if ((hasExplicitControlPoints || curvinessVaries) && waypoints.length >= 2) {
2163
- // type: "cubic" — interleave control points: [anchor, cp1, cp2, anchor, ...]
2164
- pathEntries = [`{x: ${waypoints[0].x}, y: ${waypoints[0].y}}`];
2165
- for (let i = 0; i < segments.length; i++) {
2166
- const seg = segments[i];
2167
- const nextWp = waypoints[i + 1];
2168
- if (seg.cp1 && seg.cp2) {
2169
- pathEntries.push(`{x: ${seg.cp1.x}, y: ${seg.cp1.y}}`);
2170
- pathEntries.push(`{x: ${seg.cp2.x}, y: ${seg.cp2.y}}`);
2171
- }
2172
- else {
2173
- // Auto-generate simple midpoint control points from curviness
2174
- const wp = waypoints[i];
2175
- const dx = nextWp.x - wp.x;
2176
- const dy = nextWp.y - wp.y;
2177
- const c = seg.curviness ?? 1;
2178
- pathEntries.push(`{x: ${wp.x + dx * 0.33}, y: ${wp.y + dy * 0.33 - c * Math.abs(dx) * 0.25}}`);
2179
- pathEntries.push(`{x: ${wp.x + dx * 0.66}, y: ${wp.y + dy * 0.66 - c * Math.abs(dx) * 0.25}}`);
2180
- }
2181
- pathEntries.push(`{x: ${nextWp.x}, y: ${nextWp.y}}`);
2182
- }
2183
- const pathStr = pathEntries.join(", ");
2184
- const parts = [`path: [${pathStr}]`, `type: "cubic"`];
2185
- if (autoRotate === true)
2186
- parts.push("autoRotate: true");
2187
- else if (typeof autoRotate === "number")
2188
- parts.push(`autoRotate: ${autoRotate}`);
2189
- return `{ ${parts.join(", ")} }`;
2190
- }
2191
- // Simple waypoint array with curviness
2192
- pathEntries = waypoints.map((wp) => `{x: ${wp.x}, y: ${wp.y}}`);
2193
- const curviness = segments[0]?.curviness ?? 1;
2194
- const parts = [`path: [${pathEntries.join(", ")}]`];
2195
- if (curviness !== 1)
2196
- parts.push(`curviness: ${curviness}`);
2197
- if (autoRotate === true)
2198
- parts.push("autoRotate: true");
2199
- else if (typeof autoRotate === "number")
2200
- parts.push(`autoRotate: ${autoRotate}`);
2201
- return `{ ${parts.join(", ")} }`;
2202
- }
2203
- export function setArcPathInScript(script, animationId, config) {
2204
- const loc = locateAnimation(script, animationId);
2205
- if (!loc)
2206
- return script;
2207
- const varsArg = loc.target.call.varsArg;
2208
- const anim = loc.target.animation;
2209
- if (!config.enabled) {
2210
- // Disable arc: restore x/y from motionPath's last waypoint, then remove motionPath
2211
- const motionPathProp = varsArg.properties.find((p) => isObjectProperty(p) && propKeyName(p) === "motionPath");
2212
- if (motionPathProp) {
2213
- const mpVal = motionPathProp.value;
2214
- let pathArr;
2215
- if (mpVal?.type === "ObjectExpression") {
2216
- const pathProp = mpVal.properties.find((p) => isObjectProperty(p) && propKeyName(p) === "path");
2217
- if (pathProp?.value?.type === "ArrayExpression")
2218
- pathArr = pathProp.value.elements;
2219
- }
2220
- if (pathArr && pathArr.length > 0) {
2221
- const last = pathArr[pathArr.length - 1];
2222
- if (last?.type === "ObjectExpression") {
2223
- for (const p of last.properties) {
2224
- const k = propKeyName(p);
2225
- if (k === "x" || k === "y") {
2226
- const v = p.value?.value;
2227
- if (typeof v === "number")
2228
- setVarsKey(varsArg, k, v);
2229
- }
2230
- }
2231
- }
2232
- }
2233
- }
2234
- removeVarsKey(varsArg, "motionPath");
2235
- return recast.print(loc.parsed.ast).code;
2236
- }
2237
- const waypoints = extractArcWaypoints(anim);
2238
- if (waypoints.length < 2)
2239
- return script;
2240
- // Build segments — use provided segments or create defaults
2241
- const segments = config.segments.length === waypoints.length - 1
2242
- ? config.segments
2243
- : Array.from({ length: waypoints.length - 1 }, () => ({ curviness: 1 }));
2244
- const motionPathCode = buildMotionPathObjectCode({
2245
- waypoints,
2246
- segments,
2247
- autoRotate: config.autoRotate,
2248
- });
2249
- // Set motionPath on the vars
2250
- const motionPathNode = parseExpr(motionPathCode);
2251
- const existingProp = varsArg.properties.find((p) => isObjectProperty(p) && propKeyName(p) === "motionPath");
2252
- if (existingProp) {
2253
- existingProp.value = motionPathNode;
2254
- }
2255
- else {
2256
- const prop = parseExpr(`{ motionPath: ${motionPathCode} }`).properties[0];
2257
- varsArg.properties.push(prop);
2258
- }
2259
- // Strip x/y from keyframes (they're now in motionPath)
2260
- const kfNode = findKeyframesObjectNode(varsArg);
2261
- if (kfNode) {
2262
- for (const pctProp of filterPercentageProps(kfNode)) {
2263
- if (pctProp.value?.type === "ObjectExpression") {
2264
- pctProp.value.properties = pctProp.value.properties.filter((p) => {
2265
- const k = propKeyName(p);
2266
- return k !== "x" && k !== "y";
2267
- });
2268
- }
2269
- }
2270
- }
2271
- // Strip flat x/y from vars (they're now in motionPath)
2272
- removeVarsKey(varsArg, "x");
2273
- removeVarsKey(varsArg, "y");
2274
- return recast.print(loc.parsed.ast).code;
2275
- }
2276
- export function updateArcSegmentInScript(script, animationId, segmentIndex, update) {
2277
- const loc = locateAnimation(script, animationId);
2278
- if (!loc)
2279
- return script;
2280
- const anim = loc.target.animation;
2281
- if (!anim.arcPath?.enabled)
2282
- return script;
2283
- const segments = [...anim.arcPath.segments];
2284
- if (segmentIndex < 0 || segmentIndex >= segments.length)
2285
- return script;
2286
- segments[segmentIndex] = { ...segments[segmentIndex], ...update };
2287
- const waypoints = extractArcWaypoints(anim);
2288
- if (waypoints.length < 2)
2289
- return script;
2290
- const motionPathCode = buildMotionPathObjectCode({
2291
- waypoints,
2292
- segments,
2293
- autoRotate: anim.arcPath.autoRotate,
2294
- });
2295
- const varsArg = loc.target.call.varsArg;
2296
- const existingProp = varsArg.properties.find((p) => isObjectProperty(p) && propKeyName(p) === "motionPath");
2297
- if (existingProp) {
2298
- existingProp.value = parseExpr(motionPathCode);
2299
- }
2300
- return recast.print(loc.parsed.ast).code;
2301
- }
2302
- /**
2303
- * Move a single motionPath waypoint (anchor) to a new position. The waypoint
2304
- * list is normalized to anchors for both straight and cubic paths, so
2305
- * `pointIndex` matches the node order the studio overlay renders; cubic control
2306
- * points are preserved. No-op when the animation/arc is missing or the index is
2307
- * out of range.
2308
- */
2309
- export function updateMotionPathPointInScript(script, animationId, pointIndex, point) {
2310
- const loc = locateAnimation(script, animationId);
2311
- if (!loc)
2312
- return script;
2313
- const anim = loc.target.animation;
2314
- if (!anim.arcPath?.enabled)
2315
- return script;
2316
- const waypoints = extractArcWaypoints(anim);
2317
- if (pointIndex < 0 || pointIndex >= waypoints.length || waypoints.length < 2)
2318
- return script;
2319
- const nextWaypoints = waypoints.map((wp, i) => i === pointIndex ? { x: point.x, y: point.y } : wp);
2320
- const motionPathCode = buildMotionPathObjectCode({
2321
- waypoints: nextWaypoints,
2322
- segments: anim.arcPath.segments,
2323
- autoRotate: anim.arcPath.autoRotate,
2324
- });
2325
- const varsArg = loc.target.call.varsArg;
2326
- const existingProp = varsArg.properties.find((p) => isObjectProperty(p) && propKeyName(p) === "motionPath");
2327
- if (existingProp) {
2328
- existingProp.value = parseExpr(motionPathCode);
2329
- }
2330
- return recast.print(loc.parsed.ast).code;
2331
- }
2332
- /** True when any segment carries explicit cubic control points. Add/remove are
2333
- * restricted to curviness (non-cubic) paths — synthesizing control points for
2334
- * an inserted cubic anchor is out of scope. */
2335
- function hasCubicSegments(segments) {
2336
- return segments.some((s) => s.cp1 != null || s.cp2 != null);
2337
- }
2338
- function writeMotionPathValue(loc, waypoints, segments, autoRotate) {
2339
- const motionPathCode = buildMotionPathObjectCode({ waypoints, segments, autoRotate });
2340
- const varsArg = loc.target.call.varsArg;
2341
- const existingProp = varsArg.properties.find((p) => isObjectProperty(p) && propKeyName(p) === "motionPath");
2342
- if (existingProp)
2343
- existingProp.value = parseExpr(motionPathCode);
2344
- return recast.print(loc.parsed.ast).code;
2345
- }
2346
- /**
2347
- * Insert a waypoint at `index` (between existing anchors), splitting the segment
2348
- * it lands on so the new neighbor inherits its curviness. Non-cubic paths only.
2349
- * No-op for missing animation/arc, out-of-range index, or cubic paths.
2350
- */
2351
- export function addMotionPathPointInScript(script, animationId, index, point) {
2352
- const loc = locateAnimation(script, animationId);
2353
- if (!loc)
2354
- return script;
2355
- const anim = loc.target.animation;
2356
- if (!anim.arcPath?.enabled || hasCubicSegments(anim.arcPath.segments))
2357
- return script;
2358
- const waypoints = extractArcWaypoints(anim);
2359
- // Insert strictly between two anchors: index 1..length-1.
2360
- if (index < 1 || index > waypoints.length - 1)
2361
- return script;
2362
- const segments = [...anim.arcPath.segments];
2363
- waypoints.splice(index, 0, { x: point.x, y: point.y });
2364
- const splitCurviness = segments[index - 1]?.curviness ?? 1;
2365
- segments.splice(index - 1, 0, { curviness: splitCurviness });
2366
- return writeMotionPathValue(loc, waypoints, segments, anim.arcPath.autoRotate);
2367
- }
2368
- /**
2369
- * Remove the waypoint at `index`. Refuses to drop below two anchors (a path
2370
- * can't have fewer). Non-cubic paths only. No-op for missing animation/arc,
2371
- * out-of-range index, cubic paths, or a 2-point path.
2372
- */
2373
- export function removeMotionPathPointInScript(script, animationId, index) {
2374
- const loc = locateAnimation(script, animationId);
2375
- if (!loc)
2376
- return script;
2377
- const anim = loc.target.animation;
2378
- if (!anim.arcPath?.enabled || hasCubicSegments(anim.arcPath.segments))
2379
- return script;
2380
- const waypoints = extractArcWaypoints(anim);
2381
- if (waypoints.length <= 2 || index < 0 || index >= waypoints.length)
2382
- return script;
2383
- const segments = [...anim.arcPath.segments];
2384
- waypoints.splice(index, 1);
2385
- // Drop the segment on the side that still exists (last anchor → preceding segment).
2386
- segments.splice(Math.min(index, segments.length - 1), 1);
2387
- return writeMotionPathValue(loc, waypoints, segments, anim.arcPath.autoRotate);
2388
- }
2389
- /**
2390
- * Author a fresh 2-anchor motionPath tween on a target element: a straight line
2391
- * from the element's home (0,0) to `point`, gentle ease, ready for waypoint
2392
- * editing. Mirrors `addAnimationWithKeyframesToScript`.
2393
- */
2394
- export function addMotionPathToScript(script, targetSelector, position, duration, point, ease = "power1.inOut") {
2395
- // `id: null` on the failure paths is a deliberate sentinel: callers must
2396
- // null-check before chaining (e.g. locating the new tween). An empty string
2397
- // would silently flow into selector/locate calls and match nothing.
2398
- let parsed;
2399
- try {
2400
- parsed = parseGsapAst(script);
2401
- }
2402
- catch (e) {
2403
- console.warn("[gsap-parser] addMotionPathToScript parse failed:", e);
2404
- return { script, id: null };
2405
- }
2406
- if (parsed.located.length === 0 && parsed.detection.timelineVar === null) {
2407
- return { script, id: null };
2408
- }
2409
- const motionPathCode = buildMotionPathObjectCode({
2410
- waypoints: [
2411
- { x: 0, y: 0 },
2412
- { x: point.x, y: point.y },
2413
- ],
2414
- segments: [{ curviness: 1 }],
2415
- autoRotate: false,
2416
- });
2417
- const selector = JSON.stringify(targetSelector);
2418
- const varEntries = [
2419
- `motionPath: ${motionPathCode}`,
2420
- `duration: ${valueToCode(duration)}`,
2421
- `ease: ${JSON.stringify(ease)}`,
2422
- ];
2423
- const stmtCode = `${parsed.timelineVar}.to(${selector}, { ${varEntries.join(", ")} }, ${valueToCode(position)});`;
2424
- const newStatement = parseScript(stmtCode).program.body[0];
2425
- insertAfterAnchor(parsed, newStatement);
2426
- const result = recast.print(parsed.ast).code;
2427
- const reParsed = parseGsapAst(result);
2428
- const newId = reParsed.located[reParsed.located.length - 1]?.id ?? null;
2429
- return { script: result, id: newId };
2430
- }
2431
- export function removeArcPathFromScript(script, animationId) {
2432
- return setArcPathInScript(script, animationId, {
2433
- enabled: false,
2434
- autoRotate: false,
2435
- segments: [],
2436
- });
2437
- }
2438
- // ── Split Into Property Groups ────────────────────────────────────────────
2439
- /**
2440
- * Split a multi-group tween into separate per-group tweens. Each resulting
2441
- * tween contains only properties belonging to one property group (position,
2442
- * scale, rotation, visual, etc.). `transformOrigin` stays with the group that
2443
- * has the most properties. If the tween already belongs to a single group,
2444
- * returns the script unchanged with the original ID.
2445
- */
2446
- // fallow-ignore-next-line complexity
2447
- export function splitIntoPropertyGroups(script, animationId) {
2448
- let loc = locateAnimationWithFallback(script, animationId);
2449
- if (!loc)
2450
- return { script, ids: [animationId] };
2451
- const anim = loc.target.animation;
2452
- // Collect the properties to partition. For keyframed tweens, gather the
2453
- // union of all properties across all keyframes. For flat tweens, use the
2454
- // tween's own properties map.
2455
- const allPropKeys = new Set();
2456
- if (anim.keyframes) {
2457
- for (const kf of anim.keyframes.keyframes) {
2458
- for (const k of Object.keys(kf.properties))
2459
- allPropKeys.add(k);
2460
- }
2461
- }
2462
- else {
2463
- for (const k of Object.keys(anim.properties))
2464
- allPropKeys.add(k);
2465
- }
2466
- // Partition properties into groups (excluding transformOrigin — handled below).
2467
- const groupProps = new Map();
2468
- for (const key of allPropKeys) {
2469
- if (key === "transformOrigin")
2470
- continue;
2471
- const group = classifyPropertyGroup(key);
2472
- let arr = groupProps.get(group);
2473
- if (!arr) {
2474
- arr = [];
2475
- groupProps.set(group, arr);
2476
- }
2477
- arr.push(key);
2478
- }
2479
- // Only one group (or zero) — no split needed.
2480
- if (groupProps.size <= 1)
2481
- return { script, ids: [anim.id] };
2482
- // Assign transformOrigin to the group with the most properties.
2483
- if (allPropKeys.has("transformOrigin")) {
2484
- let largestGroup;
2485
- let largestCount = 0;
2486
- for (const [group, props] of groupProps) {
2487
- if (props.length > largestCount) {
2488
- largestCount = props.length;
2489
- largestGroup = group;
2490
- }
2491
- }
2492
- if (largestGroup) {
2493
- groupProps.get(largestGroup).push("transformOrigin");
2494
- }
2495
- }
2496
- // Build per-group tweens and insert them, then remove the original.
2497
- let result = script;
2498
- // Remove the original tween first.
2499
- result = removeAnimationFromScript(result, anim.id);
2500
- // Insert one tween per group. Iteration order of the Map follows insertion
2501
- // order, which mirrors the order properties were encountered.
2502
- for (const [, props] of groupProps) {
2503
- const propSet = new Set(props);
2504
- if (anim.keyframes) {
2505
- // Build keyframes containing only this group's properties per keyframe.
2506
- const groupKeyframes = [];
2507
- for (const kf of anim.keyframes.keyframes) {
2508
- const filtered = {};
2509
- for (const [k, v] of Object.entries(kf.properties)) {
2510
- if (propSet.has(k))
2511
- filtered[k] = v;
2512
- }
2513
- // Skip keyframes where this group has zero properties.
2514
- if (Object.keys(filtered).length === 0)
2515
- continue;
2516
- groupKeyframes.push({
2517
- percentage: kf.percentage,
2518
- properties: filtered,
2519
- ...(kf.ease ? { ease: kf.ease } : {}),
2520
- });
2521
- }
2522
- if (groupKeyframes.length === 0)
2523
- continue;
2524
- const addResult = addAnimationWithKeyframesToScript(result, anim.targetSelector, typeof anim.position === "number" ? anim.position : 0, anim.duration ?? 0.5, groupKeyframes, anim.keyframes.easeEach ?? anim.ease);
2525
- result = addResult.script;
2526
- }
2527
- else {
2528
- // Flat tween — filter properties to this group.
2529
- const groupProperties = {};
2530
- for (const [k, v] of Object.entries(anim.properties)) {
2531
- if (propSet.has(k))
2532
- groupProperties[k] = v;
2533
- }
2534
- if (Object.keys(groupProperties).length === 0)
2535
- continue;
2536
- let fromProperties;
2537
- if (anim.method === "fromTo" && anim.fromProperties) {
2538
- fromProperties = {};
2539
- for (const [k, v] of Object.entries(anim.fromProperties)) {
2540
- if (propSet.has(k))
2541
- fromProperties[k] = v;
2542
- }
2543
- }
2544
- const addResult = addAnimationToScript(result, {
2545
- targetSelector: anim.targetSelector,
2546
- method: anim.method,
2547
- position: anim.position,
2548
- duration: anim.duration,
2549
- ease: anim.ease,
2550
- properties: groupProperties,
2551
- fromProperties,
2552
- extras: anim.extras,
2553
- });
2554
- result = addResult.script;
2555
- }
2556
- }
2557
- // Re-parse to collect the new IDs.
2558
- const reParsed = parseGsapAst(result);
2559
- const newIds = reParsed.located
2560
- .filter((l) => l.animation.targetSelector === anim.targetSelector)
2561
- .map((l) => l.id);
2562
- return { script: result, ids: newIds };
2563
- }
2564
- /**
2565
- * Replace a dynamic loop that generates multiple tween calls with individual
2566
- * static `tl.to()` calls — one per element. Finds the loop containing the
2567
- * animation and replaces the entire loop body with unrolled static calls.
2568
- */
2569
- export function unrollDynamicAnimations(script, animationId, elements) {
2570
- const loc = locateAnimation(script, animationId);
2571
- if (!loc)
2572
- return script;
2573
- const varsArg = loc.target.call.varsArg;
2574
- // Read duration and ease from the original tween vars
2575
- const durationVal = extractLiteralValue(findPropertyNode(varsArg, "duration"), loc.parsed.scope);
2576
- const easeVal = extractLiteralValue(findPropertyNode(varsArg, "ease"), loc.parsed.scope);
2577
- const duration = typeof durationVal === "number" ? durationVal : 8;
2578
- const ease = typeof easeVal === "string" ? easeVal : "none";
2579
- const posArg = loc.target.call.positionArg;
2580
- const position = posArg ? extractLiteralValue(posArg, loc.parsed.scope) : 0;
2581
- const posCode = typeof position === "number"
2582
- ? String(position)
2583
- : typeof position === "string"
2584
- ? JSON.stringify(position)
2585
- : "0";
2586
- // Find the enclosing loop (for/forEach) by walking up the AST path
2587
- let loopNode = null;
2588
- let current = loc.target.call.path;
2589
- while (current) {
2590
- const node = current.node ?? current.value;
2591
- if (node?.type === "ForStatement" ||
2592
- node?.type === "ForInStatement" ||
2593
- node?.type === "ForOfStatement" ||
2594
- node?.type === "WhileStatement") {
2595
- loopNode = node;
2596
- break;
2597
- }
2598
- if (node?.type === "ExpressionStatement" &&
2599
- node.expression?.type === "CallExpression" &&
2600
- node.expression.callee?.property?.name === "forEach") {
2601
- loopNode = node;
2602
- break;
2603
- }
2604
- current = current.parent ?? current.parentPath;
2605
- }
2606
- // Build replacement code: individual tl.to() calls for each element
2607
- const calls = [];
2608
- for (const el of elements) {
2609
- const kfCode = buildKeyframeObjectCode(sortedKeyframes(el.keyframes), {
2610
- easeEach: el.easeEach,
2611
- });
2612
- calls.push(`${loc.parsed.timelineVar}.to(${JSON.stringify(el.selector)}, { keyframes: ${kfCode}, duration: ${duration}, ease: ${JSON.stringify(ease)} }, ${posCode});`);
2613
- }
2614
- const replacement = calls.join("\n ");
2615
- if (loopNode) {
2616
- // Replace the entire loop with the unrolled calls
2617
- const start = loopNode.start ?? loopNode.range?.[0];
2618
- const end = loopNode.end ?? loopNode.range?.[1];
2619
- if (typeof start === "number" && typeof end === "number") {
2620
- return script.slice(0, start) + replacement + script.slice(end);
2621
- }
2622
- }
2623
- // Fallback: replace just the tween call's enclosing expression statement
2624
- const stmtNode = loc.target.call.path?.parent?.node ?? loc.target.call.path?.parentPath?.node;
2625
- if (stmtNode?.type === "ExpressionStatement") {
2626
- const start = stmtNode.start ?? stmtNode.range?.[0];
2627
- const end = stmtNode.end ?? stmtNode.range?.[1];
2628
- if (typeof start === "number" && typeof end === "number") {
2629
- return script.slice(0, start) + replacement + script.slice(end);
2630
- }
2631
- }
2632
- return script;
2633
- }
2634
- //# sourceMappingURL=gsapParser.js.map