@hyperframes/core 0.6.90 → 0.6.92

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 (31) hide show
  1. package/dist/compiler/inlineSubCompositions.d.ts.map +1 -1
  2. package/dist/compiler/inlineSubCompositions.js +17 -0
  3. package/dist/compiler/inlineSubCompositions.js.map +1 -1
  4. package/dist/lint/rules/gsap.d.ts.map +1 -1
  5. package/dist/lint/rules/gsap.js +42 -1
  6. package/dist/lint/rules/gsap.js.map +1 -1
  7. package/dist/parsers/gsapConstants.d.ts +4 -0
  8. package/dist/parsers/gsapConstants.d.ts.map +1 -1
  9. package/dist/parsers/gsapConstants.js +28 -0
  10. package/dist/parsers/gsapConstants.js.map +1 -1
  11. package/dist/parsers/gsapParser.d.ts +26 -0
  12. package/dist/parsers/gsapParser.d.ts.map +1 -1
  13. package/dist/parsers/gsapParser.js +573 -63
  14. package/dist/parsers/gsapParser.js.map +1 -1
  15. package/dist/parsers/gsapParser.test-helpers.d.ts +49 -0
  16. package/dist/parsers/gsapParser.test-helpers.d.ts.map +1 -0
  17. package/dist/parsers/gsapParser.test-helpers.js +97 -0
  18. package/dist/parsers/gsapParser.test-helpers.js.map +1 -0
  19. package/dist/parsers/gsapSerialize.d.ts +8 -0
  20. package/dist/parsers/gsapSerialize.d.ts.map +1 -1
  21. package/dist/parsers/gsapSerialize.js +2 -2
  22. package/dist/parsers/gsapSerialize.js.map +1 -1
  23. package/dist/studio-api/helpers/manualEditsRenderScript.d.ts.map +1 -1
  24. package/dist/studio-api/helpers/manualEditsRenderScript.js.map +1 -1
  25. package/dist/studio-api/helpers/sourceMutation.d.ts.map +1 -1
  26. package/dist/studio-api/helpers/sourceMutation.js +122 -5
  27. package/dist/studio-api/helpers/sourceMutation.js.map +1 -1
  28. package/dist/studio-api/routes/files.d.ts.map +1 -1
  29. package/dist/studio-api/routes/files.js +57 -8
  30. package/dist/studio-api/routes/files.js.map +1 -1
  31. package/package.json +6 -1
@@ -11,6 +11,8 @@
11
11
  import * as recast from "recast";
12
12
  import { parse as babelParse } from "@babel/parser";
13
13
  export { serializeGsapAnimations, getAnimationsForElementId, validateCompositionGsap, keyframesToGsapAnimations, gsapAnimationsToKeyframes, SUPPORTED_PROPS, SUPPORTED_EASES, } from "./gsapSerialize";
14
+ export { PROPERTY_GROUPS, classifyPropertyGroup, classifyTweenPropertyGroup, } from "./gsapConstants";
15
+ import { classifyPropertyGroup, classifyTweenPropertyGroup } from "./gsapConstants";
14
16
  export { generateSpringEaseData, SPRING_PRESETS } from "./springEase";
15
17
  const GSAP_METHODS = new Set(["set", "to", "from", "fromTo"]);
16
18
  function parseScript(script) {
@@ -279,15 +281,34 @@ function isGsapTimelineCall(node) {
279
281
  node.callee.object?.name === "gsap" &&
280
282
  node.callee.property?.name === "timeline");
281
283
  }
282
- function findTimelineVar(ast) {
284
+ function extractTimelineDefaults(callNode, scope) {
285
+ const arg = callNode.arguments?.[0];
286
+ if (!arg || arg.type !== "ObjectExpression")
287
+ return undefined;
288
+ const defaultsProp = arg.properties?.find((p) => isObjectProperty(p) && propKeyName(p) === "defaults");
289
+ if (!defaultsProp?.value || defaultsProp.value.type !== "ObjectExpression")
290
+ return undefined;
291
+ const record = objectExpressionToRecord(defaultsProp.value, scope);
292
+ const result = {};
293
+ if (typeof record.ease === "string")
294
+ result.ease = record.ease;
295
+ if (typeof record.duration === "number")
296
+ result.duration = record.duration;
297
+ return Object.keys(result).length > 0 ? result : undefined;
298
+ }
299
+ function findTimelineVar(ast, scope) {
283
300
  let timelineVar = null;
284
301
  let timelineCount = 0;
302
+ let defaults;
303
+ const emptyScope = scope ?? new Map();
285
304
  recast.types.visit(ast, {
286
305
  visitVariableDeclarator(path) {
287
306
  if (isGsapTimelineCall(path.node.init)) {
288
307
  timelineCount += 1;
289
- if (!timelineVar)
308
+ if (!timelineVar) {
290
309
  timelineVar = path.node.id?.name ?? null;
310
+ defaults = extractTimelineDefaults(path.node.init, emptyScope);
311
+ }
291
312
  }
292
313
  this.traverse(path);
293
314
  },
@@ -298,12 +319,13 @@ function findTimelineVar(ast) {
298
319
  const left = path.node.left;
299
320
  if (left?.type === "Identifier")
300
321
  timelineVar = left.name;
322
+ defaults = extractTimelineDefaults(path.node.right, emptyScope);
301
323
  }
302
324
  }
303
325
  this.traverse(path);
304
326
  },
305
327
  });
306
- return { timelineVar, timelineCount };
328
+ return { timelineVar, timelineCount, defaults };
307
329
  }
