@hyperframes/studio 0.7.54 → 0.7.56

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 (262) hide show
  1. package/dist/assets/{index-uBY329wb.js → index-BWnOxAiH.js} +1 -1
  2. package/dist/assets/{index-CMHYjEZ5.js → index-C4csZims.js} +1 -1
  3. package/dist/assets/index-CmVCjZjp.js +423 -0
  4. package/dist/assets/index-D-GyYi2d.css +1 -0
  5. package/dist/{chunk-SOTCF4DF.js → chunk-5QSIMBEJ.js} +2 -1
  6. package/dist/chunk-5QSIMBEJ.js.map +1 -0
  7. package/dist/{domEditingLayers-2ECJK24D.js → domEditingLayers-6LQGKPOI.js} +2 -2
  8. package/dist/index.d.ts +146 -144
  9. package/dist/index.html +2 -2
  10. package/dist/index.js +40582 -37803
  11. package/dist/index.js.map +1 -1
  12. package/package.json +7 -7
  13. package/src/App.tsx +106 -96
  14. package/src/components/EditorShell.tsx +253 -0
  15. package/src/components/StudioGlobalDragOverlay.tsx +1 -3
  16. package/src/components/StudioLeftSidebar.tsx +13 -3
  17. package/src/components/StudioRightPanel.tsx +8 -3
  18. package/src/components/TimelineToolbar.test.tsx +2 -2
  19. package/src/components/TimelineToolbar.tsx +179 -121
  20. package/src/components/editor/CanvasContextMenu.test.tsx +115 -0
  21. package/src/components/editor/CanvasContextMenu.tsx +198 -0
  22. package/src/components/editor/DomEditCropHandles.test.tsx +74 -3
  23. package/src/components/editor/DomEditCropHandles.tsx +64 -29
  24. package/src/components/editor/DomEditOverlay.test.ts +245 -279
  25. package/src/components/editor/DomEditOverlay.tsx +158 -126
  26. package/src/components/editor/DomEditOverlayHover.test.tsx +90 -0
  27. package/src/components/editor/DomEditRotateHandle.tsx +38 -18
  28. package/src/components/editor/DomEditSelectionChrome.test.tsx +80 -0
  29. package/src/components/editor/DomEditSelectionChrome.tsx +257 -0
  30. package/src/components/editor/LayersPanel.test.ts +66 -2
  31. package/src/components/editor/LayersPanel.tsx +43 -1
  32. package/src/components/editor/OffCanvasIndicators.test.tsx +34 -0
  33. package/src/components/editor/OffCanvasIndicators.tsx +49 -10
  34. package/src/components/editor/SnapGuideOverlay.tsx +28 -23
  35. package/src/components/editor/SnapToolbar.test.tsx +0 -1
  36. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +214 -0
  37. package/src/components/editor/anchoredResizeReleaseShift.test.ts +208 -0
  38. package/src/components/editor/canvasContextMenuZOrder.test.ts +435 -0
  39. package/src/components/editor/canvasContextMenuZOrder.ts +352 -0
  40. package/src/components/editor/domEditNudge.test.ts +80 -0
  41. package/src/components/editor/domEditNudge.ts +44 -0
  42. package/src/components/editor/domEditOverlayGeometry.test.ts +167 -1
  43. package/src/components/editor/domEditOverlayGeometry.ts +280 -20
  44. package/src/components/editor/domEditOverlayGestures.ts +44 -41
  45. package/src/components/editor/domEditOverlayStartGesture.ts +62 -2
  46. package/src/components/editor/domEditResizeLocal.test.ts +131 -0
  47. package/src/components/editor/domEditResizeLocal.ts +125 -0
  48. package/src/components/editor/domEditingDom.ts +1 -1
  49. package/src/components/editor/manualEditsDomPatches.test.ts +47 -0
  50. package/src/components/editor/manualOffsetDrag.ts +55 -12
  51. package/src/components/editor/offCanvasIndicatorGeometry.ts +31 -10
  52. package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +25 -1
  53. package/src/components/editor/resizeDraft.ts +108 -0
  54. package/src/components/editor/snapEngine.test.ts +12 -78
  55. package/src/components/editor/snapEngine.ts +13 -53
  56. package/src/components/editor/useDomEditNudge.test.tsx +228 -0
  57. package/src/components/editor/useDomEditNudge.ts +254 -0
  58. package/src/components/editor/useDomEditOverlayGestures.ts +115 -116
  59. package/src/components/editor/useDomEditOverlayRects.ts +9 -2
  60. package/src/components/nle/AssetPreviewOverlay.tsx +147 -0
  61. package/src/components/nle/NLEContext.test.ts +144 -0
  62. package/src/components/nle/NLEContext.tsx +328 -0
  63. package/src/components/nle/NLEPreview.tsx +1 -1
  64. package/src/components/nle/PreviewOverlays.tsx +240 -0
  65. package/src/components/nle/PreviewPane.tsx +163 -0
  66. package/src/components/nle/TimelinePane.test.ts +60 -0
  67. package/src/components/nle/TimelinePane.tsx +293 -0
  68. package/src/components/nle/TimelineResizeDivider.tsx +8 -2
  69. package/src/components/nle/useCompositionStack.test.tsx +44 -0
  70. package/src/components/nle/useCompositionStack.ts +17 -9
  71. package/src/components/nle/useTimelineEditCallbacks.ts +217 -0
  72. package/src/components/sidebar/AssetCard.tsx +326 -0
  73. package/src/components/sidebar/AssetContextMenu.tsx +15 -30
  74. package/src/components/sidebar/AssetsTab.test.ts +96 -0
  75. package/src/components/sidebar/AssetsTab.tsx +71 -206
  76. package/src/components/sidebar/AudioRow.tsx +39 -2
  77. package/src/components/sidebar/BlocksTab.tsx +8 -1
  78. package/src/components/sidebar/LeftSidebar.tsx +4 -1
  79. package/src/components/sidebar/assetHelpers.ts +29 -0
  80. package/src/contexts/DomEditContext.tsx +9 -0
  81. package/src/contexts/StudioContext.tsx +9 -8
  82. package/src/contexts/TimelineEditContext.tsx +3 -7
  83. package/src/hooks/deleteSelectedKeyframes.ts +35 -0
  84. package/src/hooks/domEditCommitTypes.ts +12 -1
  85. package/src/hooks/domSelectionTestHarness.ts +14 -0
  86. package/src/hooks/gestureTransaction.test.ts +311 -0
  87. package/src/hooks/gestureTransaction.ts +199 -0
  88. package/src/hooks/gsapDragCommit.test.ts +141 -41
  89. package/src/hooks/gsapDragCommit.ts +64 -74
  90. package/src/hooks/gsapDragStaticSetHelpers.ts +9 -21
  91. package/src/hooks/gsapResizeIntercept.test.ts +69 -0
  92. package/src/hooks/gsapResizeIntercept.ts +38 -10
  93. package/src/hooks/gsapRuntimeBridge.test.ts +49 -13
  94. package/src/hooks/gsapRuntimeBridge.ts +4 -4
  95. package/src/hooks/gsapScriptCommitTypes.ts +16 -5
  96. package/src/hooks/gsapShared.test.ts +19 -1
  97. package/src/hooks/gsapShared.ts +14 -0
  98. package/src/hooks/patchDocumentRootDuration.test.ts +71 -0
  99. package/src/hooks/timelineEditingGsap.ts +14 -0
  100. package/src/hooks/timelineEditingHelpers.test.ts +34 -1
  101. package/src/hooks/timelineEditingHelpers.ts +22 -48
  102. package/src/hooks/timelineMoveAdapter.test.ts +77 -0
  103. package/src/hooks/timelineMoveAdapter.ts +37 -0
  104. package/src/hooks/useAnimatedPropertyCommit.test.tsx +36 -8
  105. package/src/hooks/useAnimatedPropertyCommit.ts +31 -26
  106. package/src/hooks/useAppHotkeys.ts +16 -25
  107. package/src/hooks/useBlockHandlers.ts +55 -27
  108. package/src/hooks/useContextMenuDismiss.ts +31 -7
  109. package/src/hooks/useDomEditCommits.test.tsx +154 -1
  110. package/src/hooks/useDomEditCommits.ts +100 -3
  111. package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
  112. package/src/hooks/useDomEditSession.ts +2 -2
  113. package/src/hooks/useDomEditTextCommits.test.tsx +135 -0
  114. package/src/hooks/useDomEditTextCommits.ts +8 -3
  115. package/src/hooks/useDomEditWiring.ts +7 -9
  116. package/src/hooks/useDomGeometryCommits.ts +17 -2
  117. package/src/hooks/useDomSelection.test.ts +2 -36
  118. package/src/hooks/useDomSelection.ts +81 -51
  119. package/src/hooks/useDomSelectionSelectionGuards.test.ts +276 -0
  120. package/src/hooks/useElementLifecycleOps.test.tsx +258 -0
  121. package/src/hooks/useElementLifecycleOps.ts +40 -71
  122. package/src/hooks/useEnableKeyframes.test.ts +142 -1
  123. package/src/hooks/useEnableKeyframes.ts +73 -29
  124. package/src/hooks/useGestureCommit.test.tsx +130 -0
  125. package/src/hooks/useGestureCommit.ts +32 -8
  126. package/src/hooks/useGroupCommits.ts +7 -28
  127. package/src/hooks/useGsapAwareEditing.test.tsx +239 -0
  128. package/src/hooks/useGsapAwareEditing.ts +115 -20
  129. package/src/hooks/useGsapKeyframeOps.test.tsx +59 -0
  130. package/src/hooks/useGsapKeyframeOps.ts +38 -10
  131. package/src/hooks/useGsapScriptCommits.test.tsx +40 -1
  132. package/src/hooks/useGsapScriptCommits.ts +134 -50
  133. package/src/hooks/useGsapSelectionHandlers.ts +49 -16
  134. package/src/hooks/useMusicBeatAnalysis.ts +72 -36
  135. package/src/hooks/usePersistentEditHistory.test.ts +33 -0
  136. package/src/hooks/usePersistentEditHistory.ts +92 -58
  137. package/src/hooks/usePreviewPersistence.ts +25 -7
  138. package/src/hooks/useRazorSplit.history.test.tsx +303 -0
  139. package/src/hooks/useRazorSplit.test.ts +269 -0
  140. package/src/hooks/useRazorSplit.testHelpers.ts +64 -0
  141. package/src/hooks/useRazorSplit.ts +107 -36
  142. package/src/hooks/useRenderClipContent.ts +24 -6
  143. package/src/hooks/useStudioContextValue.ts +15 -5
  144. package/src/hooks/useStudioUrlState.ts +2 -4
  145. package/src/hooks/useTimelineEditing.test.tsx +20 -29
  146. package/src/hooks/useTimelineEditingTypes.ts +6 -0
  147. package/src/hooks/useTimelineGroupEditing.ts +18 -4
  148. package/src/index.ts +1 -1
  149. package/src/player/components/ImageThumbnail.test.tsx +173 -0
  150. package/src/player/components/ImageThumbnail.tsx +160 -0
  151. package/src/player/components/PlayheadIndicator.tsx +50 -5
  152. package/src/player/components/Timeline.test.ts +102 -19
  153. package/src/player/components/Timeline.tsx +194 -194
  154. package/src/player/components/TimelineCanvas.tsx +207 -521
  155. package/src/player/components/TimelineClip.tsx +4 -1
  156. package/src/player/components/TimelineClipDiamonds.tsx +9 -40
  157. package/src/player/components/TimelineEmptyState.tsx +3 -1
  158. package/src/player/components/TimelineLanes.tsx +487 -0
  159. package/src/player/components/TimelineOverlays.tsx +122 -0
  160. package/src/player/components/VideoThumbnail.test.tsx +152 -0
  161. package/src/player/components/VideoThumbnail.tsx +28 -7
  162. package/src/player/components/thumbnailUtils.test.ts +127 -0
  163. package/src/player/components/thumbnailUtils.ts +54 -0
  164. package/src/player/components/timelineCallbacks.ts +18 -18
  165. package/src/player/components/timelineClipChildren.tsx +39 -0
  166. package/src/player/components/timelineClipDragCommit.test.ts +943 -0
  167. package/src/player/components/timelineClipDragCommit.ts +401 -0
  168. package/src/player/components/timelineClipDragPreview.test.ts +144 -0
  169. package/src/player/components/timelineClipDragPreview.ts +323 -38
  170. package/src/player/components/timelineClipDragTypes.ts +65 -0
  171. package/src/player/components/timelineCollision.test.ts +477 -0
  172. package/src/player/components/timelineCollision.ts +263 -0
  173. package/src/player/components/timelineDragDrop.ts +69 -60
  174. package/src/player/components/timelineEditCapabilities.ts +52 -0
  175. package/src/player/components/timelineEditing.test.ts +1 -1
  176. package/src/player/components/timelineEditing.ts +76 -59
  177. package/src/player/components/timelineGroupEditing.test.ts +126 -0
  178. package/src/player/components/timelineGroupEditing.ts +133 -0
  179. package/src/player/components/timelineGroupResizeCommit.ts +54 -0
  180. package/src/player/components/timelineLayout.test.ts +95 -23
  181. package/src/player/components/timelineLayout.ts +120 -61
  182. package/src/player/components/timelineMarquee.test.ts +197 -0
  183. package/src/player/components/timelineMarquee.ts +112 -0
  184. package/src/player/components/timelineMultiDragPreview.test.ts +127 -0
  185. package/src/player/components/timelineMultiDragPreview.ts +106 -0
  186. package/src/player/components/timelineOptimisticRevision.ts +42 -0
  187. package/src/player/components/timelineSnapping.test.ts +134 -0
  188. package/src/player/components/timelineSnapping.ts +110 -0
  189. package/src/player/components/timelineStackingSync.test.ts +244 -0
  190. package/src/player/components/timelineStackingSync.ts +331 -0
  191. package/src/player/components/timelineTheme.ts +6 -1
  192. package/src/player/components/timelineZones.test.ts +425 -0
  193. package/src/player/components/timelineZones.ts +198 -0
  194. package/src/player/components/timelineZoom.test.ts +67 -0
  195. package/src/player/components/timelineZoom.ts +51 -0
  196. package/src/player/components/useResolvedTimelineEditCallbacks.ts +25 -3
  197. package/src/player/components/useTimelineClipDrag.resize.test.tsx +241 -0
  198. package/src/player/components/useTimelineClipDrag.ts +380 -386
  199. package/src/player/components/useTimelineEditPinning.ts +121 -0
  200. package/src/player/components/useTimelineGeometry.ts +129 -0
  201. package/src/player/components/useTimelinePlayhead.ts +4 -16
  202. package/src/player/components/useTimelineRangeSelection.ts +341 -50
  203. package/src/player/components/useTimelineStackingSync.test.tsx +67 -0
  204. package/src/player/components/useTimelineStackingSync.ts +83 -0
  205. package/src/player/hooks/useExpandedTimelineElements.ts +0 -3
  206. package/src/player/hooks/usePlaybackKeyboard.ts +5 -0
  207. package/src/player/hooks/useTimelinePlayer.ts +48 -53
  208. package/src/player/hooks/useTimelinePlayerLoop.ts +85 -0
  209. package/src/player/hooks/useTimelineSyncCallbacks.test.ts +219 -0
  210. package/src/player/hooks/useTimelineSyncCallbacks.ts +104 -4
  211. package/src/player/index.ts +3 -2
  212. package/src/player/lib/time.test.ts +19 -19
  213. package/src/player/lib/time.ts +6 -2
  214. package/src/player/lib/timelineDOM.test.ts +25 -98
  215. package/src/player/lib/timelineDOM.ts +12 -81
  216. package/src/player/lib/timelineElementHelpers.ts +54 -5
  217. package/src/player/lib/timelineIframeHelpers.ts +2 -0
  218. package/src/player/store/playerStore.ts +62 -3
  219. package/src/styles/studio.css +27 -0
  220. package/src/utils/assetClickBehavior.test.ts +104 -0
  221. package/src/utils/assetClickBehavior.ts +75 -0
  222. package/src/utils/assetPreviewStore.ts +33 -0
  223. package/src/utils/blockInstaller.ts +10 -2
  224. package/src/utils/canvasNudgeGate.test.ts +31 -0
  225. package/src/utils/canvasNudgeGate.ts +32 -0
  226. package/src/utils/editHistory.test.ts +35 -0
  227. package/src/utils/gsapSoftReload.test.ts +119 -1
  228. package/src/utils/gsapSoftReload.ts +153 -0
  229. package/src/utils/mediaTypes.ts +3 -2
  230. package/src/utils/resizeDebug.ts +55 -0
  231. package/src/utils/rootDuration.test.ts +90 -1
  232. package/src/utils/rootDuration.ts +76 -13
  233. package/src/utils/sdkCutover.ts +4 -0
  234. package/src/utils/studioFileHistory.ts +37 -7
  235. package/src/utils/studioHelpers.ts +62 -0
  236. package/src/utils/studioPreviewHelpers.test.ts +65 -8
  237. package/src/utils/studioPreviewHelpers.ts +10 -0
  238. package/src/utils/studioTelemetry.ts +4 -1
  239. package/src/utils/studioUiPreferences.test.ts +38 -0
  240. package/src/utils/studioUiPreferences.ts +27 -0
  241. package/src/utils/studioUrlState.test.ts +0 -1
  242. package/src/utils/timelineAssetDrop.ts +62 -0
  243. package/src/utils/timelineDiscovery.ts +0 -17
  244. package/src/utils/timelineInspector.test.ts +121 -0
  245. package/src/utils/timelineInspector.ts +32 -1
  246. package/dist/assets/index-Dq7FEg0K.css +0 -1
  247. package/dist/assets/index-pRhCpGPz.js +0 -423
  248. package/dist/chunk-SOTCF4DF.js.map +0 -1
  249. package/src/components/StudioPreviewArea.tsx +0 -500
  250. package/src/components/nle/NLELayout.test.ts +0 -12
  251. package/src/components/nle/NLELayout.tsx +0 -591
  252. package/src/player/components/TimelineLayerGutter.tsx +0 -61
  253. package/src/player/components/TimelineSelectionOverlays.tsx +0 -55
  254. package/src/player/components/timelineMarqueeSelection.test.ts +0 -87
  255. package/src/player/components/timelineSnapTargets.test.ts +0 -144
  256. package/src/player/components/timelineSnapTargets.ts +0 -164
  257. package/src/player/components/useTimelineClipDrag.test.tsx +0 -506
  258. package/src/player/components/useTimelineClipGroupDrag.ts +0 -417
  259. package/src/player/components/useTimelineMarqueeSelection.test.tsx +0 -231
  260. package/src/player/components/useTimelineMarqueeSelection.ts +0 -276
  261. package/src/utils/timelineDiscovery.test.ts +0 -90
  262. /package/dist/{domEditingLayers-2ECJK24D.js.map → domEditingLayers-6LQGKPOI.js.map} +0 -0
