@hyperframes/studio 0.7.38 → 0.7.40

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 (102) hide show
  1. package/dist/assets/{index-ClUipc8i.js → index-89kPtC4s.js} +1 -1
  2. package/dist/assets/{index-BLRTwY5l.js → index-B2Utv-2b.js} +200 -200
  3. package/dist/assets/index-BpM6cnfP.css +1 -0
  4. package/dist/assets/{index-Ykq7ihge.js → index-CjX8Ljc8.js} +1 -1
  5. package/dist/{chunk-JND3XUJL.js → chunk-BA66NM4L.js} +1 -1
  6. package/dist/chunk-BA66NM4L.js.map +1 -0
  7. package/dist/{domEditingLayers-UIQZJCOA.js → domEditingLayers-H7LDFIJ7.js} +2 -2
  8. package/dist/index.d.ts +47 -29
  9. package/dist/index.html +2 -2
  10. package/dist/index.js +4432 -3045
  11. package/dist/index.js.map +1 -1
  12. package/package.json +7 -7
  13. package/src/App.tsx +9 -7
  14. package/src/components/StudioPreviewArea.tsx +14 -0
  15. package/src/components/StudioRightPanel.tsx +9 -0
  16. package/src/components/editor/DomEditCropHandles.tsx +238 -0
  17. package/src/components/editor/DomEditOverlay.tsx +140 -151
  18. package/src/components/editor/DomEditRotateHandle.tsx +41 -0
  19. package/src/components/editor/PropertyPanel.test.ts +73 -0
  20. package/src/components/editor/PropertyPanel.tsx +42 -37
  21. package/src/components/editor/PropertyPanelEmptyState.tsx +33 -0
  22. package/src/components/editor/SnapToolbar.tsx +20 -1
  23. package/src/components/editor/clipPathHelpers.ts +113 -0
  24. package/src/components/editor/domEditOverlayCrop.test.ts +106 -0
  25. package/src/components/editor/domEditOverlayCrop.ts +115 -0
  26. package/src/components/editor/domEditOverlayGeometry.ts +14 -0
  27. package/src/components/editor/domEditOverlayShape.ts +39 -0
  28. package/src/components/editor/domEditing.test.ts +2 -0
  29. package/src/components/editor/domEditingElement.ts +8 -1
  30. package/src/components/editor/domEditingTypes.ts +3 -0
  31. package/src/components/editor/manualEditsDom.ts +1 -18
  32. package/src/components/editor/manualEditsStyleHelpers.ts +18 -0
  33. package/src/components/editor/marqueeCommit.ts +3 -2
  34. package/src/components/editor/offCanvasIndicatorGeometry.ts +74 -0
  35. package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +154 -0
  36. package/src/components/editor/offCanvasIndicatorRefresh.ts +99 -0
  37. package/src/components/editor/propertyPanelHelpers.ts +21 -34
  38. package/src/components/editor/propertyPanelStyleSections.tsx +75 -1
  39. package/src/components/editor/propertyPanelTypes.ts +3 -0
  40. package/src/components/editor/snapTargetCollection.ts +2 -3
  41. package/src/components/editor/useDomEditCompositionRect.ts +68 -0
  42. package/src/components/editor/useDomEditOverlayGestures.ts +13 -6
  43. package/src/components/editor/useDomEditOverlayRects.ts +5 -3
  44. package/src/components/sidebar/AssetsTab.test.ts +87 -0
  45. package/src/components/sidebar/AssetsTab.tsx +113 -15
  46. package/src/components/sidebar/BlocksTab.tsx +2 -1
  47. package/src/components/sidebar/GlobalAssetsView.tsx +91 -0
  48. package/src/contexts/TimelineEditContext.tsx +1 -0
  49. package/src/hooks/domSelectionTestHarness.ts +1 -0
  50. package/src/hooks/gsapDragCommit.test.ts +56 -0
  51. package/src/hooks/gsapDragCommit.ts +41 -109
  52. package/src/hooks/gsapDragPositionCommit.ts +3 -0
  53. package/src/hooks/gsapDragStaticSetHelpers.ts +109 -0
  54. package/src/hooks/gsapRuntimeBridge.ts +23 -2
  55. package/src/hooks/gsapRuntimeKeyframes.test.ts +34 -0
  56. package/src/hooks/gsapRuntimeKeyframes.ts +13 -1
  57. package/src/hooks/gsapWholePropertyOffsetCommit.ts +1 -0
  58. package/src/hooks/timelineEditingHelpers.ts +17 -5
  59. package/src/hooks/timelineTrackVisibility.test.ts +202 -0
  60. package/src/hooks/timelineTrackVisibility.ts +371 -0
  61. package/src/hooks/useCropMode.ts +91 -0
  62. package/src/hooks/useDomEditCommits.test.tsx +2 -0
  63. package/src/hooks/useRenderClipContent.test.ts +68 -2
  64. package/src/hooks/useRenderClipContent.ts +5 -6
  65. package/src/hooks/useTimelineEditing.ts +35 -30
  66. package/src/hooks/useTimelineEditingTypes.ts +30 -0
  67. package/src/icons/SystemIcons.tsx +12 -4
  68. package/src/player/components/AudioWaveform.tsx +11 -8
  69. package/src/player/components/CompositionThumbnail.tsx +14 -11
  70. package/src/player/components/PlayheadIndicator.tsx +21 -11
  71. package/src/player/components/ShortcutsPanel.tsx +9 -0
  72. package/src/player/components/Timeline.test.ts +105 -5
  73. package/src/player/components/Timeline.tsx +37 -47
  74. package/src/player/components/TimelineCanvas.tsx +275 -256
  75. package/src/player/components/TimelineClip.test.tsx +105 -0
  76. package/src/player/components/TimelineClip.tsx +44 -57
  77. package/src/player/components/TimelineRuler.tsx +17 -23
  78. package/src/player/components/TimelineShortcutHint.tsx +26 -0
  79. package/src/player/components/TimelineTypes.ts +18 -0
  80. package/src/player/components/VideoThumbnail.tsx +16 -13
  81. package/src/player/components/timelineCallbacks.ts +1 -0
  82. package/src/player/components/timelineIcons.tsx +2 -44
  83. package/src/player/components/timelineLayout.ts +18 -22
  84. package/src/player/components/timelineMotionStyles.test.ts +101 -0
  85. package/src/player/components/timelineTheme.test.ts +21 -4
  86. package/src/player/components/timelineTheme.ts +15 -15
  87. package/src/player/components/useTimelineActiveClips.test.ts +93 -0
  88. package/src/player/components/useTimelineActiveClips.ts +125 -0
  89. package/src/player/components/useTimelinePlayhead.ts +6 -0
  90. package/src/player/hooks/useExpandedTimelineElements.test.ts +110 -3
  91. package/src/player/hooks/useExpandedTimelineElements.ts +106 -19
  92. package/src/player/lib/time.test.ts +1 -0
  93. package/src/player/lib/timelineDOM.test.ts +48 -1
  94. package/src/player/lib/timelineDOM.ts +7 -0
  95. package/src/player/lib/timelineIframeHelpers.ts +1 -0
  96. package/src/player/store/playerStore.ts +19 -1
  97. package/src/styles/studio.css +108 -0
  98. package/src/utils/sourcePatcher.test.ts +11 -0
  99. package/src/utils/sourcePatcher.ts +6 -4
  100. package/dist/assets/index-DJaiR8T2.css +0 -1
  101. package/dist/chunk-JND3XUJL.js.map +0 -1
  102. /package/dist/{domEditingLayers-UIQZJCOA.js.map → domEditingLayers-H7LDFIJ7.js.map} +0 -0