308
330
  /**
309
331
  * True when the member chain of `callNode.callee` is rooted at the timeline
@@ -483,6 +505,20 @@ function parsePercentageKeyframes(node, scope) {
483
505
  ...(easeEach ? { easeEach } : {}),
484
506
  };
485
507
  }
508
+ function computeKeyframesTotalDuration(varsNode, scope) {
509
+ const kfNode = (varsNode.properties ?? []).find((p) => (p.key?.name ?? p.key?.value) === "keyframes")?.value;
510
+ if (!kfNode || kfNode.type !== "ArrayExpression")
511
+ return undefined;
512
+ let total = 0;
513
+ for (const el of kfNode.elements ?? []) {
514
+ if (!el || el.type !== "ObjectExpression")
515
+ continue;
516
+ const r = objectExpressionToRecord(el, scope);
517
+ if (typeof r.duration === "number")
518
+ total += r.duration;
519
+ }
520
+ return total > 0 ? total : undefined;
521
+ }
486
522
  // fallow-ignore-next-line complexity
487
523
  function parseObjectArrayKeyframes(node, scope) {
488
524
  const elements = node.elements ?? [];
@@ -517,13 +553,13 @@ function parseObjectArrayKeyframes(node, scope) {
517
553
  if (totalDuration > 0) {
518
554
  let cumulative = 0;
519
555
  for (const entry of raw) {
556
+ cumulative += entry.duration ?? 0;
520
557
  const percentage = Math.round((cumulative / totalDuration) * 100);
521
558
  keyframes.push({
522
559
  percentage,
523
560
  properties: entry.properties,
524
561
  ...(entry.ease ? { ease: entry.ease } : {}),
525
562
  });
526
- cumulative += entry.duration ?? 0;
527
563
  }
528
564
  }
529
565
  else {
@@ -745,10 +781,14 @@ function tweenCallToAnimation(call, scope) {
745
781
  }
746
782
  }
747
783
  }
748
- const posVal = call.positionArg ? extractLiteralValue(call.positionArg, scope) : 0;
784
+ const hasPositionArg = !!call.positionArg;
785
+ const posVal = hasPositionArg ? extractLiteralValue(call.positionArg, scope) : 0;
749
786
  const position = typeof posVal === "number" ? posVal : typeof posVal === "string" ? posVal : 0;
750
- const duration = typeof vars.duration === "number" ? vars.duration : undefined;
787
+ let duration = typeof vars.duration === "number" ? vars.duration : undefined;
751
788
  const ease = typeof vars.ease === "string" ? vars.ease : undefined;
789
+ if (duration === undefined && keyframesData) {
790
+ duration = computeKeyframesTotalDuration(call.varsArg, scope);
791
+ }
752
792
  const anim = {
753
793
  targetSelector: call.selector,
754
794
  method: call.method,
@@ -758,6 +798,19 @@ function tweenCallToAnimation(call, scope) {
758
798
  duration,
759
799
  ease,
760
800
  };
801
+ if (!hasPositionArg)
802
+ anim.implicitPosition = true;
803
+ let group = classifyTweenPropertyGroup(properties);
804
+ if (!group && keyframesData) {
805
+ const kfProps = {};
806
+ for (const kf of keyframesData.keyframes) {
807
+ for (const k of Object.keys(kf.properties))
808
+ kfProps[k] = true;
809
+ }
810
+ group = classifyTweenPropertyGroup(kfProps);
811
+ }
812
+ if (group)
813
+ anim.propertyGroup = group;
761
814
  if (Object.keys(extras).length > 0)
762
815
  anim.extras = extras;
763
816
  if (keyframesData)
@@ -770,14 +823,101 @@ function tweenCallToAnimation(call, scope) {
770
823
  anim.hasUnresolvedSelector = true;
771
824
  return anim;
772
825
  }
826
+ // ── Timeline Position Resolution ──────────────────────────────────────────
827
+ const GSAP_DEFAULT_DURATION = 0.5;
828
+ // NOTE: Label-based positions (e.g. "myLabel+=0.5") are not yet resolved —
829
+ // they fall through to parseFloat which returns null for non-numeric strings.
830
+ function resolvePositionString(pos, cursor, prevStart) {
831
+ const trimmed = pos.trim();
832
+ if (trimmed === "")
833
+ return cursor;
834
+ if (trimmed.startsWith("+=")) {
835
+ const n = Number.parseFloat(trimmed.slice(2));
836
+ return Number.isFinite(n) ? cursor + n : null;
837
+ }
838
+ if (trimmed.startsWith("-=")) {
839
+ const n = Number.parseFloat(trimmed.slice(2));
840
+ return Number.isFinite(n) ? cursor - n : null;
841
+ }
842
+ if (trimmed === "<")
843
+ return prevStart;
844
+ if (trimmed === ">")
845
+ return cursor;
846
+ if (trimmed.startsWith("<")) {
847
+ const n = Number.parseFloat(trimmed.slice(1));
848
+ return Number.isFinite(n) ? prevStart + n : null;
849
+ }
850
+ if (trimmed.startsWith(">")) {
851
+ const n = Number.parseFloat(trimmed.slice(1));
852
+ return Number.isFinite(n) ? cursor + n : null;
853
+ }
854
+ const n = Number.parseFloat(trimmed);
855
+ return Number.isFinite(n) ? n : null;
856
+ }
857
+ function applyTimelineDefaults(anims, defaults) {
858
+ if (!defaults)
859
+ return;
860
+ for (const anim of anims) {
861
+ if (anim.method === "set")
862
+ continue;
863
+ if (anim.duration === undefined && defaults.duration !== undefined) {
864
+ anim.duration = defaults.duration;
865
+ }
866
+ if (anim.ease === undefined && defaults.ease !== undefined) {
867
+ anim.ease = defaults.ease;
868
+ }
869
+ }
870
+ }
871
+ function resolveTimelinePositions(anims) {
872
+ let cursor = 0;
873
+ let prevStart = 0;
874
+ for (const anim of anims) {
875
+ const duration = anim.method === "set" ? 0 : (anim.duration ?? GSAP_DEFAULT_DURATION);
876
+ let start;
877
+ if (anim.implicitPosition) {
878
+ start = cursor;
879
+ }
880
+ else if (typeof anim.position === "number") {
881
+ start = anim.position;
882
+ }
883
+ else if (typeof anim.position === "string") {
884
+ start = resolvePositionString(anim.position, cursor, prevStart);
885
+ }
886
+ else {
887
+ start = cursor;
888
+ }
889
+ if (start != null) {
890
+ anim.resolvedStart = Math.max(0, start);
891
+ prevStart = anim.resolvedStart;
892
+ cursor = Math.max(cursor, anim.resolvedStart + duration);
893
+ }
894
+ }
895
+ }
896
+ function sortBySourcePosition(calls) {
897
+ calls.sort((a, b) => {
898
+ const aLoc = a.node.callee?.property?.loc?.start;
899
+ const bLoc = b.node.callee?.property?.loc?.start;
900
+ if (!aLoc || !bLoc)
901
+ return 0;
902
+ return aLoc.line - bLoc.line || aLoc.column - bLoc.column;
903
+ });
904
+ }
773
905
  // ── Stable ID Generation ───────────────────────────────────────────────────
906
+ /**
907
+ * IDs are transient — recomputed on every parse, never persisted across sessions.
908
+ * They exist only in ephemeral request/response payloads, React component state,
909
+ * and the in-memory keyframe cache (rebuilt on every page load). No database,
910
+ * localStorage, or file stores animation IDs, so changing the ID format (e.g.
911
+ * adding a `-scale`/`-position` suffix) is safe.
912
+ */
774
913
  function assignStableIds(anims) {
775
914
  const counts = new Map();
776
915
  return anims.map((anim) => {
777
916
  const posKey = typeof anim.position === "number"
778
917
  ? String(Math.round(anim.position * 1000))
779
918
  : String(anim.position);
780
- const base = `${anim.targetSelector}-${anim.method}-${posKey}`;
919
+ const groupSuffix = anim.propertyGroup ? `-${anim.propertyGroup}` : "";
920
+ const base = `${anim.targetSelector}-${anim.method}-${posKey}${groupSuffix}`;
781
921
  const count = (counts.get(base) ?? 0) + 1;
782
922
  counts.set(base, count);
783
923
  const id = count === 1 ? base : `${base}-${count}`;
@@ -794,10 +934,14 @@ function parseGsapAst(script) {
794
934
  const ast = parseScript(script);
795
935
  const scope = collectScopeBindings(ast);
796
936
  const targetBindings = collectTargetBindings(ast, scope);
797
- const detection = findTimelineVar(ast);
937
+ const detection = findTimelineVar(ast, scope);
798
938
  const timelineVar = detection.timelineVar ?? "tl";
799
939
  const calls = findAllTweenCalls(ast, timelineVar, scope, targetBindings);
800
- const animations = assignStableIds(calls.map((call) => tweenCallToAnimation(call, scope)));
940
+ sortBySourcePosition(calls);
941
+ const rawAnims = calls.map((call) => tweenCallToAnimation(call, scope));
942
+ applyTimelineDefaults(rawAnims, detection.defaults);
943
+ resolveTimelinePositions(rawAnims);
944
+ const animations = assignStableIds(rawAnims);
801
945
  const located = animations.map((animation, i) => ({
802
946
  id: animation.id,
803
947
  call: calls[i],
@@ -885,14 +1029,12 @@ function setVarsKey(varsArg, key, value) {
885
1029
  }
886
1030
  }
887
1031
  /**
888
- * Replace the editable-property keys on an ObjectExpression with `newProps`,
889
- * leaving `duration`, `ease`, `stagger`, callbacks and other non-editable keys
890
- * untouched.
1032
+ * Filter an ObjectExpression's properties, keeping non-editable keys
1033
+ * and delegating the keep/drop decision for editable keys to `shouldKeep`.
891
1034
  */
892
- function reconcileEditableProperties(varsArg, newProps) {
1035
+ function filterEditableKeys(varsArg, shouldKeep) {
893
1036
  if (varsArg?.type !== "ObjectExpression")
894
1037
  return;
895
- // Drop editable props no longer present.
896
1038
  varsArg.properties = varsArg.properties.filter((p) => {
897
1039
  if (!isObjectProperty(p))
898
1040
  return true;
@@ -901,8 +1043,16 @@ function reconcileEditableProperties(varsArg, newProps) {
901
1043
  return true;
902
1044
  if (!isEditablePropertyKey(key))
903
1045
  return true;
904
- return key in newProps;
1046
+ return shouldKeep(key);
905
1047
  });
1048
+ }
1049
+ /**
1050
+ * Replace the editable-property keys on an ObjectExpression with `newProps`,
1051
+ * leaving `duration`, `ease`, `stagger`, callbacks and other non-editable keys
1052
+ * untouched.
1053
+ */
1054
+ function reconcileEditableProperties(varsArg, newProps) {
1055
+ filterEditableKeys(varsArg, (key) => key in newProps);
906
1056
  // Upsert each new prop, preserving the order keys first appeared.
907
1057
  for (const [key, value] of Object.entries(newProps)) {
908
1058
  setVarsKey(varsArg, key, value);
@@ -980,6 +1130,26 @@ export function updateAnimationInScript(script, animationId, updates) {
980
1130
  applyUpdatesToCall(target.call, updates);
981
1131
  return recast.print(parsed.ast).code;
982
1132
  }
1133
+ function updateAnimationSelector(script, animationId, newSelector) {
1134
+ let parsed;
1135
+ try {
1136
+ parsed = parseGsapAst(script);
1137
+ }
1138
+ catch {
1139
+ return script;
1140
+ }
1141
+ const target = parsed.located.find((l) => l.id === animationId);
1142
+ if (!target)
1143
+ return script;
1144
+ const selectorArg = target.call.path.node.arguments?.[0];
1145
+ if (selectorArg?.type === "StringLiteral") {
1146
+ selectorArg.value = newSelector;
1147
+ }
1148
+ else if (selectorArg?.type === "Identifier") {
1149
+ target.call.path.node.arguments[0] = { type: "StringLiteral", value: newSelector };
1150
+ }
1151
+ return recast.print(parsed.ast).code;
1152
+ }
983
1153
  export function addAnimationToScript(script, animation) {
984
1154
  let parsed;
985
1155
  try {
@@ -1094,7 +1264,11 @@ export function removeAnimationFromScript(script, animationId) {
1094
1264
  console.warn("[gsap-parser] removeAnimationFromScript parse failed:", e);
1095
1265
  return script;
1096
1266
  }
1097
- const target = parsed.located.find((l) => l.id === animationId);
1267
+ let target = parsed.located.find((l) => l.id === animationId);
1268
+ if (!target) {
1269
+ const convertedId = animationId.replace(/-from-|-fromTo-/, "-to-");
1270
+ target = parsed.located.find((l) => l.id === convertedId);
1271
+ }
1098
1272
  if (!target)
1099
1273
  return script;
1100
1274
  const node = target.call.node;
@@ -1116,7 +1290,150 @@ export function removeAnimationFromScript(script, animationId) {
1116
1290
  }
1117
1291
  return recast.print(parsed.ast).code;
1118
1292
  }
1293
+ function insertInheritedStateSet(script, selector, position, properties) {
1294
+ let parsed;
1295
+ try {
1296
+ parsed = parseGsapAst(script);
1297
+ }
1298
+ catch {
1299
+ return script;
1300
+ }
1301
+ const tlVar = parsed.timelineVar;
1302
+ const props = Object.entries(properties)
1303
+ .map(([k, v]) => `${k}: ${typeof v === "string" ? JSON.stringify(v) : v}`)
1304
+ .join(", ");
1305
+ const code = `${tlVar}.set(${JSON.stringify(selector)}, { ${props} }, ${position});`;
1306
+ const newStatement = parseScript(code).program.body[0];
1307
+ const anchor = findTimelineDeclarationPath(parsed.ast, tlVar);
1308
+ if (anchor) {
1309
+ anchor.insertAfter(newStatement);
1310
+ }
1311
+ else if (parsed.located.length > 0) {
1312
+ const firstTween = parsed.located[0].call;
1313
+ const stmtPath = findStatementPath(firstTween.path);
1314
+ if (stmtPath)
1315
+ stmtPath.insertBefore(newStatement);
1316
+ else
1317
+ parsed.ast.program.body.unshift(newStatement);
1318
+ }
1319
+ else {
1320
+ parsed.ast.program.body.push(newStatement);
1321
+ }
1322
+ return recast.print(parsed.ast).code;
1323
+ }
1324
+ // fallow-ignore-next-line complexity
1325
+ export function splitAnimationsInScript(script, opts) {
1326
+ const parsed = parseGsapScript(script);
1327
+ const originalSelector = `#${opts.originalId}`;
1328
+ const newSelector = `#${opts.newId}`;
1329
+ const skippedSelectors = [];
1330
+ for (const a of parsed.animations) {
1331
+ if (a.targetSelector !== originalSelector && a.targetSelector.includes(opts.originalId)) {
1332
+ skippedSelectors.push(a.targetSelector);
1333
+ }
1334
+ }
1335
+ const matching = parsed.animations.filter((a) => a.targetSelector === originalSelector);
1336
+ if (matching.length === 0)
1337
+ return { script, skippedSelectors };
1338
+ let result = script;
1339
+ const newElementStart = opts.splitTime;
1340
+ const inheritedProps = {};
1341
+ // Reverse iteration: updateAnimationSelector mutates selectors in the source
1342
+ // string, which can shift count-based ID suffixes (e.g. "#hero-1" → "#hero-2")
1343
+ // for later animations. Processing last-to-first prevents stale ID collisions.
1344
+ for (let i = matching.length - 1; i >= 0; i--) {
1345
+ const anim = matching[i];
1346
+ const pos = typeof anim.position === "number" ? anim.position : 0;
1347
+ const dur = anim.duration ?? 0;
1348
+ const animEnd = pos + dur;
1349
+ if (anim.keyframes) {
1350
+ if (pos >= opts.splitTime) {
1351
+ result = updateAnimationSelector(result, anim.id, newSelector);
1352
+ }
1353
+ else if (animEnd > opts.splitTime) {
1354
+ // Spanning keyframes can't be correctly split without renormalizing
1355
+ // percentages and durations — leave on original, warn the caller.
1356
+ skippedSelectors.push(`${originalSelector} (keyframes spanning split)`);
1357
+ const kfs = anim.keyframes.keyframes;
1358
+ for (const kf of kfs) {
1359
+ const kfTime = pos + (kf.percentage / 100) * dur;
1360
+ if (kfTime <= opts.splitTime) {
1361
+ for (const [k, v] of Object.entries(kf.properties)) {
1362
+ inheritedProps[k] = v;
1363
+ }
1364
+ }
1365
+ }
1366
+ }
1367
+ else {
1368
+ // Entirely before split — extract final keyframe properties
1369
+ const kfs = anim.keyframes.keyframes;
1370
+ if (kfs.length > 0) {
1371
+ for (const [k, v] of Object.entries(kfs[kfs.length - 1].properties)) {
1372
+ inheritedProps[k] = v;
1373
+ }
1374
+ }
1375
+ }
1376
+ continue;
1377
+ }
1378
+ if (animEnd <= opts.splitTime) {
1379
+ for (const [k, v] of Object.entries(anim.properties)) {
1380
+ inheritedProps[k] = v;
1381
+ }
1382
+ continue;
1383
+ }
1384
+ if (pos >= opts.splitTime) {
1385
+ result = updateAnimationSelector(result, anim.id, newSelector);
1386
+ continue;
1387
+ }
1388
+ // Spans the split — use linear interpolation to compute mid-values,
1389
+ // then .fromTo() on the clone so both halves play the correct range.
1390
+ // For .fromTo() tweens we have explicit from-values; for .to() tweens
1391
+ // we use accumulated state from prior animations, defaulting to 0 for
1392
+ // unknown numeric properties (the standard GSAP transform initial state).
1393
+ const progress = dur > 0 ? (opts.splitTime - pos) / dur : 0;
1394
+ const fromSource = anim.fromProperties ?? inheritedProps;
1395
+ const midProps = {};
1396
+ for (const [k, v] of Object.entries(anim.properties)) {
1397
+ if (typeof v !== "number") {
1398
+ midProps[k] = v;
1399
+ continue;
1400
+ }
1401
+ const fromVal = typeof fromSource[k] === "number" ? fromSource[k] : 0;
1402
+ midProps[k] = fromVal + (v - fromVal) * progress;
1403
+ }
1404
+ const firstHalfDuration = opts.splitTime - pos;
1405
+ result = updateAnimationInScript(result, anim.id, {
1406
+ duration: firstHalfDuration,
1407
+ properties: midProps,
1408
+ });
1409
+ const secondHalfDuration = animEnd - opts.splitTime;
1410
+ const addResult = addAnimationToScript(result, {
1411
+ targetSelector: newSelector,
1412
+ method: "fromTo",
1413
+ position: newElementStart,
1414
+ duration: secondHalfDuration,
1415
+ properties: { ...anim.properties },
1416
+ fromProperties: { ...midProps },
1417
+ ease: anim.ease,
1418
+ extras: anim.extras,
1419
+ });
1420
+ result = addResult.script;
1421
+ for (const [k, v] of Object.entries(midProps)) {
1422
+ inheritedProps[k] = v;
1423
+ }
1424
+ }
1425
+ if (Object.keys(inheritedProps).length > 0) {
1426
+ result = insertInheritedStateSet(result, newSelector, newElementStart, inheritedProps);
1427
+ }
1428
+ return { script: result, skippedSelectors };
1429
+ }
1119
1430
  // ── Keyframe Mutation Functions ────────────────────────────────────────────
1431
+ function sortedKeyframes(kfs) {
1432
+ return kfs.slice().sort((a, b) => a.percentage - b.percentage);
1433
+ }
1434
+ function keyframePropsToCode(kf) {
1435
+ return Object.entries(kf.properties).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`);
1436
+ }
1120
1437
  /** Remove a named property from an ObjectExpression's properties array. */
1121
1438
  function removeVarsKey(varsArg, key) {
1122
1439
  if (varsArg?.type !== "ObjectExpression")
@@ -1128,6 +1445,23 @@ function percentageFromKey(key) {
1128
1445
  const m = PERCENTAGE_KEY_RE.exec(key);
1129
1446
  return m ? Number.parseFloat(m[1]) : Number.NaN;
1130
1447
  }
1448
+ const PCT_TOLERANCE = 2;
1449
+ function findKeyframePropByPct(kfNode, percentage) {
1450
+ const props = kfNode.properties;
1451
+ for (let i = 0; i < props.length; i++) {
1452
+ if (!isObjectProperty(props[i]))
1453
+ continue;
1454
+ const key = propKeyName(props[i]);
1455
+ if (typeof key !== "string")
1456
+ continue;
1457
+ const parsed = percentageFromKey(key);
1458
+ if (Number.isNaN(parsed))
1459
+ continue;
1460
+ if (Math.abs(parsed - percentage) <= PCT_TOLERANCE)
1461
+ return { idx: i, prop: props[i] };
1462
+ }
1463
+ return null;
1464
+ }
1131
1465
  /** Build a keyframe value AST node from properties and optional ease. */
1132
1466
  function buildKeyframeValueNode(properties, ease) {
1133
1467
  const entries = Object.entries(properties).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`);
@@ -1176,6 +1510,24 @@ function collapseKeyframesToFlat(varsArg, record) {
1176
1510
  removeVarsKey(varsArg, "keyframes");
1177
1511
  removeVarsKey(varsArg, "easeEach");
1178
1512
  }
1513
+ /**
1514
+ * Locate an animation's keyframes ObjectExpression and build the percentage key.
1515
+ * Shared preamble for addKeyframeToScript, removeKeyframeFromScript, and
1516
+ * updateKeyframeInScript.
1517
+ */
1518
+ function locateKeyframeCtx(script, animationId, percentage) {
1519
+ let loc = locateAnimation(script, animationId);
1520
+ if (!loc) {
1521
+ const convertedId = animationId.replace(/-from-|-fromTo-/, "-to-");
1522
+ loc = locateAnimation(script, convertedId);
1523
+ }
1524
+ if (!loc)
1525
+ return null;
1526
+ const kfNode = findKeyframesObjectNode(loc.target.call.varsArg);
1527
+ if (!kfNode)
1528
+ return null;
1529
+ return { loc, kfNode, pctKey: `${percentage}%` };
1530
+ }
1179
1531
  /**
1180
1532
  * Insert a keyframe at the given percentage in an existing percentage-keyframes
1181
1533
  * object. If the percentage already exists, its value is replaced.
@@ -1204,10 +1556,19 @@ export function addKeyframeToScript(script, animationId, percentage, properties,
1204
1556
  }
1205
1557
  const pctKey = `${percentage}%`;
1206
1558
  const newValueNode = buildKeyframeValueNode(properties, ease);
1207
- // Replace if this percentage already exists
1208
- const existingIdx = kfNode.properties.findIndex((p) => isObjectProperty(p) && propKeyName(p) === pctKey);
1209
- if (existingIdx !== -1) {
1210
- kfNode.properties[existingIdx].value = newValueNode;
1559
+ // Merge into existing keyframe at this percentage, or insert new
1560
+ const existing = findKeyframePropByPct(kfNode, percentage);
1561
+ if (existing) {
1562
+ if (existing.prop.value?.type === "ObjectExpression") {
1563
+ const existingRecord = objectExpressionToRecord(existing.prop.value, loc.parsed.scope);
1564
+ const merged = { ...existingRecord };
1565
+ for (const [k, v] of Object.entries(properties))
1566
+ merged[k] = v;
1567
+ existing.prop.value = buildKeyframeValueNode(merged, ease ?? (typeof existingRecord.ease === "string" ? existingRecord.ease : undefined));
1568
+ }
1569
+ else {
1570
+ existing.prop.value = newValueNode;
1571
+ }
1211
1572
  }
1212
1573
  else {
1213
1574
  // Build the new property node with a quoted percentage key
@@ -1285,16 +1646,14 @@ export function addKeyframeToScript(script, animationId, percentage, properties,
1285
1646
  * remaining keyframe's properties.
1286
1647
  */
1287
1648
  export function removeKeyframeFromScript(script, animationId, percentage) {
1288
- const loc = locateAnimation(script, animationId);
1289
- if (!loc)
1649
+ const ctx = locateKeyframeCtx(script, animationId, percentage);
1650
+ if (!ctx)
1290
1651
  return script;
1291
- const kfNode = findKeyframesObjectNode(loc.target.call.varsArg);
1292
- if (!kfNode)
1293
- return script;
1294
- const pctKey = `${percentage}%`;
1295
- const removeIdx = kfNode.properties.findIndex((p) => isObjectProperty(p) && propKeyName(p) === pctKey);
1296
- if (removeIdx === -1)
1652
+ const { loc, kfNode } = ctx;
1653
+ const match = findKeyframePropByPct(kfNode, percentage);
1654
+ if (!match)
1297
1655
  return script;
1656
+ const removeIdx = match.idx;
1298
1657
  kfNode.properties.splice(removeIdx, 1);
1299
1658
  const remainingKfs = filterPercentageProps(kfNode);
1300
1659
  if (remainingKfs.length < 2) {
@@ -1309,17 +1668,14 @@ export function removeKeyframeFromScript(script, animationId, percentage) {
1309
1668
  * Replace the properties (and optionally ease) at an existing keyframe percentage.
1310
1669
  */
1311
1670
  export function updateKeyframeInScript(script, animationId, percentage, properties, ease) {
1312
- const loc = locateAnimation(script, animationId);
1313
- if (!loc)
1671
+ const ctx = locateKeyframeCtx(script, animationId, percentage);
1672
+ if (!ctx)
1314
1673
  return script;
1315
- const kfNode = findKeyframesObjectNode(loc.target.call.varsArg);
1316
- if (!kfNode)
1317
- return script;
1318
- const pctKey = `${percentage}%`;
1319
- const existing = kfNode.properties.find((p) => isObjectProperty(p) && propKeyName(p) === pctKey);
1320
- if (!existing)
1674
+ const { loc, kfNode } = ctx;
1675
+ const match = findKeyframePropByPct(kfNode, percentage);
1676
+ if (!match)
1321
1677
  return script;
1322
- existing.value = buildKeyframeValueNode(properties, ease);
1678
+ match.prop.value = buildKeyframeValueNode(properties, ease);
1323
1679
  return recast.print(loc.parsed.ast).code;
1324
1680
  }
1325
1681
  /** Resolve from/to property maps for a tween being converted to keyframes. */
@@ -1333,46 +1689,60 @@ const CSS_IDENTITY = {
1333
1689
  function cssIdentityValue(prop) {
1334
1690
  return CSS_IDENTITY[prop] ?? 0;
1335
1691
  }
1692
+ /**
1693
+ * Resolve the 0% (from) and 100% (to) property maps for a tween being
1694
+ * converted to percentage keyframes.
1695
+ *
1696
+ * @param resolvedFromValues — Despite the "from" in the name (historical), these
1697
+ * are runtime-captured DOM values that override the conversion endpoint:
1698
+ * - For to(): overrides fromProps (the 0% state / where the element is now).
1699
+ * - For from(): overrides toProps (the 100% state / where the element rests).
1700
+ * - For fromTo(): merges into toProps (the 100% endpoint the user is editing).
1701
+ */
1336
1702
  function resolveConversionProps(anim, resolvedFromValues) {
1337
1703
  if (anim.method === "to") {
1338
- if (resolvedFromValues) {
1339
- return { fromProps: resolvedFromValues, toProps: { ...anim.properties } };
1340
- }
1341
1704
  const identityFrom = {};
1342
1705
  for (const [key, val] of Object.entries(anim.properties)) {
1343
1706
  if (val != null)
1344
1707
  identityFrom[key] = typeof val === "number" ? cssIdentityValue(key) : val;
1345
1708
  }
1346
- return { fromProps: identityFrom, toProps: { ...anim.properties } };
1709
+ const fromProps = resolvedFromValues
1710
+ ? { ...identityFrom, ...resolvedFromValues }
1711
+ : identityFrom;
1712
+ return { fromProps, toProps: { ...anim.properties } };
1347
1713
  }
1348
1714
  if (anim.method === "from") {
1349
- if (resolvedFromValues) {
1350
- return { fromProps: { ...anim.properties }, toProps: resolvedFromValues };
1351
- }
1352
1715
  const identityTo = {};
1353
1716
  for (const [key, val] of Object.entries(anim.properties)) {
1354
1717
  if (val != null)
1355
1718
  identityTo[key] = typeof val === "number" ? cssIdentityValue(key) : val;
1356
1719
  }
1357
- return { fromProps: { ...anim.properties }, toProps: identityTo };
1358
- }
1359
- // fromTo
1360
- return { fromProps: { ...(anim.fromProperties ?? {}) }, toProps: { ...anim.properties } };
1720
+ const toProps = resolvedFromValues ? { ...identityTo, ...resolvedFromValues } : identityTo;
1721
+ return { fromProps: { ...anim.properties }, toProps };
1722
+ }
1723
+ // fromTo(fromVars, toVars): anim.fromProperties = fromVars (0% state),
1724
+ // anim.properties = toVars (100% state). resolvedFromValues contains the
1725
+ // current DOM position from a drag — it represents the NEW destination, so
1726
+ // it merges into toProps (the 100% endpoint the user is editing), NOT into
1727
+ // fromProps. This is intentional and not inverted.
1728
+ const toProps = resolvedFromValues
1729
+ ? { ...anim.properties, ...resolvedFromValues }
1730
+ : { ...anim.properties };
1731
+ return { fromProps: { ...(anim.fromProperties ?? {}) }, toProps };
1361
1732
  }
1362
1733
  /** Strip editable properties and ease/keyframes keys from a varsArg. */
1363
1734
  function stripEditableAndEase(varsArg) {
1735
+ // ease is a BUILTIN_VAR_KEY (not editable), so filterEditableKeys won't remove it —
1736
+ // drop it explicitly before filtering, along with keyframes.
1364
1737
  if (varsArg?.type !== "ObjectExpression")
1365
1738
  return;
1366
1739
  varsArg.properties = varsArg.properties.filter((p) => {
1367
1740
  if (!isObjectProperty(p))
1368
1741
  return true;
1369
1742
  const key = propKeyName(p);
1370
- if (typeof key !== "string")
1371
- return true;
1372
- if (key === "ease" || key === "keyframes")
1373
- return false;
1374
- return !isEditablePropertyKey(key);
1743
+ return key !== "ease" && key !== "keyframes";
1375
1744
  });
1745
+ filterEditableKeys(varsArg, () => false);
1376
1746
  }
1377
1747
  /** Build and prepend a keyframes property node onto varsArg. */
1378
1748
  function insertKeyframesProp(varsArg, fromProps, toProps, easeEach) {
@@ -1391,7 +1761,11 @@ function insertKeyframesProp(varsArg, fromProps, toProps, easeEach) {
1391
1761
  * the "to" state for `from()` tweens (the values the DOM would resolve to).
1392
1762
  */
1393
1763
  export function convertToKeyframesInScript(script, animationId, resolvedFromValues) {
1394
- const loc = locateAnimation(script, animationId);
1764
+ let loc = locateAnimation(script, animationId);
1765
+ if (!loc) {
1766
+ const convertedId = animationId.replace(/-from-|-fromTo-/, "-to-");
1767
+ loc = locateAnimation(script, convertedId);
1768
+ }
1395
1769
  if (!loc)
1396
1770
  return script;
1397
1771
  const anim = loc.target.animation;
@@ -1418,7 +1792,11 @@ export function convertToKeyframesInScript(script, animationId, resolvedFromValu
1418
1792
  * last keyframe's properties.
1419
1793
  */
1420
1794
  export function removeAllKeyframesFromScript(script, animationId) {
1421
- const loc = locateAnimation(script, animationId);
1795
+ let loc = locateAnimation(script, animationId);
1796
+ if (!loc) {
1797
+ const convertedId = animationId.replace(/-from-|-fromTo-/, "-to-");
1798
+ loc = locateAnimation(script, convertedId);
1799
+ }
1422
1800
  if (!loc)
1423
1801
  return script;
1424
1802
  const kfNode = findKeyframesObjectNode(loc.target.call.varsArg);
@@ -1443,7 +1821,11 @@ export function removeAllKeyframesFromScript(script, animationId) {
1443
1821
  * Called when the user first edits a dynamically-generated keyframe in the studio.
1444
1822
  */
1445
1823
  export function materializeKeyframesInScript(script, animationId, keyframes, easeEach, resolvedSelector) {
1446
- const loc = locateAnimation(script, animationId);
1824
+ let loc = locateAnimation(script, animationId);
1825
+ if (!loc) {
1826
+ const convertedId = animationId.replace(/-from-|-fromTo-/, "-to-");
1827
+ loc = locateAnimation(script, convertedId);
1828
+ }
1447
1829
  if (!loc)
1448
1830
  return script;
1449
1831
  const varsArg = loc.target.call.varsArg;
@@ -1452,9 +1834,8 @@ export function materializeKeyframesInScript(script, animationId, keyframes, eas
1452
1834
  loc.target.call.node.arguments[0] = parseExpr(JSON.stringify(resolvedSelector));
1453
1835
  }
1454
1836
  const entries = [];
1455
- const sorted = keyframes.slice().sort((a, b) => a.percentage - b.percentage);
1456
- for (const kf of sorted) {
1457
- const propEntries = Object.entries(kf.properties).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`);
1837
+ for (const kf of sortedKeyframes(keyframes)) {
1838
+ const propEntries = keyframePropsToCode(kf);
1458
1839
  if (kf.ease)
1459
1840
  propEntries.push(`ease: ${JSON.stringify(kf.ease)}`);
1460
1841
  entries.push(`${JSON.stringify(kf.percentage + "%")}: { ${propEntries.join(", ")} }`);
@@ -1652,6 +2033,136 @@ export function removeArcPathFromScript(script, animationId) {
1652
2033
  segments: [],
1653
2034
  });
1654
2035
  }
2036
+ // ── Split Into Property Groups ────────────────────────────────────────────
2037
+ /**
2038
+ * Split a multi-group tween into separate per-group tweens. Each resulting
2039
+ * tween contains only properties belonging to one property group (position,
2040
+ * scale, rotation, visual, etc.). `transformOrigin` stays with the group that
2041
+ * has the most properties. If the tween already belongs to a single group,
2042
+ * returns the script unchanged with the original ID.
2043
+ */
2044
+ // fallow-ignore-next-line complexity
2045
+ export function splitIntoPropertyGroups(script, animationId) {
2046
+ let loc = locateAnimation(script, animationId);
2047
+ if (!loc) {
2048
+ const convertedId = animationId.replace(/-from-|-fromTo-/, "-to-");
2049
+ loc = locateAnimation(script, convertedId);
2050
+ }
2051
+ if (!loc)
2052
+ return { script, ids: [animationId] };
2053
+ const anim = loc.target.animation;
2054
+ // Collect the properties to partition. For keyframed tweens, gather the
2055
+ // union of all properties across all keyframes. For flat tweens, use the
2056
+ // tween's own properties map.
2057
+ const allPropKeys = new Set();
2058
+ if (anim.keyframes) {
2059
+ for (const kf of anim.keyframes.keyframes) {
2060
+ for (const k of Object.keys(kf.properties))
2061
+ allPropKeys.add(k);
2062
+ }
2063
+ }
2064
+ else {
2065
+ for (const k of Object.keys(anim.properties))
2066
+ allPropKeys.add(k);
2067
+ }
2068
+ // Partition properties into groups (excluding transformOrigin — handled below).
2069
+ const groupProps = new Map();
2070
+ for (const key of allPropKeys) {
2071
+ if (key === "transformOrigin")
2072
+ continue;
2073
+ const group = classifyPropertyGroup(key);
2074
+ let arr = groupProps.get(group);
2075
+ if (!arr) {
2076
+ arr = [];
2077
+ groupProps.set(group, arr);
2078
+ }
2079
+ arr.push(key);
2080
+ }
2081
+ // Only one group (or zero) — no split needed.
2082
+ if (groupProps.size <= 1)
2083
+ return { script, ids: [anim.id] };
2084
+ // Assign transformOrigin to the group with the most properties.
2085
+ if (allPropKeys.has("transformOrigin")) {
2086
+ let largestGroup;
2087
+ let largestCount = 0;
2088
+ for (const [group, props] of groupProps) {
2089
+ if (props.length > largestCount) {
2090
+ largestCount = props.length;
2091
+ largestGroup = group;
2092
+ }
2093
+ }
2094
+ if (largestGroup) {
2095
+ groupProps.get(largestGroup).push("transformOrigin");
2096
+ }
2097
+ }
2098
+ // Build per-group tweens and insert them, then remove the original.
2099
+ let result = script;
2100
+ // Remove the original tween first.
2101
+ result = removeAnimationFromScript(result, anim.id);
2102
+ // Insert one tween per group. Iteration order of the Map follows insertion
2103
+ // order, which mirrors the order properties were encountered.
2104
+ for (const [, props] of groupProps) {
2105
+ const propSet = new Set(props);
2106
+ if (anim.keyframes) {
2107
+ // Build keyframes containing only this group's properties per keyframe.
2108
+ const groupKeyframes = [];
2109
+ for (const kf of anim.keyframes.keyframes) {
2110
+ const filtered = {};
2111
+ for (const [k, v] of Object.entries(kf.properties)) {
2112
+ if (propSet.has(k))
2113
+ filtered[k] = v;
2114
+ }
2115
+ // Skip keyframes where this group has zero properties.
2116
+ if (Object.keys(filtered).length === 0)
2117
+ continue;
2118
+ groupKeyframes.push({
2119
+ percentage: kf.percentage,
2120
+ properties: filtered,
2121
+ ...(kf.ease ? { ease: kf.ease } : {}),
2122
+ });
2123
+ }
2124
+ if (groupKeyframes.length === 0)
2125
+ continue;
2126
+ const addResult = addAnimationWithKeyframesToScript(result, anim.targetSelector, typeof anim.position === "number" ? anim.position : 0, anim.duration ?? 0.5, groupKeyframes, anim.keyframes.easeEach ?? anim.ease);
2127
+ result = addResult.script;
2128
+ }
2129
+ else {
2130
+ // Flat tween — filter properties to this group.
2131
+ const groupProperties = {};
2132
+ for (const [k, v] of Object.entries(anim.properties)) {
2133
+ if (propSet.has(k))
2134
+ groupProperties[k] = v;
2135
+ }
2136
+ if (Object.keys(groupProperties).length === 0)
2137
+ continue;
2138
+ let fromProperties;
2139
+ if (anim.method === "fromTo" && anim.fromProperties) {
2140
+ fromProperties = {};
2141
+ for (const [k, v] of Object.entries(anim.fromProperties)) {
2142
+ if (propSet.has(k))
2143
+ fromProperties[k] = v;
2144
+ }
2145
+ }
2146
+ const addResult = addAnimationToScript(result, {
2147
+ targetSelector: anim.targetSelector,
2148
+ method: anim.method,
2149
+ position: anim.position,
2150
+ duration: anim.duration,
2151
+ ease: anim.ease,
2152
+ properties: groupProperties,
2153
+ fromProperties,
2154
+ extras: anim.extras,
2155
+ });
2156
+ result = addResult.script;
2157
+ }
2158
+ }
2159
+ // Re-parse to collect the new IDs.
2160
+ const reParsed = parseGsapAst(result);
2161
+ const newIds = reParsed.located
2162
+ .filter((l) => l.animation.targetSelector === anim.targetSelector)
2163
+ .map((l) => l.id);
2164
+ return { script: result, ids: newIds };
2165
+ }
1655
2166
  /**
1656
2167
  * Replace a dynamic loop that generates multiple tween calls with individual
1657
2168
  * static `tl.to()` calls — one per element. Finds the loop containing the
@@ -1698,15 +2209,14 @@ export function unrollDynamicAnimations(script, animationId, elements) {
1698
2209
  const calls = [];
1699
2210
  for (const el of elements) {
1700
2211
  const kfEntries = [];
1701
- const sorted = el.keyframes.slice().sort((a, b) => a.percentage - b.percentage);
1702
- for (const kf of sorted) {
1703
- const propEntries = Object.entries(kf.properties).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`);
2212
+ for (const kf of sortedKeyframes(el.keyframes)) {
2213
+ const propEntries = keyframePropsToCode(kf);
1704
2214
  kfEntries.push(`${JSON.stringify(kf.percentage + "%")}: { ${propEntries.join(", ")} }`);
1705
2215
  }
1706
2216
  if (el.easeEach) {
1707
2217
  kfEntries.push(`easeEach: ${JSON.stringify(el.easeEach)}`);
1708
2218
  }
1709
- calls.push(`tl.to(${JSON.stringify(el.selector)}, { keyframes: { ${kfEntries.join(", ")} }, duration: ${duration}, ease: ${JSON.stringify(ease)} }, ${posCode});`);
2219
+ calls.push(`${loc.parsed.timelineVar}.to(${JSON.stringify(el.selector)}, { keyframes: { ${kfEntries.join(", ")} }, duration: ${duration}, ease: ${JSON.stringify(ease)} }, ${posCode});`);
1710
2220
  }
1711
2221
  const replacement = calls.join("\n ");
1712
2222
  if (loopNode) {