@hyperframes/studio 0.7.55 → 0.7.57

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 (274) hide show
  1. package/dist/assets/hyperframes-player--Z69cEkE.js +459 -0
  2. package/dist/assets/{index-CPetwHFV.js → index-Bf1x1y8H.js} +1 -1
  3. package/dist/assets/index-DfmYkU44.js +423 -0
  4. package/dist/assets/{index-BXaqaVKt.js → index-KsfE1bUu.js} +1 -1
  5. package/dist/assets/index-_pqzyxB1.css +1 -0
  6. package/dist/{chunk-SOTCF4DF.js → chunk-5QSIMBEJ.js} +2 -1
  7. package/dist/chunk-5QSIMBEJ.js.map +1 -0
  8. package/dist/{domEditingLayers-2ECJK24D.js → domEditingLayers-6LQGKPOI.js} +2 -2
  9. package/dist/index.d.ts +172 -144
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +43613 -40346
  12. package/dist/index.js.map +1 -1
  13. package/package.json +7 -7
  14. package/src/App.tsx +106 -96
  15. package/src/components/EditorShell.tsx +253 -0
  16. package/src/components/StudioGlobalDragOverlay.tsx +1 -3
  17. package/src/components/StudioLeftSidebar.tsx +13 -3
  18. package/src/components/StudioRightPanel.tsx +8 -3
  19. package/src/components/TimelineToolbar.test.tsx +2 -2
  20. package/src/components/TimelineToolbar.tsx +179 -121
  21. package/src/components/editor/CanvasContextMenu.test.tsx +152 -1
  22. package/src/components/editor/CanvasContextMenu.tsx +33 -18
  23. package/src/components/editor/DomEditCropHandles.test.tsx +74 -3
  24. package/src/components/editor/DomEditCropHandles.tsx +64 -29
  25. package/src/components/editor/DomEditOverlay.test.ts +245 -279
  26. package/src/components/editor/DomEditOverlay.tsx +126 -125
  27. package/src/components/editor/DomEditOverlayHover.test.tsx +90 -0
  28. package/src/components/editor/DomEditRotateHandle.tsx +38 -18
  29. package/src/components/editor/DomEditSelectionChrome.test.tsx +80 -0
  30. package/src/components/editor/DomEditSelectionChrome.tsx +257 -0
  31. package/src/components/editor/LayersPanel.test.ts +66 -2
  32. package/src/components/editor/LayersPanel.tsx +53 -2
  33. package/src/components/editor/OffCanvasIndicators.test.tsx +34 -0
  34. package/src/components/editor/OffCanvasIndicators.tsx +49 -10
  35. package/src/components/editor/SnapGuideOverlay.tsx +28 -23
  36. package/src/components/editor/SnapToolbar.test.tsx +0 -1
  37. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +214 -0
  38. package/src/components/editor/anchoredResizeReleaseShift.test.ts +106 -10
  39. package/src/components/editor/canvasContextMenuZOrder.test.ts +186 -2
  40. package/src/components/editor/canvasContextMenuZOrder.ts +144 -22
  41. package/src/components/editor/domEditNudge.test.ts +80 -0
  42. package/src/components/editor/domEditNudge.ts +44 -0
  43. package/src/components/editor/domEditOverlayGeometry.test.ts +167 -1
  44. package/src/components/editor/domEditOverlayGeometry.ts +280 -20
  45. package/src/components/editor/domEditOverlayGestures.ts +44 -41
  46. package/src/components/editor/domEditOverlayStartGesture.ts +62 -2
  47. package/src/components/editor/domEditResizeLocal.test.ts +131 -0
  48. package/src/components/editor/domEditResizeLocal.ts +125 -0
  49. package/src/components/editor/domEditingDom.ts +1 -1
  50. package/src/components/editor/manualEditsDomPatches.test.ts +47 -0
  51. package/src/components/editor/manualOffsetDrag.ts +55 -12
  52. package/src/components/editor/offCanvasIndicatorGeometry.ts +31 -10
  53. package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +25 -1
  54. package/src/components/editor/propertyPanelColorGradingSection.tsx +15 -21
  55. package/src/components/editor/resizeDraft.ts +108 -0
  56. package/src/components/editor/snapEngine.test.ts +12 -78
  57. package/src/components/editor/snapEngine.ts +13 -53
  58. package/src/components/editor/useCanvasContextMenuState.ts +92 -0
  59. package/src/components/editor/useDomEditNudge.test.tsx +228 -0
  60. package/src/components/editor/useDomEditNudge.ts +254 -0
  61. package/src/components/editor/useDomEditOverlayGestures.ts +115 -116
  62. package/src/components/editor/useDomEditOverlayRects.ts +9 -2
  63. package/src/components/editor/useZOrderCrossedFlash.tsx +68 -0
  64. package/src/components/nle/AssetPreviewOverlay.test.tsx +106 -0
  65. package/src/components/nle/AssetPreviewOverlay.tsx +171 -0
  66. package/src/components/nle/NLEContext.test.ts +144 -0
  67. package/src/components/nle/NLEContext.tsx +328 -0
  68. package/src/components/nle/NLEPreview.tsx +1 -1
  69. package/src/components/nle/PreviewOverlays.tsx +260 -0
  70. package/src/components/nle/PreviewPane.tsx +163 -0
  71. package/src/components/nle/TimelinePane.test.ts +60 -0
  72. package/src/components/nle/TimelinePane.tsx +293 -0
  73. package/src/components/nle/TimelineResizeDivider.tsx +8 -2
  74. package/src/components/nle/useCompositionStack.test.tsx +44 -0
  75. package/src/components/nle/useCompositionStack.ts +17 -9
  76. package/src/components/nle/useTimelineEditCallbacks.ts +217 -0
  77. package/src/components/sidebar/AssetCard.test.tsx +106 -0
  78. package/src/components/sidebar/AssetCard.tsx +343 -0
  79. package/src/components/sidebar/AssetContextMenu.tsx +15 -30
  80. package/src/components/sidebar/AssetsTab.test.ts +96 -0
  81. package/src/components/sidebar/AssetsTab.tsx +71 -206
  82. package/src/components/sidebar/AudioRow.tsx +56 -2
  83. package/src/components/sidebar/BlocksTab.tsx +8 -1
  84. package/src/components/sidebar/LeftSidebar.tsx +4 -1
  85. package/src/components/sidebar/assetHelpers.ts +29 -0
  86. package/src/contexts/DomEditContext.tsx +9 -0
  87. package/src/contexts/StudioContext.tsx +9 -8
  88. package/src/contexts/TimelineEditContext.tsx +3 -7
  89. package/src/hooks/deleteSelectedKeyframes.ts +35 -0
  90. package/src/hooks/domEditCommitTypes.ts +23 -1
  91. package/src/hooks/domSelectionTestHarness.ts +14 -0
  92. package/src/hooks/gestureTransaction.test.ts +311 -0
  93. package/src/hooks/gestureTransaction.ts +199 -0
  94. package/src/hooks/gsapDragCommit.test.ts +141 -41
  95. package/src/hooks/gsapDragCommit.ts +64 -74
  96. package/src/hooks/gsapDragStaticSetHelpers.ts +9 -21
  97. package/src/hooks/gsapResizeIntercept.test.ts +69 -0
  98. package/src/hooks/gsapResizeIntercept.ts +38 -10
  99. package/src/hooks/gsapRuntimeBridge.test.ts +49 -13
  100. package/src/hooks/gsapRuntimeBridge.ts +4 -4
  101. package/src/hooks/gsapScriptCommitTypes.ts +16 -5
  102. package/src/hooks/gsapShared.test.ts +19 -1
  103. package/src/hooks/gsapShared.ts +14 -0
  104. package/src/hooks/patchDocumentRootDuration.test.ts +71 -0
  105. package/src/hooks/timelineEditingGsap.ts +14 -0
  106. package/src/hooks/timelineEditingHelpers.test.ts +125 -1
  107. package/src/hooks/timelineEditingHelpers.ts +53 -292
  108. package/src/hooks/timelineMoveAdapter.test.ts +81 -0
  109. package/src/hooks/timelineMoveAdapter.ts +39 -0
  110. package/src/hooks/timelineTimingSync.test.ts +150 -0
  111. package/src/hooks/timelineTimingSync.ts +412 -0
  112. package/src/hooks/useAnimatedPropertyCommit.test.tsx +36 -8
  113. package/src/hooks/useAnimatedPropertyCommit.ts +31 -26
  114. package/src/hooks/useAppHotkeys.ts +16 -25
  115. package/src/hooks/useBlockHandlers.ts +55 -27
  116. package/src/hooks/useCaptionDetection.ts +2 -0
  117. package/src/hooks/useCompositionDimensions.ts +32 -5
  118. package/src/hooks/useContextMenuDismiss.ts +31 -7
  119. package/src/hooks/useDomEditCommits.test.tsx +245 -1
  120. package/src/hooks/useDomEditCommits.ts +162 -3
  121. package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
  122. package/src/hooks/useDomEditSession.ts +2 -2
  123. package/src/hooks/useDomEditTextCommits.test.tsx +135 -0
  124. package/src/hooks/useDomEditTextCommits.ts +8 -3
  125. package/src/hooks/useDomEditWiring.ts +7 -9
  126. package/src/hooks/useDomGeometryCommits.ts +17 -2
  127. package/src/hooks/useDomSelection.test.ts +2 -36
  128. package/src/hooks/useDomSelection.ts +81 -51
  129. package/src/hooks/useDomSelectionSelectionGuards.test.ts +276 -0
  130. package/src/hooks/useElementLifecycleOps.test.tsx +423 -0
  131. package/src/hooks/useElementLifecycleOps.ts +53 -71
  132. package/src/hooks/useElementPicker.ts +7 -8
  133. package/src/hooks/useEnableKeyframes.test.ts +142 -1
  134. package/src/hooks/useEnableKeyframes.ts +73 -29
  135. package/src/hooks/useGestureCommit.test.tsx +130 -0
  136. package/src/hooks/useGestureCommit.ts +32 -8
  137. package/src/hooks/useGroupCommits.ts +7 -28
  138. package/src/hooks/useGsapAwareEditing.test.tsx +239 -0
  139. package/src/hooks/useGsapAwareEditing.ts +115 -20
  140. package/src/hooks/useGsapKeyframeOps.test.tsx +59 -0
  141. package/src/hooks/useGsapKeyframeOps.ts +38 -10
  142. package/src/hooks/useGsapScriptCommits.test.tsx +40 -1
  143. package/src/hooks/useGsapScriptCommits.ts +134 -50
  144. package/src/hooks/useGsapSelectionHandlers.ts +49 -16
  145. package/src/hooks/useMusicBeatAnalysis.ts +72 -36
  146. package/src/hooks/usePersistentEditHistory.test.ts +33 -0
  147. package/src/hooks/usePersistentEditHistory.ts +92 -58
  148. package/src/hooks/usePreviewPersistence.ts +25 -7
  149. package/src/hooks/useRazorSplit.history.test.tsx +148 -18
  150. package/src/hooks/useRazorSplit.test.ts +269 -0
  151. package/src/hooks/useRazorSplit.testHelpers.ts +64 -0
  152. package/src/hooks/useRazorSplit.ts +107 -36
  153. package/src/hooks/useRenderClipContent.ts +24 -6
  154. package/src/hooks/useStudioContextValue.ts +15 -5
  155. package/src/hooks/useStudioUrlState.ts +2 -4
  156. package/src/hooks/useTimelineAssetDropOps.ts +175 -0
  157. package/src/hooks/useTimelineEditing.test.tsx +398 -241
  158. package/src/hooks/useTimelineEditing.ts +181 -265
  159. package/src/hooks/useTimelineEditingTypes.ts +6 -0
  160. package/src/hooks/useTimelineGroupEditing.ts +167 -132
  161. package/src/index.ts +1 -1
  162. package/src/player/components/ImageThumbnail.test.tsx +173 -0
  163. package/src/player/components/ImageThumbnail.tsx +160 -0
  164. package/src/player/components/PlayerControls.tsx +5 -5
  165. package/src/player/components/PlayheadIndicator.tsx +55 -5
  166. package/src/player/components/Timeline.test.ts +183 -23
  167. package/src/player/components/Timeline.tsx +194 -194
  168. package/src/player/components/TimelineCanvas.tsx +218 -523
  169. package/src/player/components/TimelineClip.tsx +4 -1
  170. package/src/player/components/TimelineClipDiamonds.tsx +9 -40
  171. package/src/player/components/TimelineEmptyState.tsx +3 -1
  172. package/src/player/components/TimelineLanes.tsx +487 -0
  173. package/src/player/components/TimelineOverlays.tsx +122 -0
  174. package/src/player/components/TimelineRuler.tsx +58 -48
  175. package/src/player/components/VideoThumbnail.test.tsx +152 -0
  176. package/src/player/components/VideoThumbnail.tsx +28 -7
  177. package/src/player/components/thumbnailUtils.test.ts +127 -0
  178. package/src/player/components/thumbnailUtils.ts +54 -0
  179. package/src/player/components/timelineCallbacks.ts +18 -18
  180. package/src/player/components/timelineClipChildren.tsx +39 -0
  181. package/src/player/components/timelineClipDragCommit.test.ts +1026 -0
  182. package/src/player/components/timelineClipDragCommit.ts +498 -0
  183. package/src/player/components/timelineClipDragPreview.test.ts +144 -0
  184. package/src/player/components/timelineClipDragPreview.ts +323 -38
  185. package/src/player/components/timelineClipDragTypes.ts +65 -0
  186. package/src/player/components/timelineCollision.test.ts +42 -2
  187. package/src/player/components/timelineDragDrop.ts +69 -60
  188. package/src/player/components/timelineEditCapabilities.ts +52 -0
  189. package/src/player/components/timelineEditing.test.ts +1 -1
  190. package/src/player/components/timelineEditing.ts +76 -59
  191. package/src/player/components/timelineGroupEditing.test.ts +126 -0
  192. package/src/player/components/timelineGroupEditing.ts +133 -0
  193. package/src/player/components/timelineGroupResizeCommit.ts +54 -0
  194. package/src/player/components/timelineLayout.test.ts +95 -23
  195. package/src/player/components/timelineLayout.ts +207 -75
  196. package/src/player/components/timelineMarquee.test.ts +197 -0
  197. package/src/player/components/timelineMarquee.ts +112 -0
  198. package/src/player/components/timelineOptimisticRevision.ts +42 -0
  199. package/src/player/components/timelineRevealScroll.test.ts +133 -0
  200. package/src/player/components/timelineRevealScroll.ts +91 -0
  201. package/src/player/components/timelineStackingSync.test.ts +60 -0
  202. package/src/player/components/timelineStackingSync.ts +27 -2
  203. package/src/player/components/timelineTheme.ts +6 -1
  204. package/src/player/components/timelineTrackPersistPipeline.test.ts +168 -0
  205. package/src/player/components/timelineZones.test.ts +122 -310
  206. package/src/player/components/timelineZones.ts +82 -135
  207. package/src/player/components/useResolvedTimelineEditCallbacks.ts +25 -3
  208. package/src/player/components/useTimelineClipDrag.resize.test.tsx +241 -0
  209. package/src/player/components/useTimelineClipDrag.ts +380 -386
  210. package/src/player/components/useTimelineEditPinning.ts +121 -0
  211. package/src/player/components/useTimelineGeometry.ts +129 -0
  212. package/src/player/components/useTimelinePlayhead.ts +4 -16
  213. package/src/player/components/useTimelineRangeSelection.ts +341 -50
  214. package/src/player/components/useTimelineRevealClip.ts +56 -0
  215. package/src/player/components/useTimelineStackingSync.test.tsx +77 -0
  216. package/src/player/components/useTimelineStackingSync.ts +94 -0
  217. package/src/player/hooks/useExpandedTimelineElements.ts +7 -3
  218. package/src/player/hooks/usePlaybackKeyboard.ts +5 -0
  219. package/src/player/hooks/useTimelinePlayer.seek.test.ts +55 -0
  220. package/src/player/hooks/useTimelinePlayer.test.ts +36 -3
  221. package/src/player/hooks/useTimelinePlayer.ts +52 -53
  222. package/src/player/hooks/useTimelinePlayerLoop.ts +85 -0
  223. package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -1
  224. package/src/player/index.ts +5 -2
  225. package/src/player/lib/runtimeProtocol.test.ts +48 -0
  226. package/src/player/lib/runtimeProtocol.ts +65 -0
  227. package/src/player/lib/time.test.ts +19 -19
  228. package/src/player/lib/time.ts +6 -2
  229. package/src/player/lib/timelineDOM.test.ts +25 -98
  230. package/src/player/lib/timelineDOM.ts +21 -81
  231. package/src/player/lib/timelineElementHelpers.ts +73 -5
  232. package/src/player/lib/timelineIframeHelpers.ts +4 -4
  233. package/src/player/store/playerStore.test.ts +38 -13
  234. package/src/player/store/playerStore.ts +99 -3
  235. package/src/styles/studio.css +27 -0
  236. package/src/utils/assetPreviewDismiss.test.ts +27 -0
  237. package/src/utils/assetPreviewDismiss.ts +29 -0
  238. package/src/utils/assetPreviewStore.ts +33 -0
  239. package/src/utils/blockInstaller.ts +10 -2
  240. package/src/utils/editHistory.test.ts +35 -0
  241. package/src/utils/gsapSoftReload.test.ts +119 -1
  242. package/src/utils/gsapSoftReload.ts +153 -0
  243. package/src/utils/mediaTypes.ts +3 -2
  244. package/src/utils/resizeDebug.ts +55 -0
  245. package/src/utils/rootDuration.test.ts +90 -1
  246. package/src/utils/rootDuration.ts +76 -13
  247. package/src/utils/sdkCutover.ts +4 -0
  248. package/src/utils/studioFileHistory.ts +37 -7
  249. package/src/utils/studioHelpers.ts +62 -0
  250. package/src/utils/studioPreviewHelpers.test.ts +65 -8
  251. package/src/utils/studioPreviewHelpers.ts +10 -0
  252. package/src/utils/studioTelemetry.ts +4 -1
  253. package/src/utils/studioUrlState.test.ts +0 -1
  254. package/src/utils/timelineAssetDrop.test.ts +144 -52
  255. package/src/utils/timelineAssetDrop.ts +67 -29
  256. package/src/utils/timelineDiscovery.ts +0 -17
  257. package/dist/assets/hyperframes-player-BBrKzTGd.js +0 -459
  258. package/dist/assets/index-BRwkMj0w.js +0 -423
  259. package/dist/assets/index-Dq7FEg0K.css +0 -1
  260. package/dist/chunk-SOTCF4DF.js.map +0 -1
  261. package/src/components/StudioPreviewArea.tsx +0 -500
  262. package/src/components/nle/NLELayout.test.ts +0 -12
  263. package/src/components/nle/NLELayout.tsx +0 -591
  264. package/src/player/components/TimelineLayerGutter.tsx +0 -61
  265. package/src/player/components/TimelineSelectionOverlays.tsx +0 -55
  266. package/src/player/components/timelineMarqueeSelection.test.ts +0 -87
  267. package/src/player/components/timelineSnapTargets.test.ts +0 -144
  268. package/src/player/components/timelineSnapTargets.ts +0 -164
  269. package/src/player/components/useTimelineClipDrag.test.tsx +0 -506
  270. package/src/player/components/useTimelineClipGroupDrag.ts +0 -417
  271. package/src/player/components/useTimelineMarqueeSelection.test.tsx +0 -231
  272. package/src/player/components/useTimelineMarqueeSelection.ts +0 -276
  273. package/src/utils/timelineDiscovery.test.ts +0 -90
  274. /package/dist/{domEditingLayers-2ECJK24D.js.map → domEditingLayers-6LQGKPOI.js.map} +0 -0