@@ -14,7 +14,16 @@ import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeComp
14
14
  import { roundTo3 } from "../utils/rounding";
15
15
  import { computeElementPercentage } from "./gsapShared";
16
16
  import { computeDraggedGsapPosition } from "./draggedGsapPosition";
17
- import type { RuntimeTweenChange, SetPatchProps } from "./gsapRuntimePatch";
17
+ import type { RuntimeTweenChange } from "./gsapRuntimePatch";
18
+ import {
19
+ setPatchFromUpdateProperties,
20
+ setPatchFromUpdateProperty,
21
+ } from "./gsapDragStaticSetHelpers";
22
+ export {
23
+ findExistingPositionWrite,
24
+ findRotationSetAnimation,
25
+ findSizeSetAnimation,
26
+ } from "./gsapDragStaticSetHelpers";
18
27
  export interface GsapDragCommitCallbacks {
19
28
  commitMutation: (
20
29
  selection: DomEditSelection,
@@ -103,86 +112,6 @@ export async function materializeIfDynamic(
103
112
 
104
113
  // ── Drag → GSAP position math ──────────────────────────────────────────────
105
114
 
106
- /** The shape of an `update-property` mutation a static-set nudge POSTs. */
107
- interface UpdatePropertyMutation {
108
- type: "update-property";
109
- animationId: string;
110
- property: string;
111
- value: number;
112
- }
113
-
114
- /**
115
- * Build the `instantPatch` for a value-only `tl.set` from the SAME
116
- * `update-property` mutation(s) that are POSTed — so the patch can never carry a
117
- * value the source write didn't (one source of truth). Each mutation contributes
118
- * its `{property: value}` channel to the patch's props.
119
- */
120
- function setPatchFromUpdateProperties(
121
- selector: string,
122
- mutations: UpdatePropertyMutation[],
123
- global = false,
124
- ): { selector: string; change: RuntimeTweenChange } {
125
- const props: SetPatchProps = {};
126
- for (const m of mutations) props[m.property as keyof SetPatchProps] = m.value;
127
- // An off-timeline `gsap.set` has no runtime tween to patch — apply it to the
128
- // element directly. An on-timeline `tl.set` mutates its tween (so a re-seek keeps it).
129
- return { selector, change: { kind: global ? "global-set" : "set", props } };
130
- }
131
-
132
- /** Single-mutation convenience over {@link setPatchFromUpdateProperties}. */
133
- function setPatchFromUpdateProperty(
134
- selector: string,
135
- mutation: UpdatePropertyMutation,
136
- global = false,
137
- ): { selector: string; change: RuntimeTweenChange } {
138
- return setPatchFromUpdateProperties(selector, [mutation], global);
139
- }
140
-
141
- /**
142
- * Find the studio position-hold `set` for a selector — a `tl.set("#el",{x,y})`
143
- * with no duration. This is what a static-element nudge writes/updates.
144
- */
145
- function findPositionSetAnimation(
146
- animations: GsapAnimation[],
147
- selector: string,
148
- ): GsapAnimation | null {
149
- return (
150
- animations.find(
151
- (a) =>
152
- a.method === "set" &&
153
- a.targetSelector === selector &&
154
- ("x" in a.properties || "y" in a.properties),
155
- ) ?? null
156
- );
157
- }
158
-
159
- /**
160
- * Find the EXISTING static position HOLD to update for a static-hold drag. Not
161
- * just a `set`: a degenerate `tl.to("#el",{duration:0,x,y})` (what
162
- * remove-all-keyframes leaves behind) is a held position too, and the next drag
163
- * must UPDATE it in place rather than append a second `gsap.set` that fights it
164
- * (the duplicate-position-write bug). Only zero-duration holds qualify — a
165
- * live-duration `to`/`from` is NOT a static hold (and in the static path it's a
166
- * stale/phantom parse: re-committing it would resurrect a just-deleted tween).
167
- * Prefers a `set` (the canonical static channel) when both forms exist.
168
- */
169
- function findExistingPositionWrite(
170
- animations: GsapAnimation[],
171
- selector: string,
172
- ): GsapAnimation | null {
173
- const set = findPositionSetAnimation(animations, selector);
174
- if (set) return set;
175
- return (
176
- animations.find(
177
- (a) =>
178
- a.targetSelector === selector &&
179
- a.propertyGroup === "position" &&
180
- !a.keyframes &&
181
- (a.duration ?? 0) === 0,
182
- ) ?? null
183
- );
184
- }
185
-
186
115
  /**
187
116
  * Commit a STATIC element drag as a `tl.set("#el",{x,y})` — the single-source
188
117
  * position channel for elements with no position animation. Idempotent: a
@@ -201,6 +130,34 @@ export async function commitStaticGsapPosition(
201
130
  ): Promise<void> {
202
131
  const { newX, newY } = computeDraggedGsapPosition(selection.element, studioOffset, gsapPos);
203
132
  if (existingSet) {
133
+ if (existingSet.keyframes) {
134
+ // Keyframed zero-duration hold (drag-path corruption): can't update-property
135
+ // into keyframes — delete it and write a clean static set instead.
136
+ const coalesceKey = `gsap:heal-static:${existingSet.id}`;
137
+ await callbacks.commitMutation(
138
+ selection,
139
+ { type: "delete", animationId: existingSet.id },
140
+ { label: "Move layer", skipReload: true, coalesceKey },
141
+ );
142
+ await callbacks.commitMutation(
143
+ selection,
144
+ {
145
+ type: "add",
146
+ targetSelector: selector,
147
+ method: "set",
148
+ position: 0,
149
+ properties: { x: newX, y: newY },
150
+ global: true,
151
+ },
152
+ {
153
+ label: "Move layer",
154
+ softReload: true,
155
+ coalesceKey,
156
+ instantPatch: { selector, change: { kind: "global-set", props: { x: newX, y: newY } } },
157
+ },
158
+ );
159
+ return;
160
+ }
204
161
  // Update in place — two single-property mutations (the API updates one prop
205
162
  // per call). Coalesce them and reload only after the second lands.
206
163
  const coalesceKey = `gsap:set-nudge:${existingSet.id}`;
@@ -262,19 +219,6 @@ export async function commitStaticGsapPosition(
262
219
  );
263
220
  }
264
221
 
265
- export { findExistingPositionWrite };
266
-
267
- function findRotationSetAnimation(
268
- animations: GsapAnimation[],
269
- selector: string,
270
- ): GsapAnimation | null {
271
- return (
272
- animations.find(
273
- (a) => a.method === "set" && a.targetSelector === selector && "rotation" in a.properties,
274
- ) ?? null
275
- );
276
- }
277
-
278
222
  /**
279
223
  * Commit a STATIC element rotation as a `tl.set("#el",{rotation})` — the single-
280
224
  * source rotation channel for elements with no rotation animation (mirrors
@@ -328,19 +272,6 @@ export async function commitStaticGsapRotation(
328
272
  );
329
273
  }
330
274
 
331
- export { findRotationSetAnimation };
332
-
333
- function findSizeSetAnimation(animations: GsapAnimation[], selector: string): GsapAnimation | null {
334
- return (
335
- animations.find(
336
- (a) =>
337
- a.method === "set" &&
338
- a.targetSelector === selector &&
339
- ("width" in a.properties || "height" in a.properties),
340
- ) ?? null
341
- );
342
- }
343
-
344
275
  /**
345
276
  * Commit a STATIC element resize as a `tl.set("#el",{width,height})` — the
346
277
  * single-source size channel for elements with no size animation (mirrors
@@ -489,8 +420,6 @@ export async function commitKeyframedSizeFromResize(
489
420
  return true;
490
421
  }
491
422
 
492
- export { findSizeSetAnimation };
493
-
494
423
  // ── Whole-path offset (plain drag on animated element) ──────────────────
495
424
 
496
425
  /**
@@ -498,6 +427,7 @@ export { findSizeSetAnimation };
498
427
  * shifts together so the animation shape is preserved and the element can't
499
428
  * dart off-screen. For flat tweens (no keyframes), convert first then shift.
500
429
  */
430
+ // fallow-ignore-next-line code-duplication
501
431
  // fallow-ignore-next-line complexity
502
432
  export async function commitWholePathOffset(
503
433
  selection: DomEditSelection,
@@ -515,6 +445,7 @@ export async function commitWholePathOffset(
515
445
  gsapPos,
516
446
  );
517
447
  const deltaX = newX - baseGsapX;
448
+ // fallow-ignore-next-line code-duplication
518
449
  const deltaY = newY - baseGsapY;
519
450
  const origX = Number.parseFloat(el.getAttribute("data-hf-drag-initial-offset-x") ?? "") || 0;
520
451
  const origY = Number.parseFloat(el.getAttribute("data-hf-drag-initial-offset-y") ?? "") || 0;
@@ -525,6 +456,7 @@ export async function commitWholePathOffset(
525
456
  el.removeAttribute("data-hf-drag-initial-offset-y");
526
457
  };
527
458
 
459
+ // fallow-ignore-next-line code-duplication
528
460
  let effectiveAnim = anim;
529
461
  if (anim.keyframes) {
530
462
  const newId = await materializeIfDynamic(anim, iframe, callbacks.commitMutation, selection);
@@ -202,6 +202,7 @@ async function commitFlatViaKeyframes(
202
202
  }
203
203
 
204
204
  const coalesceKey = `gsap:convert-drag:${anim.id}`;
205
+ // fallow-ignore-next-line code-duplication
205
206
  await callbacks.commitMutation(
206
207
  selection,
207
208
  {
@@ -229,6 +230,7 @@ async function commitFlatViaKeyframes(
229
230
  if (editedSelected) parkPlayheadOnKeyframe(anim, pct);
230
231
  }
231
232
 
233
+ // fallow-ignore-next-line code-duplication
232
234
  // fallow-ignore-next-line complexity
233
235
  export async function commitGsapPositionFromDrag(
234
236
  selection: DomEditSelection,
@@ -240,6 +242,7 @@ export async function commitGsapPositionFromDrag(
240
242
  callbacks: GsapDragCommitCallbacks,
241
243
  ): Promise<void> {
242
244
  const el = selection.element;
245
+ // fallow-ignore-next-line code-duplication
243
246
  const { newX, newY, baseGsapX, baseGsapY } = computeDraggedGsapPosition(
244
247
  el,
245
248
  studioOffset,
@@ -0,0 +1,109 @@
1
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
2
+ import { resolveTweenDuration } from "../utils/globalTimeCompiler";
3
+ import type { RuntimeTweenChange, SetPatchProps } from "./gsapRuntimePatch";
4
+
5
+ /** The shape of an `update-property` mutation a static-set nudge POSTs. */
6
+ interface UpdatePropertyMutation {
7
+ type: "update-property";
8
+ animationId: string;
9
+ property: string;
10
+ value: number;
11
+ }
12
+
13
+ /**
14
+ * Build the `instantPatch` for a value-only `tl.set` from the SAME
15
+ * `update-property` mutation(s) that are POSTed — so the patch can never carry a
16
+ * value the source write didn't (one source of truth). Each mutation contributes
17
+ * its `{property: value}` channel to the patch's props.
18
+ */
19
+ export function setPatchFromUpdateProperties(
20
+ selector: string,
21
+ mutations: UpdatePropertyMutation[],
22
+ global = false,
23
+ ): { selector: string; change: RuntimeTweenChange } {
24
+ const props: SetPatchProps = {};
25
+ for (const m of mutations) props[m.property as keyof SetPatchProps] = m.value;
26
+ // An off-timeline `gsap.set` has no runtime tween to patch — apply it to the
27
+ // element directly. An on-timeline `tl.set` mutates its tween (so a re-seek keeps it).
28
+ return { selector, change: { kind: global ? "global-set" : "set", props } };
29
+ }
30
+
31
+ /** Single-mutation convenience over {@link setPatchFromUpdateProperties}. */
32
+ export function setPatchFromUpdateProperty(
33
+ selector: string,
34
+ mutation: UpdatePropertyMutation,
35
+ global = false,
36
+ ): { selector: string; change: RuntimeTweenChange } {
37
+ return setPatchFromUpdateProperties(selector, [mutation], global);
38
+ }
39
+
40
+ /**
41
+ * Find the studio position-hold `set` for a selector — a `tl.set("#el",{x,y})`
42
+ * with no duration. This is what a static-element nudge writes/updates.
43
+ */
44
+ function findPositionSetAnimation(
45
+ animations: GsapAnimation[],
46
+ selector: string,
47
+ ): GsapAnimation | null {
48
+ return (
49
+ animations.find(
50
+ (a) =>
51
+ a.method === "set" &&
52
+ a.targetSelector === selector &&
53
+ ("x" in a.properties || "y" in a.properties),
54
+ ) ?? null
55
+ );
56
+ }
57
+
58
+ /**
59
+ * Find the EXISTING static position HOLD to update for a static-hold drag. Not
60
+ * just a `set`: a degenerate `tl.to("#el",{duration:0,x,y})` (what
61
+ * remove-all-keyframes leaves behind) is a held position too, and the next drag
62
+ * must UPDATE it in place rather than append a second `gsap.set` that fights it
63
+ * (the duplicate-position-write bug). Only zero-duration holds qualify — a
64
+ * live-duration `to`/`from` is NOT a static hold (and in the static path it's a
65
+ * stale/phantom parse: re-committing it would resurrect a just-deleted tween).
66
+ * A keyframed zero-duration `to` is ALSO a static hold (a drag-path corruption
67
+ * artifact) and must be recognized so the static commit normalizes it.
68
+ * Prefers a `set` (the canonical static channel) when both forms exist.
69
+ */
70
+ export function findExistingPositionWrite(
71
+ animations: GsapAnimation[],
72
+ selector: string,
73
+ ): GsapAnimation | null {
74
+ const set = findPositionSetAnimation(animations, selector);
75
+ if (set) return set;
76
+ return (
77
+ animations.find(
78
+ (a) =>
79
+ a.targetSelector === selector &&
80
+ a.propertyGroup === "position" &&
81
+ resolveTweenDuration(a) === 0,
82
+ ) ?? null
83
+ );
84
+ }
85
+
86
+ export function findRotationSetAnimation(
87
+ animations: GsapAnimation[],
88
+ selector: string,
89
+ ): GsapAnimation | null {
90
+ return (
91
+ animations.find(
92
+ (a) => a.method === "set" && a.targetSelector === selector && "rotation" in a.properties,
93
+ ) ?? null
94
+ );
95
+ }
96
+
97
+ export function findSizeSetAnimation(
98
+ animations: GsapAnimation[],
99
+ selector: string,
100
+ ): GsapAnimation | null {
101
+ return (
102
+ animations.find(
103
+ (a) =>
104
+ a.method === "set" &&
105
+ a.targetSelector === selector &&
106
+ ("width" in a.properties || "height" in a.properties),
107
+ ) ?? null
108
+ );
109
+ }
@@ -38,6 +38,23 @@ import {
38
38
  import { hasNonHoldTweenForElement } from "./gsapRuntimeKeyframes";
39
39
  import { roundTo3 } from "../utils/rounding";
40
40
 
41
+ // Position channels — used to scope the "has a live position tween?" check so a
42
+ // sibling rotation/scale animation never forces a static position hold into the
43
+ // keyframe branch (which corrupts it into a frozen duration-0 keyframed tween).
44
+ const POSITION_CHANNELS = [
45
+ "x",
46
+ "y",
47
+ "xPercent",
48
+ "yPercent",
49
+ "left",
50
+ "top",
51
+ // GSAP normalizes translateX/Y to x/y at play time, but readTween reads the
52
+ // AUTHORED shape — include them so a hand-authored translateX/Y position tween
53
+ // still counts as a live position tween.
54
+ "translateX",
55
+ "translateY",
56
+ ];
57
+
41
58
  // ── Property-group tween resolution ───────────────────────────────────────
42
59
 
43
60
  /**
@@ -185,7 +202,7 @@ export async function tryGsapDragIntercept(
185
202
  // `tl.set("#el",{x,y})`, not a keyframe conversion: re-nudge an existing set in
186
203
  // place (idempotent), else add a new one. This also covers the stale-cache
187
204
  // phantom — committing a set is correct because the element genuinely has no live motion.
188
- const hasNonHold = hasNonHoldTweenForElement(iframe, selector);
205
+ const hasNonHold = hasNonHoldTweenForElement(iframe, selector, undefined, POSITION_CHANNELS);
189
206
  // A KEYFRAMED position tween — even one that's currently a flat constant ("hold",
190
207
  // e.g. 0% and 100% identical) — is still an animation the user is building, so a
191
208
  // drag must add/update a keyframe, NOT fall back to a static `set`. Without this,
@@ -193,7 +210,9 @@ export async function tryGsapDragIntercept(
193
210
  // fights the tween (the "drag didn't create a keyframe / didn't persist" bug). The
194
211
  // static path is only for elements with NO keyframed position tween (truly static,
195
212
  // or just a leftover position-hold `set`).
196
- const hasKeyframedPosTween = !!posAnim?.keyframes;
213
+ // A zero-duration keyframed tween is a static HOLD, not a live animation —
214
+ // treat it as static so the drag heals it instead of feeding it more keyframes.
215
+ const hasKeyframedPosTween = !!posAnim?.keyframes && resolveTweenDuration(posAnim) > 0;
197
216
  if (!hasNonHold && !hasKeyframedPosTween) {
198
217
  const existingSet =
199
218
  posAnim && posAnim.method === "set" && posAnim.targetSelector === selector
@@ -362,6 +381,7 @@ export async function tryGsapResizeIntercept(
362
381
  const outsideRange = ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01); // Convert flat tweens to keyframes only for in-range resizes.
363
382
  // Outside-range uses the extend path which handles everything atomically.
364
383
  if (!outsideRange) {
384
+ // fallow-ignore-next-line code-duplication
365
385
  if (anim.hasUnresolvedKeyframes || anim.hasUnresolvedSelector) {
366
386
  const newId = await materializeIfDynamic(anim, iframe, commitMutation, selection);
367
387
  if (newId) anim = { ...anim, id: newId };
@@ -530,6 +550,7 @@ export async function tryGsapRotationIntercept(
530
550
  return true;
531
551
  }
532
552
 
553
+ // fallow-ignore-next-line code-duplication
533
554
  if (anim.hasUnresolvedKeyframes || anim.hasUnresolvedSelector) {
534
555
  const newId = await materializeIfDynamic(anim, iframe, commitMutation, selection);
535
556
  if (newId) anim = { ...anim, id: newId };
@@ -176,6 +176,40 @@ describe("hasNonHoldTweenForElement — strict live-tween existence (drag stale-
176
176
  });
177
177
  });
178
178
 
179
+ describe("hasNonHoldTweenForElement — channel-scoped (position vs sibling rotation)", () => {
180
+ const el = { id: "puck-c" };
181
+ const rotationTween = {
182
+ targets: () => [el],
183
+ vars: { keyframes: { "0%": { rotation: 0 }, "100%": { rotation: 90 } }, duration: 1 },
184
+ duration: () => 1,
185
+ startTime: () => 0,
186
+ };
187
+ const positionTween = {
188
+ targets: () => [el],
189
+ vars: { keyframes: { "0%": { x: 0, y: 0 }, "100%": { x: 100, y: 40 } }, duration: 1 },
190
+ duration: () => 1,
191
+ startTime: () => 0,
192
+ };
193
+
194
+ it("unfiltered: a rotation-only keyframed tween counts as non-hold", () => {
195
+ expect(hasNonHoldTweenForElement(fakeIframe(el, [rotationTween]), "#puck-c")).toBe(true);
196
+ });
197
+
198
+ it("position-scoped: a sibling rotation-only tween is NOT a live position tween", () => {
199
+ // The primary fix: a keyframed rotation must not route a static position hold
200
+ // into the keyframe branch. Scoping to position channels returns false here.
201
+ expect(
202
+ hasNonHoldTweenForElement(fakeIframe(el, [rotationTween]), "#puck-c", undefined, ["x", "y"]),
203
+ ).toBe(false);
204
+ });
205
+
206
+ it("position-scoped: a real keyframed position tween still counts", () => {
207
+ expect(
208
+ hasNonHoldTweenForElement(fakeIframe(el, [positionTween]), "#puck-c", undefined, ["x", "y"]),
209
+ ).toBe(true);
210
+ });
211
+ });
212
+
179
213
  describe("arcPathFromMotionPathValue", () => {
180
214
  it("builds arc config from object form { path, curviness }", () => {
181
215
  const arc = arcPathFromMotionPathValue({
@@ -237,6 +237,7 @@ export function resolveRuntimeTween(
237
237
  kind: "keyframe" | "set",
238
238
  compositionId?: string,
239
239
  channels?: string[],
240
+ // fallow-ignore-next-line code-duplication
240
241
  ): ResolvedRuntimeTween | null {
241
242
  const timelines = timelinesOf(iframe);
242
243
  if (!timelines) return null;
@@ -261,6 +262,7 @@ export function resolveRuntimeTween(
261
262
  const wantChannels = channels && channels.length > 0 ? channels : null;
262
263
 
263
264
  let first: ResolvedRuntimeTween | null = null;
265
+ // fallow-ignore-next-line code-duplication
264
266
  let channelMatch: ResolvedRuntimeTween | null = null;
265
267
  for (const tlId of tlIds) {
266
268
  const timeline = timelines[tlId];
@@ -316,6 +318,7 @@ export function readRuntimeKeyframes(
316
318
  selector: string,
317
319
  compositionId?: string,
318
320
  requireChannels?: string[],
321
+ // fallow-ignore-next-line code-duplication
319
322
  ): ReadTween | null {
320
323
  const timelines = timelinesOf(iframe);
321
324
  if (!timelines) return null;
@@ -344,11 +347,13 @@ export function readRuntimeKeyframes(
344
347
  // (e.g. two non-overlapping gesture recordings → two separate `to()`s). The
345
348
  // overlay must draw the segment under the PLAYHEAD, not blindly the first one
346
349
  // — otherwise recording a second gesture leaves the path stuck on the first.
350
+ // fallow-ignore-next-line code-duplication
347
351
  let firstRead: ReadTween | null = null;
348
352
  for (const tlId of tlIds) {
349
353
  const timeline = timelines[tlId];
350
354
  if (!timeline?.getChildren) continue;
351
355
  const now = typeof timeline.time === "function" ? timeline.time() : null;
356
+ // fallow-ignore-next-line code-duplication
352
357
  for (const tween of timeline.getChildren(true)) {
353
358
  if (!tween.vars || !matchesElement(tween, targetEl)) continue;
354
359
  const dur = typeof tween.duration === "function" ? tween.duration() : 0;
@@ -377,12 +382,17 @@ export function readRuntimeKeyframes(
377
382
  * The drag's stale-parse guard needs this exact distinction — after a delete-all
378
383
  * only a hold may remain, and resurrecting the deleted tween from the stale parse
379
384
  * must be avoided.
385
+ * When `channels` is provided, only tweens carrying one of those keyframe
386
+ * properties count as non-hold motion (e.g. position channels), so a sibling
387
+ * rotation/scale tween doesn't make a static position hold enter the keyframe
388
+ * branch.
380
389
  */
381
390
  // fallow-ignore-next-line complexity
382
391
  export function hasNonHoldTweenForElement(
383
392
  iframe: HTMLIFrameElement | null,
384
393
  selector: string,
385
394
  compositionId?: string,
395
+ channels?: string[],
386
396
  ): boolean {
387
397
  const timelines = timelinesOf(iframe);
388
398
  if (!timelines) return false;
@@ -401,11 +411,13 @@ export function hasNonHoldTweenForElement(
401
411
  }
402
412
  if (!targetEl) return false;
403
413
 
414
+ // fallow-ignore-next-line code-duplication
404
415
  for (const tween of timeline.getChildren(true)) {
405
416
  if (!tween.vars || !matchesElement(tween, targetEl)) continue;
406
417
  const dur = typeof tween.duration === "function" ? tween.duration() : 0;
407
418
  if (isZeroDurationSet(dur)) continue; // skip hold/set tweens (see isZeroDurationSet)
408
- if (readTween(tween.vars)) return true;
419
+ const read = readTween(tween.vars);
420
+ if (read && (!channels || readCarriesChannel(read, channels))) return true;
409
421
  }
410
422
  return false;
411
423
  }
@@ -29,6 +29,7 @@ export async function commitWholePropertyOffset(
29
29
  callbacks: GsapDragCommitCallbacks,
30
30
  label: string,
31
31
  ): Promise<void> {
32
+ // fallow-ignore-next-line code-duplication
32
33
  let effectiveAnim = anim;
33
34
  if (anim.keyframes) {
34
35
  const newId = await materializeIfDynamic(anim, iframe, callbacks.commitMutation, selection);
@@ -41,19 +41,30 @@ export type PatchTarget = NonNullable<ReturnType<typeof buildPatchTarget>>;
41
41
  // The runtime re-reads data-start/data-duration from the DOM on each sync tick
42
42
  // (packages/core/src/runtime/init.ts:1324-1368), so attribute mutations here are
43
43
  // picked up automatically on the next frame without a rebind call.
44
- export function patchIframeDomTiming(
44
+ export function findTimelineElementInIframe(
45
45
  iframe: HTMLIFrameElement | null,
46
46
  element: TimelineElement,
47
- attrs: Array<[string, string]>,
48
- ): void {
47
+ ): Element | null {
49
48
  try {
50
49
  const doc = iframe?.contentDocument;
51
- if (!doc) return;
52
- const el = element.domId
50
+ if (!doc) return null;
51
+ return element.domId
53
52
  ? doc.getElementById(element.domId)
54
53
  : element.selector
55
54
  ? (doc.querySelectorAll(element.selector)[element.selectorIndex ?? 0] ?? null)
56
55
  : null;
56
+ } catch {
57
+ return null;
58
+ }
59
+ }
60
+
61
+ export function patchIframeDomTiming(
62
+ iframe: HTMLIFrameElement | null,
63
+ element: TimelineElement,
64
+ attrs: Array<[string, string]>,
65
+ ): void {
66
+ try {
67
+ const el = findTimelineElementInIframe(iframe, element);
57
68
  if (!el) return;
58
69
  for (const [name, value] of attrs) el.setAttribute(name, value);
59
70
  } catch {
@@ -61,6 +72,7 @@ export function patchIframeDomTiming(
61
72
  }
62
73
  }
63
74
 
75
+ // fallow-ignore-next-line complexity
64
76
  export function resolveResizePlaybackStart(
65
77
  original: string,
66
78
  target: PatchTarget,