@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
@@ -1,4 +1,7 @@
1
- import { describe, expect, it } from "vitest";
1
+ // @vitest-environment happy-dom
2
+ import { act, createElement, useRef } from "react";
3
+ import { createRoot } from "react-dom/client";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
5
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
3
6
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
4
7
  import {
@@ -7,8 +10,20 @@ import {
7
10
  isPlayheadWithinTween,
8
11
  promoteSetToKeyframes,
9
12
  resolveNewTweenRange,
13
+ useEnableKeyframes,
10
14
  type EnableKeyframesSession,
11
15
  } from "./useEnableKeyframes";
16
+ import { usePlayerStore } from "../player/store/playerStore";
17
+
18
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
19
+
20
+ let cleanup: (() => void) | null = null;
21
+ afterEach(() => {
22
+ cleanup?.();
23
+ cleanup = null;
24
+ vi.unstubAllGlobals();
25
+ window.location.hash = "";
26
+ });
12
27
 
13
28
  function anim(overrides: Partial<GsapAnimation>): GsapAnimation {
14
29
  return {
@@ -203,3 +218,129 @@ describe("promoteSetToKeyframes — auto endpoint", () => {
203
218
  expect(kfs[0].percentage).toBe(0);
204
219
  });
205
220
  });
221
+
222
+ function renderEnableKeyframes(session: EnableKeyframesSession): () => Promise<void> {
223
+ let enable: (() => Promise<void>) | null = null;
224
+ function Probe() {
225
+ const sessionRef = useRef<EnableKeyframesSession | undefined>(session);
226
+ enable = useEnableKeyframes(sessionRef);
227
+ return null;
228
+ }
229
+ const container = document.createElement("div");
230
+ const root = createRoot(container);
231
+ act(() => root.render(createElement(Probe)));
232
+ cleanup = () => act(() => root.unmount());
233
+ if (!enable) throw new Error("hook did not initialize");
234
+ return enable;
235
+ }
236
+
237
+ function flatTweenResponses(flat: GsapAnimation, converted: GsapAnimation) {
238
+ const responses = [{ animations: [flat] }, { animations: [converted] }];
239
+ vi.stubGlobal(
240
+ "fetch",
241
+ vi.fn(async () => ({
242
+ ok: true,
243
+ json: async () => responses.shift() ?? { animations: [] },
244
+ })),
245
+ );
246
+ }
247
+
248
+ function makeElementSelection(): DomEditSelection {
249
+ const element = document.body.appendChild(document.createElement("div"));
250
+ element.id = "el";
251
+ return {
252
+ id: "el",
253
+ selector: "#el",
254
+ sourceFile: "index.html",
255
+ element,
256
+ } as DomEditSelection;
257
+ }
258
+
259
+ function stubFlatTweenConversion(id: string): {
260
+ flat: GsapAnimation;
261
+ converted: GsapAnimation;
262
+ } {
263
+ const flat = anim({ id, position: 1, duration: 1, properties: { x: 10 } });
264
+ const converted = anim({
265
+ id,
266
+ position: 1,
267
+ duration: 1,
268
+ keyframes: {
269
+ format: "object-array",
270
+ keyframes: [
271
+ { percentage: 0, properties: { x: 0 } },
272
+ { percentage: 100, properties: { x: 10 } },
273
+ ],
274
+ },
275
+ });
276
+ flatTweenResponses(flat, converted);
277
+ return { flat, converted };
278
+ }
279
+
280
+ describe("useEnableKeyframes — flat tween transaction", () => {
281
+ it("skips the convert reload and coalesces an outside-range terminal soft reload", async () => {
282
+ window.location.hash = "#/project/test-project";
283
+ usePlayerStore.setState({ currentTime: 3 });
284
+ const selection = makeElementSelection();
285
+ const { flat } = stubFlatTweenConversion("flat-1");
286
+ const handleConvert = vi.fn(async () => undefined);
287
+ const commitMutation = vi.fn(async () => undefined);
288
+ const enable = renderEnableKeyframes({
289
+ domEditSelection: selection,
290
+ selectedGsapAnimations: [flat],
291
+ previewIframeRef: {
292
+ current: {
293
+ contentWindow: { gsap: { getProperty: () => 10 } },
294
+ } as unknown as HTMLIFrameElement,
295
+ },
296
+ handleGsapAddAnimation: vi.fn(),
297
+ handleGsapConvertToKeyframes: handleConvert,
298
+ handleGsapRemoveKeyframe: vi.fn(),
299
+ commitMutation,
300
+ });
301
+
302
+ await act(async () => enable());
303
+
304
+ const convertOptions = handleConvert.mock.calls[0]?.[3];
305
+ const terminalOptions = commitMutation.mock.calls[0]?.[1];
306
+ expect(convertOptions).toMatchObject({ skipReload: true, coalesceMs: Infinity });
307
+ expect(terminalOptions).toMatchObject({
308
+ softReload: true,
309
+ coalesceKey: convertOptions?.coalesceKey,
310
+ });
311
+ expect(convertOptions?.coalesceKey).toEqual(expect.any(String));
312
+ });
313
+
314
+ it("passes the convert coalesce key to an inside-range batch edit", async () => {
315
+ window.location.hash = "#/project/test-project";
316
+ usePlayerStore.setState({ currentTime: 1.5 });
317
+ const selection = makeElementSelection();
318
+ const { flat } = stubFlatTweenConversion("flat-2");
319
+ const handleConvert = vi.fn(async () => undefined);
320
+ const addKeyframeBatch = vi.fn(async () => undefined);
321
+ const enable = renderEnableKeyframes({
322
+ domEditSelection: selection,
323
+ selectedGsapAnimations: [flat],
324
+ previewIframeRef: {
325
+ current: {
326
+ contentWindow: { gsap: { getProperty: () => 5 } },
327
+ } as unknown as HTMLIFrameElement,
328
+ },
329
+ handleGsapAddAnimation: vi.fn(),
330
+ handleGsapConvertToKeyframes: handleConvert,
331
+ handleGsapRemoveKeyframe: vi.fn(),
332
+ handleGsapAddKeyframeBatch: addKeyframeBatch,
333
+ });
334
+
335
+ await act(async () => enable());
336
+
337
+ const convertOptions = handleConvert.mock.calls[0]?.[3];
338
+ expect(addKeyframeBatch.mock.calls[0]?.[3]).toEqual({
339
+ softReload: true,
340
+ coalesceKey: convertOptions?.coalesceKey,
341
+ // Must carry the convert phase's infinite window so the inside-range
342
+ // apply folds into one undo entry regardless of round-trip latency.
343
+ coalesceMs: Infinity,
344
+ });
345
+ });
346
+ });
@@ -12,7 +12,7 @@ import type { GsapAnimation, GsapPercentageKeyframe } from "@hyperframes/core/gs
12
12
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
13
13
  import { usePlayerStore } from "../player/store/playerStore";
14
14
  import { fetchParsedAnimations, getAnimationsForElement } from "./useGsapTweenCache";
15
- import { selectorFromSelection, computeElementPercentage } from "./gsapShared";
15
+ import { selectorFromSelection, computeElementPercentage, isInstantHold } from "./gsapShared";
16
16
  import {
17
17
  resolveTweenStart,
18
18
  resolveTweenDuration,
@@ -21,6 +21,9 @@ import {
21
21
  import { POSITION_PROPS } from "./gsapRuntimeReaders";
22
22
  import { roundTo3 } from "../utils/rounding";
23
23
  import { nearestPointOnPath } from "../components/editor/motionPathGeometry";
24
+ import type { CommitMutationOptions } from "./gsapScriptCommitTypes";
25
+
26
+ let enableKeyframesTransactionCounter = 0;
24
27
 
25
28
  export interface EnableKeyframesSession {
26
29
  domEditSelection: DomEditSelection | null;
@@ -30,16 +33,19 @@ export interface EnableKeyframesSession {
30
33
  handleGsapConvertToKeyframes: (
31
34
  animId: string,
32
35
  resolvedFromValues?: Record<string, number | string>,
36
+ duration?: number,
37
+ commitOverrides?: Partial<CommitMutationOptions>,
33
38
  ) => void | Promise<void>;
34
39
  handleGsapRemoveKeyframe: (animId: string, pct: number) => void;
35
40
  handleGsapAddKeyframeBatch?: (
36
41
  animId: string,
37
42
  pct: number,
38
43
  properties: Record<string, number | string>,
44
+ commitOverrides?: Partial<CommitMutationOptions>,
39
45
  ) => Promise<void>;
40
46
  commitMutation?: (
41
47
  mutation: Record<string, unknown>,
42
- options: { label: string; softReload?: boolean },
48
+ options: CommitMutationOptions,
43
49
  ) => Promise<void>;
44
50
  }
45
51
 
@@ -107,6 +113,36 @@ export function buildExtendedKeyframes(
107
113
  return { position: roundTo3(newStart), duration: newDuration, keyframes };
108
114
  }
109
115
 
116
+ async function replaceSetWithSingleKeyframe(
117
+ session: EnableKeyframesSession,
118
+ sel: DomEditSelection,
119
+ setAnim: GsapAnimation,
120
+ t: number,
121
+ iframe: HTMLIFrameElement | null,
122
+ selector: string,
123
+ ): Promise<void> {
124
+ const position = readElementPosition(iframe, sel, setAnim);
125
+ if (Object.keys(position).length === 0) {
126
+ for (const [key, held] of Object.entries(setAnim.properties ?? {})) {
127
+ if (typeof held === "number") position[key] = held;
128
+ }
129
+ }
130
+ if (Object.keys(position).length === 0 || !session.commitMutation) return;
131
+ const range = resolveNewTweenRange(sel.dataAttributes?.start, sel.dataAttributes?.duration, t);
132
+ await session.commitMutation(
133
+ {
134
+ type: "replace-with-keyframes",
135
+ animationId: setAnim.id,
136
+ targetSelector: selector,
137
+ position: roundTo3(range.start),
138
+ duration: roundTo3(range.duration),
139
+ keyframes: [{ percentage: 0, properties: position }],
140
+ ease: setAnim.ease,
141
+ },
142
+ { label: "Enable keyframes", softReload: true },
143
+ );
144
+ }
145
+
110
146
  // fallow-ignore-next-line complexity
111
147
  function readElementPosition(
112
148
  iframe: HTMLIFrameElement | null,
@@ -199,6 +235,7 @@ async function applyKeyframeAtPlayhead(
199
235
  kfAnim: GsapAnimation,
200
236
  t: number,
201
237
  iframe: HTMLIFrameElement | null,
238
+ commitOverrides?: Partial<CommitMutationOptions>,
202
239
  ): Promise<void> {
203
240
  if (!isPlayheadWithinTween(kfAnim, t)) {
204
241
  const position = readElementPosition(iframe, sel, kfAnim);
@@ -215,7 +252,11 @@ async function applyKeyframeAtPlayhead(
215
252
  keyframes: extended.keyframes,
216
253
  ease: kfAnim.ease,
217
254
  },
218
- { label: "Add keyframe", softReload: true },
255
+ {
256
+ label: "Add keyframe",
257
+ softReload: true,
258
+ ...commitOverrides,
259
+ },
219
260
  );
220
261
  }
221
262
  return;
@@ -229,7 +270,7 @@ async function applyKeyframeAtPlayhead(
229
270
  if (session.handleGsapAddKeyframeBatch) {
230
271
  const position = readElementPosition(iframe, sel, kfAnim);
231
272
  if (Object.keys(position).length > 0) {
232
- await session.handleGsapAddKeyframeBatch(kfAnim.id, pct, position);
273
+ await session.handleGsapAddKeyframeBatch(kfAnim.id, pct, position, commitOverrides);
233
274
  }
234
275
  }
235
276
  }
@@ -259,27 +300,7 @@ export async function promoteSetToKeyframes(
259
300
  // the set with a single keyframe at the playhead holding its value, matching the
260
301
  // no-animation branch: one diamond the user can build motion from.
261
302
  if (t <= setStart) {
262
- const position = readElementPosition(iframe, sel, setAnim);
263
- if (Object.keys(position).length === 0) {
264
- for (const key of Object.keys(setAnim.properties ?? {})) {
265
- const held = setAnim.properties?.[key];
266
- if (typeof held === "number") position[key] = held;
267
- }
268
- }
269
- if (Object.keys(position).length === 0) return;
270
- const range = resolveNewTweenRange(sel.dataAttributes?.start, sel.dataAttributes?.duration, t);
271
- await session.commitMutation(
272
- {
273
- type: "replace-with-keyframes",
274
- animationId: setAnim.id,
275
- targetSelector: selector,
276
- position: roundTo3(range.start),
277
- duration: roundTo3(range.duration),
278
- keyframes: [{ percentage: 0, properties: position }],
279
- ease: setAnim.ease,
280
- },
281
- { label: "Enable keyframes", softReload: true },
282
- );
303
+ await replaceSetWithSingleKeyframe(session, sel, setAnim, t, iframe, selector);
283
304
  return;
284
305
  }
285
306
  const endPosition = readElementPosition(iframe, sel, setAnim);
@@ -395,8 +416,8 @@ export function useEnableKeyframes(
395
416
  // the curve.
396
417
  const arcAnim = anims.find((a) => a.arcPath);
397
418
  const kfAnim = anims.find((a) => a.keyframes && !a.arcPath);
398
- const setAnim = anims.find((a) => a.method === "set" && !a.keyframes && !a.arcPath);
399
- const flatAnim = anims.find((a) => !a.keyframes && !a.arcPath && a.method !== "set");
419
+ const setAnim = anims.find((a) => isInstantHold(a) && !a.keyframes && !a.arcPath);
420
+ const flatAnim = anims.find((a) => !a.keyframes && !a.arcPath && !isInstantHold(a));
400
421
 
401
422
  if (arcAnim) {
402
423
  await applyArcWaypointAtPlayhead(session, sel, arcAnim, t, iframe);
@@ -409,9 +430,32 @@ export function useEnableKeyframes(
409
430
  // resolvedFromValues, so the 0%/100% stops keep the real start→end motion
410
431
  // (passing the playhead value would flatten it). Then apply uniformly so an
411
432
  // out-of-range playhead extends the range just like a keyframe tween.
412
- await session.handleGsapConvertToKeyframes(flatAnim.id);
433
+ enableKeyframesTransactionCounter += 1;
434
+ const coalesceKey = `enable-keyframes:${flatAnim.id}:${enableKeyframesTransactionCounter}`;
435
+ const convertCommitOverrides: Partial<CommitMutationOptions> = {
436
+ skipReload: true,
437
+ coalesceKey,
438
+ coalesceMs: Number.POSITIVE_INFINITY,
439
+ };
440
+ await session.handleGsapConvertToKeyframes(
441
+ flatAnim.id,
442
+ undefined,
443
+ undefined,
444
+ convertCommitOverrides,
445
+ );
413
446
  const converted = (await fetchAnimationsForElement(sel)).find((a) => a.keyframes);
414
- if (converted) await applyKeyframeAtPlayhead(session, sel, converted, t, iframe);
447
+ if (converted) {
448
+ const applyCommitOverrides: Partial<CommitMutationOptions> = {
449
+ softReload: true,
450
+ coalesceKey,
451
+ // Must match the convert phase's window: editHistory keys coalescing
452
+ // off the incoming entry, so without Infinity here the apply (landing
453
+ // after two POSTs + a fetch) falls back to the 300ms default and the
454
+ // conversion splits into two undo entries under real latency.
455
+ coalesceMs: Number.POSITIVE_INFINITY,
456
+ };
457
+ await applyKeyframeAtPlayhead(session, sel, converted, t, iframe, applyCommitOverrides);
458
+ }
415
459
  } else {
416
460
  const position = readElementPosition(iframe, sel, null);
417
461
  const { start: elStart, duration: elDuration } = resolveNewTweenRange(
@@ -0,0 +1,130 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from "react";
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+ import type { DomEditSelection } from "../components/editor/domEditing";
5
+ import { usePlayerStore } from "../player";
6
+ import { mountReactHarness } from "./domSelectionTestHarness";
7
+ import type { CommitMutationOptions } from "./gsapScriptCommitTypes";
8
+ import { useGestureCommit } from "./useGestureCommit";
9
+
10
+ const gestureRecording = vi.hoisted(() => ({
11
+ startRecording: vi.fn(),
12
+ stopRecording: vi.fn(() => [
13
+ { time: 0, properties: { x: 0, y: 0, opacity: 1 } },
14
+ { time: 0.5, properties: { x: 50, y: 25, opacity: 0.5 } },
15
+ { time: 1, properties: { x: 100, y: 50, opacity: 0 } },
16
+ ]),
17
+ clearSamples: vi.fn(),
18
+ cancelRecording: vi.fn(),
19
+ isRecording: false,
20
+ recordingDuration: 0,
21
+ samplesRef: { current: [] },
22
+ trailRef: { current: [] },
23
+ }));
24
+
25
+ vi.mock("./useGestureRecording", () => ({
26
+ useGestureRecording: () => gestureRecording,
27
+ }));
28
+
29
+ vi.mock("../utils/rdpSimplify", () => ({
30
+ simplifyGestureSamples: () =>
31
+ new Map([
32
+ [0, { x: 0, y: 0, opacity: 1 }],
33
+ [50, { x: 50, y: 25, opacity: 0.5 }],
34
+ [100, { x: 100, y: 50, opacity: 0 }],
35
+ ]),
36
+ }));
37
+
38
+ vi.mock("../utils/gestureSmoother", () => ({
39
+ smoothGestureKeyframes: (keyframes: unknown) => keyframes,
40
+ }));
41
+
42
+ vi.mock("../utils/velocityEaseFitter", () => ({
43
+ fitEasesFromVelocity: (keyframes: unknown) => keyframes,
44
+ }));
45
+
46
+ Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
47
+
48
+ let cleanup: (() => void) | null = null;
49
+
50
+ afterEach(() => {
51
+ cleanup?.();
52
+ cleanup = null;
53
+ usePlayerStore.getState().reset();
54
+ document.body.replaceChildren();
55
+ vi.clearAllMocks();
56
+ });
57
+
58
+ function makeSelection(element: HTMLElement): DomEditSelection {
59
+ return {
60
+ id: element.id,
61
+ element,
62
+ label: "Card",
63
+ tagName: "div",
64
+ sourceFile: "index.html",
65
+ compositionPath: "index.html",
66
+ isCompositionHost: false,
67
+ isInsideLockedComposition: false,
68
+ boundingBox: { x: 0, y: 0, width: 100, height: 100 },
69
+ textContent: null,
70
+ dataAttributes: { start: "0", duration: "2" },
71
+ inlineStyles: {},
72
+ computedStyles: {},
73
+ textFields: [],
74
+ capabilities: {
75
+ canSelect: true,
76
+ canEditStyles: true,
77
+ canCrop: true,
78
+ canMove: true,
79
+ canResize: true,
80
+ canApplyManualOffset: true,
81
+ canApplyManualSize: true,
82
+ canApplyManualRotation: true,
83
+ },
84
+ };
85
+ }
86
+
87
+ describe("useGestureCommit", () => {
88
+ it("coalesces property-group commits and reloads only the terminal group", async () => {
89
+ const iframe = document.createElement("iframe");
90
+ document.body.append(iframe);
91
+ const element = document.createElement("div");
92
+ element.id = "card";
93
+ const commitMutation = vi.fn<
94
+ (mutation: Record<string, unknown>, options: CommitMutationOptions) => Promise<void>
95
+ >(async () => {});
96
+ const sessionRef = {
97
+ current: {
98
+ domEditSelection: makeSelection(element),
99
+ selectedGsapAnimations: [],
100
+ commitMutation,
101
+ },
102
+ };
103
+ const captured: { hook: ReturnType<typeof useGestureCommit> | null } = { hook: null };
104
+ function Probe() {
105
+ captured.hook = useGestureCommit({
106
+ domEditSessionRef: sessionRef,
107
+ previewIframeRef: { current: iframe },
108
+ showToast: vi.fn(),
109
+ isGestureRecordingRef: { current: false },
110
+ });
111
+ return null;
112
+ }
113
+ const root = mountReactHarness(<Probe />);
114
+ cleanup = () => act(() => root.unmount());
115
+ if (!captured.hook) throw new Error("hook did not initialize");
116
+
117
+ act(() => captured.hook?.handleToggleRecording());
118
+ act(() => captured.hook?.handleToggleRecording());
119
+ await act(async () => {
120
+ await vi.waitFor(() => expect(commitMutation).toHaveBeenCalledTimes(2));
121
+ });
122
+
123
+ const options = commitMutation.mock.calls.map((call) => call[1]);
124
+ expect(new Set(options.map((entry) => entry.coalesceKey)).size).toBe(1);
125
+ expect(options[0]).toEqual(expect.objectContaining({ coalesceMs: Infinity, skipReload: true }));
126
+ expect(options[0]).not.toHaveProperty("softReload");
127
+ expect(options[1]).toEqual(expect.objectContaining({ coalesceMs: Infinity, softReload: true }));
128
+ expect(options[1]).not.toHaveProperty("skipReload");
129
+ });
130
+ });
@@ -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(