@hyperframes/core 0.6.91 → 0.6.93

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.
@@ -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
@@ -531,13 +553,13 @@ function parseObjectArrayKeyframes(node, scope) {
531
553
  if (totalDuration > 0) {
532
554
  let cumulative = 0;
533
555
  for (const entry of raw) {
556
+ cumulative += entry.duration ?? 0;
534
557
  const percentage = Math.round((cumulative / totalDuration) * 100);
535
558
  keyframes.push({
536
559
  percentage,
537
560
  properties: entry.properties,
538
561
  ...(entry.ease ? { ease: entry.ease } : {}),
539
562
  });
540
- cumulative += entry.duration ?? 0;
541
563
  }
542
564
  }
543
565
  else {
@@ -759,7 +781,8 @@ function tweenCallToAnimation(call, scope) {
759
781
  }
760
782
  }
761
783
  }
762
- const posVal = call.positionArg ? extractLiteralValue(call.positionArg, scope) : 0;
784
+ const hasPositionArg = !!call.positionArg;
785
+ const posVal = hasPositionArg ? extractLiteralValue(call.positionArg, scope) : 0;
763
786
  const position = typeof posVal === "number" ? posVal : typeof posVal === "string" ? posVal : 0;
764
787
  let duration = typeof vars.duration === "number" ? vars.duration : undefined;
765
788
  const ease = typeof vars.ease === "string" ? vars.ease : undefined;