@@ -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
+ });
@@ -19,6 +19,7 @@ import {
19
19
  } from "./gsapKeyframeCacheHelpers";
20
20
  import type {
21
21
  CommitMutation,
22
+ CommitMutationOptions,
22
23
  SafeGsapCommitMutation,
23
24
  TrackGsapSaveFailure,
24
25
  } from "./gsapScriptCommitTypes";
@@ -54,6 +55,21 @@ interface GsapKeyframeOpsParams extends SdkKeyframeDeps {
54
55
  trackGsapSaveFailure: TrackGsapSaveFailure;
55
56
  }
56
57
 
58
+ /**
59
+ * Translate a gesture's commit overrides into the SDK persist options. The
60
+ * server path's `softReload`/`skipReload` maps to the SDK's `skipRefresh`, and
61
+ * `coalesceKey`/`coalesceMs` must ride along so an SDK-routed edit folds into
62
+ * one undo entry the same way the server path does.
63
+ */
64
+ function toSdkPersistOptions(label: string, overrides?: Partial<CommitMutationOptions>) {
65
+ return {
66
+ label,
67
+ coalesceKey: overrides?.coalesceKey,
68
+ coalesceMs: overrides?.coalesceMs,
69
+ skipRefresh: overrides?.skipReload,
70
+ };
71
+ }
72
+
57
73
  export function useGsapKeyframeOps({
58
74
  activeCompPath,
59
75
  commitMutation,
@@ -140,6 +156,7 @@ export function useGsapKeyframeOps({
140
156
  animationId: string,
141
157
  percentage: number,
142
158
  properties: Record<string, number | string>,
159
+ commitOverrides?: Partial<CommitMutationOptions>,
143
160
  ) => {
144
161
  if (sdkSession && sdkDeps) {
145
162
  const sourceFile = selection.sourceFile || activeCompPath || "index.html";
@@ -150,21 +167,30 @@ export function useGsapKeyframeOps({
150
167
  properties,
151
168
  sdkSession,
152
169
  sdkDeps,
153
- { label: `Add keyframe at ${percentage}%` },
170
+ toSdkPersistOptions(`Add keyframe at ${percentage}%`, commitOverrides),
154
171
  );
155
172
  if (handled) return;
156
173
  }
157
174
  return commitMutation(
158
175
  selection,
159
176
  { type: "add-keyframe", animationId, percentage, properties },
160
- { label: `Add keyframe at ${percentage}%`, softReload: true },
177
+ {
178
+ label: `Add keyframe at ${percentage}%`,
179
+ softReload: true,
180
+ ...commitOverrides,
181
+ },
161
182
  );
162
183
  },
163
184
  [commitMutation, activeCompPath, sdkSession, sdkDeps],
164
185
  );
165
186
 
166
187
  const removeKeyframe = useCallback(
167
- (selection: DomEditSelection, animationId: string, percentage: number) => {
188
+ (
189
+ selection: DomEditSelection,
190
+ animationId: string,
191
+ percentage: number,
192
+ commitOverrides?: Partial<CommitMutationOptions>,
193
+ ) => {
168
194
  const sourceFile = selection.sourceFile || activeCompPath || "index.html";
169
195
  const mutation = { type: "remove-keyframe", animationId, percentage };
170
196
  void executeOptimisticKeyframeCacheUpdate({
@@ -182,6 +208,7 @@ export function useGsapKeyframeOps({
182
208
  ),
183
209
  }),
184
210
  persist: async () => {
211
+ const label = `Remove keyframe at ${percentage}%`;
185
212
  if (sdkSession && sdkDeps) {
186
213
  const handled = await sdkGsapRemoveKeyframePersist(
187
214
  sourceFile,
@@ -189,14 +216,14 @@ export function useGsapKeyframeOps({
189
216
  percentage,
190
217
  sdkSession,
191
218
  sdkDeps,
192
- { label: `Remove keyframe at ${percentage}%` },
219
+ toSdkPersistOptions(label, commitOverrides),
193
220
  );
194
221
  if (handled) return;
195
222
  }
196
- await commitMutation(selection, mutation, {
197
- label: `Remove keyframe at ${percentage}%`,
198
- softReload: true,
199
- });
223
+ const commitOptions = commitOverrides?.skipReload
224
+ ? { label, ...commitOverrides }
225
+ : { label, softReload: true, ...commitOverrides };
226
+ await commitMutation(selection, mutation, commitOptions);
200
227
  },
201
228
  }).catch((error) => {
202
229
  trackGsapSaveFailure(error, selection, mutation, `Remove keyframe at ${percentage}%`);
@@ -261,6 +288,7 @@ export function useGsapKeyframeOps({
261
288
  animationId: string,
262
289
  resolvedFromValues?: Record<string, number | string>,
263
290
  duration?: number,
291
+ commitOverrides: Partial<CommitMutationOptions> = { softReload: true },
264
292
  ) => {
265
293
  if (sdkSession && sdkDeps) {
266
294
  const targetPath = selection.sourceFile || activeCompPath || "index.html";
@@ -270,7 +298,7 @@ export function useGsapKeyframeOps({
270
298
  resolvedFromValues,
271
299
  sdkSession,
272
300
  sdkDeps,
273
- { label: "Convert to keyframes" },
301
+ toSdkPersistOptions("Convert to keyframes", commitOverrides),
274
302
  );
275
303
  if (handled) return;
276
304
  }
@@ -279,7 +307,7 @@ export function useGsapKeyframeOps({
279
307
  // `duration` only applies when the target is a static `set` (which has
280
308
  // none) — it spans the converted keyframes across the element's clip.
281
309
  { type: "convert-to-keyframes", animationId, resolvedFromValues, duration },
282
- { label: "Convert to keyframes" },
310
+ { label: "Convert to keyframes", ...commitOverrides },
283
311
  );
284
312
  },
285
313
  [commitMutation, activeCompPath, sdkSession, sdkDeps],
@@ -217,6 +217,7 @@ let cleanup: (() => void) | null = null;
217
217
  function renderCommitHook() {
218
218
  const reloadPreview = vi.fn();
219
219
  const onCacheInvalidate = vi.fn();
220
+ const onFileContentChanged = vi.fn();
220
221
  const forceReloadSdkSession = vi.fn();
221
222
  const recordEdit = vi.fn(async () => {});
222
223
  const showToast = vi.fn();
@@ -231,7 +232,7 @@ function renderCommitHook() {
231
232
  domEditSaveTimestampRef: { current: 0 },
232
233
  reloadPreview,
233
234
  onCacheInvalidate,
234
- onFileContentChanged: undefined,
235
+ onFileContentChanged,
235
236
  showToast,
236
237
  sdkSession: null,
237
238
  writeProjectFile: undefined,
@@ -252,6 +253,7 @@ function renderCommitHook() {
252
253
  api: hookApi,
253
254
  reloadPreview,
254
255
  onCacheInvalidate,
256
+ onFileContentChanged,
255
257
  forceReloadSdkSession,
256
258
  recordEdit,
257
259
  showToast,
@@ -383,4 +385,41 @@ describe("runCommit — instantPatch wiring", () => {
383
385
  expectSoftReloadedWith(deps.reloadPreview, "AFTER");
384
386
  expect(deps.reloadPreview).not.toHaveBeenCalled();
385
387
  });
388
+
389
+ it("batch capability posts ordered mutations and finalizes the result once", async () => {
390
+ applySoftReload.mockReturnValue("applied");
391
+ mockFetchResult();
392
+ const deps = renderCommitHook();
393
+ const firstMutation = { type: "add", value: 1 };
394
+ const lastMutation = { type: "delete", value: 2 };
395
+ const batch = deps.api.commitMutation.batch;
396
+ if (!batch) throw new Error("batch capability missing");
397
+
398
+ await act(async () => {
399
+ await batch(
400
+ [
401
+ { selection, mutation: firstMutation, options: { label: "Resize", skipReload: true } },
402
+ { selection, mutation: lastMutation, options: { label: "Resize", softReload: true } },
403
+ ],
404
+ { label: "Resize", coalesceKey: "tx:resize:1", coalesceMs: Infinity, softReload: true },
405
+ );
406
+ });
407
+
408
+ expect(fetch).toHaveBeenCalledTimes(1);
409
+ expect(fetch).toHaveBeenCalledWith(
410
+ "/api/projects/proj-1/gsap-mutations-batch/index.html",
411
+ expect.objectContaining({
412
+ method: "POST",
413
+ body: JSON.stringify({ mutations: [firstMutation, lastMutation] }),
414
+ }),
415
+ );
416
+ expect(deps.recordEdit).toHaveBeenCalledTimes(1);
417
+ expect(deps.recordEdit).toHaveBeenCalledWith(
418
+ expect.objectContaining({ label: "Resize", coalesceKey: "tx:resize:1" }),
419
+ );
420
+ expect(deps.onFileContentChanged).toHaveBeenCalledTimes(1);
421
+ expect(deps.forceReloadSdkSession).toHaveBeenCalledTimes(1);
422
+ expect(applySoftReload).toHaveBeenCalledTimes(1);
423
+ expect(deps.onCacheInvalidate).toHaveBeenCalledTimes(1);
424
+ });
386
425
  });