@@ -10,8 +10,10 @@ import { smoothGestureKeyframes } from "../utils/gestureSmoother";
10
10
  import { usePlayerStore } from "../player";
11
11
  import type { DomEditSelection } from "../components/editor/domEditing";
12
12
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
13
+ import type { CommitMutationOptions } from "./gsapScriptCommitTypes";
13
14
  import { roundTo3 } from "../utils/rounding";
14
15
  import { classifyPropertyGroup } from "@hyperframes/core/gsap-parser";
16
+ import { isInstantHold } from "./gsapShared";
15
17
 
16
18
  type RecordedKeyframe = {
17
19
  percentage: number;
@@ -68,10 +70,18 @@ interface GestureSessionRef {
68
70
  selectedGsapAnimations?: GsapAnimation[];
69
71
  commitMutation?: (
70
72
  mutation: Record<string, unknown>,
71
- options: { label: string; softReload?: boolean },
73
+ options: CommitMutationOptions,
72
74
  ) => Promise<void>;
73
75
  }
74
76
 
77
+ /** Only the LAST group in a per-group commit loop reloads the preview; the
78
+ * earlier ones skip it, so a multi-group gesture recording is one reload. */
79
+ function reloadOnlyLast(index: number, count: number): Partial<CommitMutationOptions> {
80
+ return index === count - 1 ? { softReload: true } : { skipReload: true };
81
+ }
82
+
83
+ let gestureRecordingCommitCounter = 0;
84
+
75
85
  interface UseGestureCommitParams {
76
86
  domEditSessionRef: React.MutableRefObject<GestureSessionRef>;
77
87
  previewIframeRef: React.RefObject<HTMLIFrameElement | null>;
@@ -112,6 +122,10 @@ export function useGestureCommit({
112
122
  return;
113
123
  }
114
124
  commitInFlightRef.current = true;
125
+ const coalesceOptions = {
126
+ coalesceKey: `gesture-recording:${++gestureRecordingCommitCounter}`,
127
+ coalesceMs: Number.POSITIVE_INFINITY,
128
+ };
115
129
  gestureStateRef.current = "idle";
116
130
  isGestureRecordingRef.current = false;
117
131
  const frozenSamples = gestureRecording.stopRecording();
@@ -175,9 +189,9 @@ export function useGestureCommit({
175
189
  ? allAnims.find((a) => a.propertyGroup === "position" && a.targetSelector === selector)
176
190
  : undefined;
177
191
  if (existingPositionTween) {
178
- if (existingPositionTween.method === "set") {
179
- // A `set` is a static hold, not a tween to merge into — replace it with
180
- // the recorded motion (which already starts from the set's position).
192
+ if (isInstantHold(existingPositionTween)) {
193
+ // An instant hold is not a tween to merge into — replace it with the
194
+ // recorded motion (which already starts from the held position).
181
195
  await liveSession.commitMutation(
182
196
  {
183
197
  type: "replace-with-keyframes",
@@ -242,7 +256,8 @@ export function useGestureCommit({
242
256
  // Emit one tween per property group so a mixed-prop gesture (e.g.
243
257
  // x/y + opacity) doesn't collapse into an untagged legacy mixed
244
258
  // tween that the position-only drag intercept can't edit.
245
- for (const groupKfs of partitionKeyframesByGroup(keyframes)) {
259
+ const keyframeGroups = partitionKeyframesByGroup(keyframes);
260
+ for (const [index, groupKfs] of keyframeGroups.entries()) {
246
261
  await liveSession.commitMutation(
247
262
  {
248
263
  type: "add-with-keyframes",
@@ -256,14 +271,19 @@ export function useGestureCommit({
256
271
  // not inherit a sigmoid.
257
272
  easeEach: "none",
258
273
  },
259
- { label: "Gesture recording (new range)", softReload: true },
274
+ {
275
+ label: "Gesture recording (new range)",
276
+ ...coalesceOptions,
277
+ ...reloadOnlyLast(index, keyframeGroups.length),
278
+ },
260
279
  );
261
280
  }
262
281
  }
263
282
  }
264
283
  } else {
265
284
  // No existing tween — same per-group split as the new-range branch above.
266
- for (const groupKfs of partitionKeyframesByGroup(keyframes)) {
285
+ const keyframeGroups = partitionKeyframesByGroup(keyframes);
286
+ for (const [index, groupKfs] of keyframeGroups.entries()) {
267
287
  await liveSession.commitMutation(
268
288
  {
269
289
  type: "add-with-keyframes",
@@ -274,7 +294,11 @@ export function useGestureCommit({
274
294
  // Linear fallback (see above) — constant-speed segments stay linear.
275
295
  easeEach: "none",
276
296
  },
277
- { label: "Gesture recording", softReload: true },
297
+ {
298
+ label: "Gesture recording",
299
+ ...coalesceOptions,
300
+ ...reloadOnlyLast(index, keyframeGroups.length),
301
+ },
278
302
  );
279
303
  }
280
304
  }
@@ -1,25 +1,12 @@
1
1
  import { useCallback } from "react";
2
- import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
3
- import { createStudioSaveHttpError } from "../utils/studioSaveDiagnostics";
2
+ import {
3
+ readProjectFileContent,
4
+ saveProjectFilesWithHistory,
5
+ type DomEditCommitBaseParams,
6
+ } from "../utils/studioFileHistory";
4
7
  import { buildDomEditPatchTarget, type DomEditSelection } from "../components/editor/domEditing";
5
- import type { EditHistoryKind } from "../utils/editHistory";
6
8
 
7
- interface RecordEditInput {
8
- label: string;
9
- kind: EditHistoryKind;
10
- coalesceKey?: string;
11
- files: Record<string, { before: string; after: string }>;
12
- }
13
-
14
- interface UseGroupCommitsParams {
15
- activeCompPath: string | null;
16
- showToast: (message: string, tone?: "error" | "info") => void;
17
- writeProjectFile: (path: string, content: string) => Promise<void>;
18
- domEditSaveTimestampRef: React.MutableRefObject<number>;
19
- editHistory: { recordEdit: (entry: RecordEditInput) => Promise<void> };
20
- projectIdRef: React.MutableRefObject<string | null>;
21
- reloadPreview: () => void;
22
- clearDomSelection: () => void;
9
+ interface UseGroupCommitsParams extends DomEditCommitBaseParams {
23
10
  /** Resync the SDK session after a server-side write (the wrapper/unwrap changes
24
11
  * structure the in-memory doc doesn't know about). */
25
12
  forceReloadSdkSession?: () => void;
@@ -81,15 +68,7 @@ async function commitStructuralMutation(
81
68
  | "reloadPreview"
82
69
  >,
83
70
  ): Promise<{ content?: string; groupId?: string }> {
84
- const response = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`);
85
- if (!response.ok) {
86
- throw await createStudioSaveHttpError(response, `Failed to read ${targetPath}`);
87
- }
88
- const data = (await response.json()) as { content?: string };
89
- const originalContent = data.content;
90
- if (typeof originalContent !== "string") {
91
- throw new Error(`Missing file contents for ${targetPath}`);
92
- }
71
+ const originalContent = await readProjectFileContent(pid, targetPath);
93
72
 
94
73
  deps.domEditSaveTimestampRef.current = Date.now();
95
74
  const mutateResponse = await fetch(
@@ -0,0 +1,239 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
6
+ import type { DomEditSelection } from "../components/editor/domEditingTypes";
7
+ import type { DomEditGroupPathOffsetCommit } from "../components/editor/DomEditOverlay";
8
+ import { mountReactHarness } from "./domSelectionTestHarness";
9
+
10
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
11
+
12
+ const mocks = vi.hoisted(() => ({
13
+ resize: vi.fn(),
14
+ drag: vi.fn(),
15
+ readPosition: vi.fn(),
16
+ setPosition: vi.fn(),
17
+ }));
18
+
19
+ vi.mock("./gsapResizeIntercept", () => ({ tryGsapResizeIntercept: mocks.resize }));
20
+ vi.mock("./gsapRuntimeBridge", () => ({
21
+ POSITION_CHANNELS: ["x", "y"],
22
+ tryGsapDragIntercept: mocks.drag,
23
+ tryGsapRotationIntercept: vi.fn(),
24
+ }));
25
+ vi.mock("./gsapPositionDetection", () => ({
26
+ readGsapPositionFromIframe: mocks.readPosition,
27
+ }));
28
+ vi.mock("../utils/elementGsap", () => ({ setElementGsapPosition: mocks.setPosition }));
29
+ vi.mock("./useAnimatedPropertyCommit", () => ({
30
+ useAnimatedPropertyCommit: () => ({
31
+ commitAnimatedProperty: vi.fn(),
32
+ commitAnimatedProperties: vi.fn(),
33
+ }),
34
+ }));
35
+ vi.mock("./useSafeGsapCommitMutation", () => ({
36
+ useGsapSaveFailureTelemetry: () => vi.fn(),
37
+ useSafeGsapCommitMutation: (commit: unknown) => commit,
38
+ }));
39
+
40
+ import { useGsapAwareEditing } from "./useGsapAwareEditing";
41
+
42
+ afterEach(() => {
43
+ vi.clearAllMocks();
44
+ });
45
+
46
+ function mountResizeHandler(animations: GsapAnimation[]) {
47
+ const element = document.createElement("div");
48
+ const selection = { element, id: "clip", selector: "#clip" } as unknown as DomEditSelection;
49
+ const fallback = vi.fn().mockResolvedValue(undefined);
50
+ const commitMutation = vi.fn().mockResolvedValue(undefined);
51
+ let resize:
52
+ | ((
53
+ selection: DomEditSelection,
54
+ size: { width: number; height: number },
55
+ offset?: { x: number; y: number },
56
+ restore?: () => void,
57
+ ) => Promise<void>)
58
+ | null = null;
59
+ function Harness() {
60
+ resize = useGsapAwareEditing({
61
+ domEditSelection: selection,
62
+ selectedGsapAnimations: animations,
63
+ gsapCommitMutation: commitMutation,
64
+ previewIframeRef: { current: null },
65
+ showToast: vi.fn(),
66
+ bumpGsapCache: vi.fn(),
67
+ makeFetchFallback: () => vi.fn().mockResolvedValue(animations),
68
+ trackGsapInteractionFailure: vi.fn(),
69
+ handleDomBoxSizeCommit: fallback,
70
+ addGsapAnimation: vi.fn(),
71
+ convertToKeyframes: vi.fn(),
72
+ setArcPath: vi.fn(),
73
+ updateArcSegment: vi.fn(),
74
+ }).handleGsapAwareBoxSizeCommit;
75
+ return null;
76
+ }
77
+ const root = mountReactHarness(<Harness />);
78
+ return { selection, fallback, commitMutation, resize: resize!, root };
79
+ }
80
+
81
+ describe("useGsapAwareEditing anchored resize", () => {
82
+ it("forwards the anchor offset to the DOM fallback when GSAP does not handle resize", async () => {
83
+ mocks.resize.mockResolvedValue(false);
84
+ const h = mountResizeHandler([]);
85
+ await act(() => h.resize(h.selection, { width: 300, height: 200 }, { x: -50, y: -25 }));
86
+ expect(h.fallback).toHaveBeenCalledWith(
87
+ h.selection,
88
+ { width: 300, height: 200 },
89
+ { x: -50, y: -25 },
90
+ );
91
+ act(() => h.root.unmount());
92
+ });
93
+
94
+ it("persists the anchor exactly once through GSAP position when size route handles resize", async () => {
95
+ mocks.resize.mockResolvedValue(true);
96
+ mocks.drag.mockResolvedValue(true);
97
+ const h = mountResizeHandler([]);
98
+ await act(() => h.resize(h.selection, { width: 300, height: 200 }, { x: -50, y: -25 }));
99
+ expect(h.fallback).not.toHaveBeenCalled();
100
+ expect(mocks.drag).toHaveBeenCalledTimes(1);
101
+ expect(mocks.drag.mock.calls[0]![1]).toEqual({ x: -50, y: -25 });
102
+ act(() => h.root.unmount());
103
+ });
104
+
105
+ it("settles the live GSAP position before resize persistence reaches its first await", async () => {
106
+ let resolveResize!: (handled: boolean) => void;
107
+ const pendingResize = new Promise<boolean>((resolve) => {
108
+ resolveResize = resolve;
109
+ });
110
+ mocks.resize.mockReturnValue(pendingResize);
111
+ mocks.drag.mockResolvedValue(true);
112
+ mocks.readPosition.mockReturnValue({ x: 120.4, y: 80.2 });
113
+ const h = mountResizeHandler([]);
114
+ h.selection.element.setAttribute("data-hf-drag-gsap-base-x", "120.4");
115
+ h.selection.element.setAttribute("data-hf-drag-gsap-base-y", "80.2");
116
+ h.selection.element.setAttribute("data-hf-drag-initial-offset-x", "0");
117
+ h.selection.element.setAttribute("data-hf-drag-initial-offset-y", "0");
118
+
119
+ let commit!: Promise<void>;
120
+ act(() => {
121
+ commit = h.resize(h.selection, { width: 300, height: 200 }, { x: -50.2, y: -25.6 });
122
+ });
123
+
124
+ expect(mocks.setPosition).toHaveBeenCalledWith(h.selection.element, 70, 55);
125
+ expect(mocks.setPosition.mock.invocationCallOrder[0]).toBeLessThan(
126
+ mocks.resize.mock.invocationCallOrder[0]!,
127
+ );
128
+
129
+ resolveResize(true);
130
+ await act(() => commit);
131
+ act(() => h.root.unmount());
132
+ });
133
+
134
+ it("passes a transaction-scoped commit wrapper into the resize path", async () => {
135
+ mocks.resize.mockImplementation(async (selection, _size, _animations, _iframe, commit) => {
136
+ await commit(selection, { type: "resize" }, { label: "Resize", softReload: true });
137
+ return true;
138
+ });
139
+ const h = mountResizeHandler([]);
140
+
141
+ await act(() => h.resize(h.selection, { width: 300, height: 200 }));
142
+
143
+ expect(h.commitMutation).toHaveBeenCalledWith(
144
+ h.selection,
145
+ { type: "resize" },
146
+ expect.objectContaining({
147
+ coalesceKey: expect.stringMatching(/^tx:Resize layer:\d+$/),
148
+ softReload: true,
149
+ }),
150
+ );
151
+ act(() => h.root.unmount());
152
+ });
153
+
154
+ it("folds a group drag's member writes into one undo entry via a shared coalesceKey", async () => {
155
+ const capturedKeys: Array<string | undefined> = [];
156
+ mocks.drag.mockImplementation(
157
+ async (
158
+ selection: DomEditSelection,
159
+ _next: unknown,
160
+ _anims: unknown,
161
+ _iframe: unknown,
162
+ commit: (
163
+ s: DomEditSelection,
164
+ m: unknown,
165
+ o: { coalesceKey?: string; label?: string; softReload?: boolean },
166
+ ) => Promise<void>,
167
+ ) => {
168
+ await commit(selection, { type: "move" }, { label: "Move", softReload: true });
169
+ return true;
170
+ },
171
+ );
172
+ const commitMutation = vi.fn(
173
+ (_s: DomEditSelection, _m: unknown, o: { coalesceKey?: string }) => {
174
+ capturedKeys.push(o.coalesceKey);
175
+ return Promise.resolve();
176
+ },
177
+ );
178
+ let groupCommit!: (updates: DomEditGroupPathOffsetCommit[]) => Promise<void>;
179
+ function Harness() {
180
+ groupCommit = useGsapAwareEditing({
181
+ domEditSelection: null,
182
+ selectedGsapAnimations: [],
183
+ gsapCommitMutation: commitMutation,
184
+ previewIframeRef: { current: null },
185
+ showToast: vi.fn(),
186
+ bumpGsapCache: vi.fn(),
187
+ makeFetchFallback: () => vi.fn().mockResolvedValue([]),
188
+ trackGsapInteractionFailure: vi.fn(),
189
+ handleDomBoxSizeCommit: vi.fn(),
190
+ addGsapAnimation: vi.fn(),
191
+ convertToKeyframes: vi.fn(),
192
+ setArcPath: vi.fn(),
193
+ updateArcSegment: vi.fn(),
194
+ }).handleGsapAwareGroupPathOffsetCommit;
195
+ return null;
196
+ }
197
+ const root = mountReactHarness(<Harness />);
198
+ const updates = [
199
+ {
200
+ selection: { element: document.createElement("div"), id: "a", selector: "#a" },
201
+ next: { x: 10, y: 10 },
202
+ },
203
+ {
204
+ selection: { element: document.createElement("div"), id: "b", selector: "#b" },
205
+ next: { x: 10, y: 10 },
206
+ },
207
+ ] as unknown as DomEditGroupPathOffsetCommit[];
208
+
209
+ await act(() => groupCommit(updates));
210
+
211
+ expect(capturedKeys).toHaveLength(2);
212
+ expect(capturedKeys[0]).toMatch(/^group-drag:\d+$/);
213
+ // Both members share ONE coalesceKey → they fold into a single undo entry.
214
+ expect(capturedKeys[0]).toBe(capturedKeys[1]);
215
+ act(() => root.unmount());
216
+ });
217
+
218
+ it("restores once when resize persistence fails", async () => {
219
+ const error = new Error("resize failed");
220
+ const restore = vi.fn();
221
+ mocks.resize.mockRejectedValue(error);
222
+ const h = mountResizeHandler([]);
223
+
224
+ const commit = h.resize(h.selection, { width: 300, height: 200 }, undefined, restore);
225
+ await expect(commit).rejects.toBe(error);
226
+ expect(restore).toHaveBeenCalledTimes(1);
227
+ act(() => h.root.unmount());
228
+ });
229
+
230
+ it("does not apply the anchor twice when scale route already settles the drop point", async () => {
231
+ mocks.resize.mockResolvedValue(true);
232
+ const scale = { propertyGroup: "scale" } as GsapAnimation;
233
+ const h = mountResizeHandler([scale]);
234
+ await act(() => h.resize(h.selection, { width: 300, height: 200 }, { x: -50, y: -25 }));
235
+ expect(mocks.drag).not.toHaveBeenCalled();
236
+ expect(h.fallback).not.toHaveBeenCalled();
237
+ act(() => h.root.unmount());
238
+ });
239
+ });
@@ -10,15 +10,30 @@
10
10
  import { useCallback } from "react";
11
11
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
12
12
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
13
- import { tryGsapDragIntercept, tryGsapRotationIntercept } from "./gsapRuntimeBridge";
13
+ import {
14
+ POSITION_CHANNELS,
15
+ tryGsapDragIntercept,
16
+ tryGsapRotationIntercept,
17
+ } from "./gsapRuntimeBridge";
14
18
  import { tryGsapResizeIntercept } from "./gsapResizeIntercept";
19
+ import { computeDraggedGsapPosition } from "./draggedGsapPosition";
20
+ import { readGsapPositionFromIframe } from "./gsapPositionDetection";
21
+ import { selectorFromSelection } from "./gsapShared";
15
22
  import { useAnimatedPropertyCommit } from "./useAnimatedPropertyCommit";
16
23
  import {
17
24
  useGsapSaveFailureTelemetry,
18
25
  useSafeGsapCommitMutation,
19
26
  } from "./useSafeGsapCommitMutation";
20
27
  import type { CommitMutation } from "./gsapScriptCommitTypes";
28
+ import { setElementGsapPosition } from "../utils/elementGsap";
29
+ import { logResize, logResizeSettle } from "../utils/resizeDebug";
21
30
  import type { DomEditGroupPathOffsetCommit } from "../components/editor/DomEditOverlay";
31
+ import { runGestureTransaction } from "./gestureTransaction";
32
+ import { hasNonHoldTweenForElement } from "./gsapRuntimeKeyframes";
33
+
34
+ // Distinct coalesceKey per group drag so consecutive group drags don't fold
35
+ // into one another's undo entry (module-local counter, not Date.now()).
36
+ let groupDragCommitCounter = 0;
22
37
 
23
38
  export interface UseGsapAwareEditingParams {
24
39
  domEditSelection: DomEditSelection | null;
@@ -38,6 +53,7 @@ export interface UseGsapAwareEditingParams {
38
53
  handleDomBoxSizeCommit: (
39
54
  selection: DomEditSelection,
40
55
  next: { width: number; height: number },
56
+ offset?: { x: number; y: number },
41
57
  ) => Promise<void>;
42
58
  // GSAP script commit ops (from useGsapScriptCommits)
43
59
  addGsapAnimation: (
@@ -127,7 +143,18 @@ export function useGsapAwareEditing({
127
143
  // composition there's nothing to write (a no-op, exactly like the single-drag path).
128
144
  const handleGsapAwareGroupPathOffsetCommit = useCallback(
129
145
  async (updates: DomEditGroupPathOffsetCommit[]) => {
130
- if (!gsapCommitMutation) return;
146
+ if (!gsapCommitMutation || updates.length === 0) return;
147
+ // A group drag is ONE user action: fold every member's position write into
148
+ // a single undo entry by forcing a shared coalesceKey (infinite window, so
149
+ // it survives the N sequential server round-trips) onto each commit —
150
+ // otherwise each member records its own entry and it takes N presses to undo.
151
+ const coalesceKey = `group-drag:${++groupDragCommitCounter}`;
152
+ const coalescedCommit: typeof gsapCommitMutation = (selection, mutation, options) =>
153
+ gsapCommitMutation(selection, mutation, {
154
+ ...options,
155
+ coalesceKey,
156
+ coalesceMs: Number.POSITIVE_INFINITY,
157
+ });
131
158
  for (const { selection, next } of updates) {
132
159
  try {
133
160
  await tryGsapDragIntercept(
@@ -135,7 +162,7 @@ export function useGsapAwareEditing({
135
162
  next,
136
163
  [],
137
164
  previewIframeRef.current,
138
- gsapCommitMutation,
165
+ coalescedCommit,
139
166
  makeFetchFallback(selection),
140
167
  );
141
168
  } catch (error) {
@@ -148,24 +175,92 @@ export function useGsapAwareEditing({
148
175
  );
149
176
 
150
177
  const handleGsapAwareBoxSizeCommit = useCallback(
151
- async (selection: DomEditSelection, next: { width: number; height: number }) => {
152
- if (gsapCommitMutation) {
153
- try {
154
- const handled = await tryGsapResizeIntercept(
155
- selection,
156
- next,
157
- selectedGsapAnimations,
178
+ async (
179
+ selection: DomEditSelection,
180
+ next: { width: number; height: number },
181
+ offset?: { x: number; y: number },
182
+ restore: () => void = () => undefined,
183
+ ) => {
184
+ const scaleRoute = selectedGsapAnimations.some((anim) => anim.propertyGroup === "scale");
185
+ const selector = selectorFromSelection(selection);
186
+ const hasLivePositionTween = selector
187
+ ? hasNonHoldTweenForElement(
158
188
  previewIframeRef.current,
159
- gsapCommitMutation,
160
- makeFetchFallback(selection),
161
- );
162
- if (handled) return;
163
- } catch (error) {
164
- trackGsapInteractionFailure(error, selection, "resize", "Resize animated layer");
165
- throw error;
166
- }
167
- }
168
- return handleDomBoxSizeCommit(selection, next);
189
+ selector,
190
+ undefined,
191
+ POSITION_CHANNELS,
192
+ )
193
+ : false;
194
+ logResize("commit-route", {
195
+ next,
196
+ offset: offset ?? null,
197
+ scaleRoute,
198
+ animCount: selectedGsapAnimations.length,
199
+ animGroups: selectedGsapAnimations.map((a) => `${a.propertyGroup}:${a.method}`),
200
+ });
201
+ return runGestureTransaction({
202
+ element: selection.element,
203
+ label: "Resize layer",
204
+ settle: () => {
205
+ // Scale resize settles its center-scale residual after the scale commit
206
+ // renders. Width/height can settle its anchored position immediately.
207
+ if (!offset || scaleRoute || !selector) return;
208
+ const gsapPos = readGsapPositionFromIframe(previewIframeRef.current, selector) ?? {
209
+ x: 0,
210
+ y: 0,
211
+ };
212
+ const { newX, newY } = computeDraggedGsapPosition(selection.element, offset, gsapPos);
213
+ logResize("sync-settle", { gsapPos, offset, newX, newY });
214
+ setElementGsapPosition(selection.element, newX, newY);
215
+ },
216
+ persist: async (commit) => {
217
+ if (gsapCommitMutation) {
218
+ const commitMutation = commit(gsapCommitMutation);
219
+ try {
220
+ const handled = await tryGsapResizeIntercept(
221
+ selection,
222
+ next,
223
+ selectedGsapAnimations,
224
+ previewIframeRef.current,
225
+ commitMutation,
226
+ makeFetchFallback(selection),
227
+ );
228
+ if (handled) {
229
+ logResize("intercept-handled", {
230
+ scaleRoute,
231
+ willForwardOffset: !!(offset && !scaleRoute),
232
+ });
233
+ // Scale-route resize persists its residual position internally.
234
+ // Width/height persists the already-settled anchor through drag.
235
+ if (offset && !scaleRoute) {
236
+ await tryGsapDragIntercept(
237
+ selection,
238
+ offset,
239
+ selectedGsapAnimations,
240
+ previewIframeRef.current,
241
+ commitMutation,
242
+ makeFetchFallback(selection),
243
+ );
244
+ }
245
+ logResizeSettle(selection.element, scaleRoute ? "gsap-scale" : "gsap-size");
246
+ return;
247
+ }
248
+ } catch (error) {
249
+ trackGsapInteractionFailure(error, selection, "resize", "Resize animated layer");
250
+ throw error;
251
+ }
252
+ }
253
+ logResize("dom-route", {
254
+ next,
255
+ offset: offset ?? null,
256
+ hadGsapMutation: !!gsapCommitMutation,
257
+ });
258
+ logResizeSettle(selection.element, "dom-route");
259
+ await handleDomBoxSizeCommit(selection, next, offset);
260
+ },
261
+ restore,
262
+ skipPixelAssert: hasLivePositionTween,
263
+ });
169
264
  },
170
265
  [
171
266
  handleDomBoxSizeCommit,
@@ -99,3 +99,62 @@ describe("useGsapKeyframeOps — resizeKeyframedTween", () => {
99
99
  expect(labelArg).toBe("Retime keyframe (resize tween)");
100
100
  });
101
101
  });
102
+
103
+ describe("useGsapKeyframeOps — keyframe transaction options", () => {
104
+ it("soft-reloads a standalone convert when the SDK path is unavailable", async () => {
105
+ const commitMutation = vi.fn<(...args: unknown[]) => Promise<unknown>>(async () => ({
106
+ ok: true,
107
+ }));
108
+ const api = renderKeyframeOps({ commitMutation, trackGsapSaveFailure: vi.fn() });
109
+
110
+ await act(async () => {
111
+ await api.convertToKeyframes(selection, "box-to-0-opacity");
112
+ });
113
+
114
+ expect(commitMutation).toHaveBeenCalledWith(
115
+ selection,
116
+ expect.objectContaining({
117
+ type: "convert-to-keyframes",
118
+ animationId: "box-to-0-opacity",
119
+ }),
120
+ { label: "Convert to keyframes", softReload: true },
121
+ );
122
+ });
123
+
124
+ it("threads one coalesce key through skipped convert reload and terminal batch edit", async () => {
125
+ const commitMutation = vi.fn<(...args: unknown[]) => Promise<unknown>>(async () => ({
126
+ ok: true,
127
+ }));
128
+ const api = renderKeyframeOps({ commitMutation, trackGsapSaveFailure: vi.fn() });
129
+ const coalesceKey = "enable-keyframes:box-to-0-opacity:1";
130
+
131
+ await act(async () => {
132
+ await api.convertToKeyframes(selection, "box-to-0-opacity", undefined, undefined, {
133
+ skipReload: true,
134
+ coalesceKey,
135
+ coalesceMs: Infinity,
136
+ });
137
+ await api.addKeyframeBatch(
138
+ selection,
139
+ "box-to-0-opacity",
140
+ 50,
141
+ { opacity: 0.5 },
142
+ {
143
+ coalesceKey,
144
+ },
145
+ );
146
+ });
147
+
148
+ expect(commitMutation.mock.calls[0]?.[2]).toEqual({
149
+ label: "Convert to keyframes",
150
+ skipReload: true,
151
+ coalesceKey,
152
+ coalesceMs: Infinity,
153
+ });
154
+ expect(commitMutation.mock.calls[1]?.[2]).toEqual({
155
+ label: "Add keyframe at 50%",
156
+ softReload: true,
157
+ coalesceKey,
158
+ });
159
+ });
160
+ });