@@ -775,6 +798,19 @@ function tweenCallToAnimation(call, scope) {
775
798
  duration,
776
799
  ease,
777
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;
778
814
  if (Object.keys(extras).length > 0)
779
815
  anim.extras = extras;
780
816
  if (keyframesData)
@@ -787,14 +823,101 @@ function tweenCallToAnimation(call, scope) {
787
823
  anim.hasUnresolvedSelector = true;
788
824
  return anim;
789
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
+ }
790
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
+ */
791
913
  function assignStableIds(anims) {
792
914
  const counts = new Map();
793
915
  return anims.map((anim) => {
794
916
  const posKey = typeof anim.position === "number"
795
917
  ? String(Math.round(anim.position * 1000))
796
918
  : String(anim.position);
797
- const base = `${anim.targetSelector}-${anim.method}-${posKey}`;
919
+ const groupSuffix = anim.propertyGroup ? `-${anim.propertyGroup}` : "";
920
+ const base = `${anim.targetSelector}-${anim.method}-${posKey}${groupSuffix}`;
798
921
  const count = (counts.get(base) ?? 0) + 1;
799
922
  counts.set(base, count);
800
923
  const id = count === 1 ? base : `${base}-${count}`;
@@ -811,10 +934,14 @@ function parseGsapAst(script) {
811
934
  const ast = parseScript(script);
812
935
  const scope = collectScopeBindings(ast);
813
936
  const targetBindings = collectTargetBindings(ast, scope);
814
- const detection = findTimelineVar(ast);
937
+ const detection = findTimelineVar(ast, scope);
815
938
  const timelineVar = detection.timelineVar ?? "tl";
816
939
  const calls = findAllTweenCalls(ast, timelineVar, scope, targetBindings);
817
- 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);
818
945
  const located = animations.map((animation, i) => ({
819
946
  id: animation.id,
820
947
  call: calls[i],
@@ -1137,7 +1264,11 @@ export function removeAnimationFromScript(script, animationId) {
1137
1264
  console.warn("[gsap-parser] removeAnimationFromScript parse failed:", e);
1138
1265
  return script;
1139
1266
  }
1140
- 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
+ }
1141
1272
  if (!target)
1142
1273
  return script;
1143
1274
  const node = target.call.node;
@@ -1314,6 +1445,23 @@ function percentageFromKey(key) {
1314
1445
  const m = PERCENTAGE_KEY_RE.exec(key);
1315
1446
  return m ? Number.parseFloat(m[1]) : Number.NaN;
1316
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
+ }
1317
1465
  /** Build a keyframe value AST node from properties and optional ease. */
1318
1466
  function buildKeyframeValueNode(properties, ease) {
1319
1467
  const entries = Object.entries(properties).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`);
@@ -1368,7 +1516,11 @@ function collapseKeyframesToFlat(varsArg, record) {
1368
1516
  * updateKeyframeInScript.
1369
1517
  */
1370
1518
  function locateKeyframeCtx(script, animationId, percentage) {
1371
- const loc = locateAnimation(script, animationId);
1519
+ let loc = locateAnimation(script, animationId);
1520
+ if (!loc) {
1521
+ const convertedId = animationId.replace(/-from-|-fromTo-/, "-to-");
1522
+ loc = locateAnimation(script, convertedId);
1523
+ }
1372
1524
  if (!loc)
1373
1525
  return null;
1374
1526
  const kfNode = findKeyframesObjectNode(loc.target.call.varsArg);
@@ -1404,10 +1556,19 @@ export function addKeyframeToScript(script, animationId, percentage, properties,
1404
1556
  }
1405
1557
  const pctKey = `${percentage}%`;
1406
1558
  const newValueNode = buildKeyframeValueNode(properties, ease);
1407
- // Replace if this percentage already exists
1408
- const existingIdx = kfNode.properties.findIndex((p) => isObjectProperty(p) && propKeyName(p) === pctKey);
1409
- if (existingIdx !== -1) {
1410
- 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
+ }
1411
1572
  }
1412
1573
  else {
1413
1574
  // Build the new property node with a quoted percentage key
@@ -1488,10 +1649,11 @@ export function removeKeyframeFromScript(script, animationId, percentage) {
1488
1649
  const ctx = locateKeyframeCtx(script, animationId, percentage);
1489
1650
  if (!ctx)
1490
1651
  return script;
1491
- const { loc, kfNode, pctKey } = ctx;
1492
- const removeIdx = kfNode.properties.findIndex((p) => isObjectProperty(p) && propKeyName(p) === pctKey);
1493
- if (removeIdx === -1)
1652
+ const { loc, kfNode } = ctx;
1653
+ const match = findKeyframePropByPct(kfNode, percentage);
1654
+ if (!match)
1494
1655
  return script;
1656
+ const removeIdx = match.idx;
1495
1657
  kfNode.properties.splice(removeIdx, 1);
1496
1658
  const remainingKfs = filterPercentageProps(kfNode);
1497
1659
  if (remainingKfs.length < 2) {
@@ -1509,11 +1671,11 @@ export function updateKeyframeInScript(script, animationId, percentage, properti
1509
1671
  const ctx = locateKeyframeCtx(script, animationId, percentage);
1510
1672
  if (!ctx)
1511
1673
  return script;
1512
- const { loc, kfNode, pctKey } = ctx;
1513
- const existing = kfNode.properties.find((p) => isObjectProperty(p) && propKeyName(p) === pctKey);
1514
- if (!existing)
1674
+ const { loc, kfNode } = ctx;
1675
+ const match = findKeyframePropByPct(kfNode, percentage);
1676
+ if (!match)
1515
1677
  return script;
1516
- existing.value = buildKeyframeValueNode(properties, ease);
1678
+ match.prop.value = buildKeyframeValueNode(properties, ease);
1517
1679
  return recast.print(loc.parsed.ast).code;
1518
1680
  }
1519
1681
  /** Resolve from/to property maps for a tween being converted to keyframes. */
@@ -1527,31 +1689,46 @@ const CSS_IDENTITY = {
1527
1689
  function cssIdentityValue(prop) {
1528
1690
  return CSS_IDENTITY[prop] ?? 0;
1529
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
+ */
1530
1702
  function resolveConversionProps(anim, resolvedFromValues) {
1531
1703
  if (anim.method === "to") {
1532
- if (resolvedFromValues) {
1533
- return { fromProps: resolvedFromValues, toProps: { ...anim.properties } };
1534
- }
1535
1704
  const identityFrom = {};
1536
1705
  for (const [key, val] of Object.entries(anim.properties)) {
1537
1706
  if (val != null)
1538
1707
  identityFrom[key] = typeof val === "number" ? cssIdentityValue(key) : val;
1539
1708
  }
1540
- return { fromProps: identityFrom, toProps: { ...anim.properties } };
1709
+ const fromProps = resolvedFromValues
1710
+ ? { ...identityFrom, ...resolvedFromValues }
1711
+ : identityFrom;
1712
+ return { fromProps, toProps: { ...anim.properties } };
1541
1713
  }
1542
1714
  if (anim.method === "from") {
1543
- if (resolvedFromValues) {
1544
- return { fromProps: { ...anim.properties }, toProps: resolvedFromValues };
1545
- }
1546
1715
  const identityTo = {};
1547
1716
  for (const [key, val] of Object.entries(anim.properties)) {
1548
1717
  if (val != null)
1549
1718
  identityTo[key] = typeof val === "number" ? cssIdentityValue(key) : val;
1550
1719
  }
1551
- return { fromProps: { ...anim.properties }, toProps: identityTo };
1552
- }
1553
- // fromTo
1554
- 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 };
1555
1732
  }
1556
1733
  /** Strip editable properties and ease/keyframes keys from a varsArg. */
1557
1734
  function stripEditableAndEase(varsArg) {
@@ -1584,7 +1761,11 @@ function insertKeyframesProp(varsArg, fromProps, toProps, easeEach) {
1584
1761
  * the "to" state for `from()` tweens (the values the DOM would resolve to).
1585
1762
  */
1586
1763
  export function convertToKeyframesInScript(script, animationId, resolvedFromValues) {
1587
- 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
+ }
1588
1769
  if (!loc)
1589
1770
  return script;
1590
1771
  const anim = loc.target.animation;
@@ -1611,7 +1792,11 @@ export function convertToKeyframesInScript(script, animationId, resolvedFromValu
1611
1792
  * last keyframe's properties.
1612
1793
  */
1613
1794
  export function removeAllKeyframesFromScript(script, animationId) {
1614
- 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
+ }
1615
1800
  if (!loc)
1616
1801
  return script;
1617
1802
  const kfNode = findKeyframesObjectNode(loc.target.call.varsArg);
@@ -1636,7 +1821,11 @@ export function removeAllKeyframesFromScript(script, animationId) {
1636
1821
  * Called when the user first edits a dynamically-generated keyframe in the studio.
1637
1822
  */
1638
1823
  export function materializeKeyframesInScript(script, animationId, keyframes, easeEach, resolvedSelector) {
1639
- 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
+ }
1640
1829
  if (!loc)
1641
1830
  return script;
1642
1831
  const varsArg = loc.target.call.varsArg;
@@ -1844,6 +2033,136 @@ export function removeArcPathFromScript(script, animationId) {
1844
2033
  segments: [],
1845
2034
  });
1846
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
+ }
1847
2166
  /**
1848
2167
  * Replace a dynamic loop that generates multiple tween calls with individual
1849
2168
  * static `tl.to()` calls — one per element. Finds the loop containing the
@@ -1897,7 +2216,7 @@ export function unrollDynamicAnimations(script, animationId, elements) {
1897
2216
  if (el.easeEach) {
1898
2217
  kfEntries.push(`easeEach: ${JSON.stringify(el.easeEach)}`);
1899
2218
  }
1900
- 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});`);
1901
2220
  }
1902
2221
  const replacement = calls.join("\n ");
1903
2222
  if (loopNode) {