@hyperframes/studio 0.7.55 → 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 (237) hide show
  1. package/dist/assets/{index-BXaqaVKt.js → index-BWnOxAiH.js} +1 -1
  2. package/dist/assets/{index-CPetwHFV.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 +40414 -37680
  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/DomEditCropHandles.test.tsx +74 -3
  21. package/src/components/editor/DomEditCropHandles.tsx +64 -29
  22. package/src/components/editor/DomEditOverlay.test.ts +245 -279
  23. package/src/components/editor/DomEditOverlay.tsx +158 -126
  24. package/src/components/editor/DomEditOverlayHover.test.tsx +90 -0
  25. package/src/components/editor/DomEditRotateHandle.tsx +38 -18
  26. package/src/components/editor/DomEditSelectionChrome.test.tsx +80 -0
  27. package/src/components/editor/DomEditSelectionChrome.tsx +257 -0
  28. package/src/components/editor/LayersPanel.test.ts +66 -2
  29. package/src/components/editor/LayersPanel.tsx +43 -1
  30. package/src/components/editor/OffCanvasIndicators.test.tsx +34 -0
  31. package/src/components/editor/OffCanvasIndicators.tsx +49 -10
  32. package/src/components/editor/SnapGuideOverlay.tsx +28 -23
  33. package/src/components/editor/SnapToolbar.test.tsx +0 -1
  34. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +214 -0
  35. package/src/components/editor/anchoredResizeReleaseShift.test.ts +106 -10
  36. package/src/components/editor/domEditNudge.test.ts +80 -0
  37. package/src/components/editor/domEditNudge.ts +44 -0
  38. package/src/components/editor/domEditOverlayGeometry.test.ts +167 -1
  39. package/src/components/editor/domEditOverlayGeometry.ts +280 -20
  40. package/src/components/editor/domEditOverlayGestures.ts +44 -41
  41. package/src/components/editor/domEditOverlayStartGesture.ts +62 -2
  42. package/src/components/editor/domEditResizeLocal.test.ts +131 -0
  43. package/src/components/editor/domEditResizeLocal.ts +125 -0
  44. package/src/components/editor/domEditingDom.ts +1 -1
  45. package/src/components/editor/manualEditsDomPatches.test.ts +47 -0
  46. package/src/components/editor/manualOffsetDrag.ts +55 -12
  47. package/src/components/editor/offCanvasIndicatorGeometry.ts +31 -10
  48. package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +25 -1
  49. package/src/components/editor/resizeDraft.ts +108 -0
  50. package/src/components/editor/snapEngine.test.ts +12 -78
  51. package/src/components/editor/snapEngine.ts +13 -53
  52. package/src/components/editor/useDomEditNudge.test.tsx +228 -0
  53. package/src/components/editor/useDomEditNudge.ts +254 -0
  54. package/src/components/editor/useDomEditOverlayGestures.ts +115 -116
  55. package/src/components/editor/useDomEditOverlayRects.ts +9 -2
  56. package/src/components/nle/AssetPreviewOverlay.tsx +147 -0
  57. package/src/components/nle/NLEContext.test.ts +144 -0
  58. package/src/components/nle/NLEContext.tsx +328 -0
  59. package/src/components/nle/NLEPreview.tsx +1 -1
  60. package/src/components/nle/PreviewOverlays.tsx +240 -0
  61. package/src/components/nle/PreviewPane.tsx +163 -0
  62. package/src/components/nle/TimelinePane.test.ts +60 -0
  63. package/src/components/nle/TimelinePane.tsx +293 -0
  64. package/src/components/nle/TimelineResizeDivider.tsx +8 -2
  65. package/src/components/nle/useCompositionStack.test.tsx +44 -0
  66. package/src/components/nle/useCompositionStack.ts +17 -9
  67. package/src/components/nle/useTimelineEditCallbacks.ts +217 -0
  68. package/src/components/sidebar/AssetCard.tsx +326 -0
  69. package/src/components/sidebar/AssetContextMenu.tsx +15 -30
  70. package/src/components/sidebar/AssetsTab.test.ts +96 -0
  71. package/src/components/sidebar/AssetsTab.tsx +71 -206
  72. package/src/components/sidebar/AudioRow.tsx +39 -2
  73. package/src/components/sidebar/BlocksTab.tsx +8 -1
  74. package/src/components/sidebar/LeftSidebar.tsx +4 -1
  75. package/src/components/sidebar/assetHelpers.ts +29 -0
  76. package/src/contexts/DomEditContext.tsx +9 -0
  77. package/src/contexts/StudioContext.tsx +9 -8
  78. package/src/contexts/TimelineEditContext.tsx +3 -7
  79. package/src/hooks/deleteSelectedKeyframes.ts +35 -0
  80. package/src/hooks/domEditCommitTypes.ts +12 -1
  81. package/src/hooks/domSelectionTestHarness.ts +14 -0
  82. package/src/hooks/gestureTransaction.test.ts +311 -0
  83. package/src/hooks/gestureTransaction.ts +199 -0
  84. package/src/hooks/gsapDragCommit.test.ts +141 -41
  85. package/src/hooks/gsapDragCommit.ts +64 -74
  86. package/src/hooks/gsapDragStaticSetHelpers.ts +9 -21
  87. package/src/hooks/gsapResizeIntercept.test.ts +69 -0
  88. package/src/hooks/gsapResizeIntercept.ts +38 -10
  89. package/src/hooks/gsapRuntimeBridge.test.ts +49 -13
  90. package/src/hooks/gsapRuntimeBridge.ts +4 -4
  91. package/src/hooks/gsapScriptCommitTypes.ts +16 -5
  92. package/src/hooks/gsapShared.test.ts +19 -1
  93. package/src/hooks/gsapShared.ts +14 -0
  94. package/src/hooks/patchDocumentRootDuration.test.ts +71 -0
  95. package/src/hooks/timelineEditingGsap.ts +14 -0
  96. package/src/hooks/timelineEditingHelpers.test.ts +34 -1
  97. package/src/hooks/timelineEditingHelpers.ts +22 -48
  98. package/src/hooks/timelineMoveAdapter.test.ts +77 -0
  99. package/src/hooks/timelineMoveAdapter.ts +37 -0
  100. package/src/hooks/useAnimatedPropertyCommit.test.tsx +36 -8
  101. package/src/hooks/useAnimatedPropertyCommit.ts +31 -26
  102. package/src/hooks/useAppHotkeys.ts +16 -25
  103. package/src/hooks/useBlockHandlers.ts +55 -27
  104. package/src/hooks/useContextMenuDismiss.ts +31 -7
  105. package/src/hooks/useDomEditCommits.test.tsx +154 -1
  106. package/src/hooks/useDomEditCommits.ts +100 -3
  107. package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
  108. package/src/hooks/useDomEditSession.ts +2 -2
  109. package/src/hooks/useDomEditTextCommits.test.tsx +135 -0
  110. package/src/hooks/useDomEditTextCommits.ts +8 -3
  111. package/src/hooks/useDomEditWiring.ts +7 -9
  112. package/src/hooks/useDomGeometryCommits.ts +17 -2
  113. package/src/hooks/useDomSelection.test.ts +2 -36
  114. package/src/hooks/useDomSelection.ts +81 -51
  115. package/src/hooks/useDomSelectionSelectionGuards.test.ts +276 -0
  116. package/src/hooks/useElementLifecycleOps.test.tsx +258 -0
  117. package/src/hooks/useElementLifecycleOps.ts +40 -71
  118. package/src/hooks/useEnableKeyframes.test.ts +142 -1
  119. package/src/hooks/useEnableKeyframes.ts +73 -29
  120. package/src/hooks/useGestureCommit.test.tsx +130 -0
  121. package/src/hooks/useGestureCommit.ts +32 -8
  122. package/src/hooks/useGroupCommits.ts +7 -28
  123. package/src/hooks/useGsapAwareEditing.test.tsx +239 -0
  124. package/src/hooks/useGsapAwareEditing.ts +115 -20
  125. package/src/hooks/useGsapKeyframeOps.test.tsx +59 -0
  126. package/src/hooks/useGsapKeyframeOps.ts +38 -10
  127. package/src/hooks/useGsapScriptCommits.test.tsx +40 -1
  128. package/src/hooks/useGsapScriptCommits.ts +134 -50
  129. package/src/hooks/useGsapSelectionHandlers.ts +49 -16
  130. package/src/hooks/useMusicBeatAnalysis.ts +72 -36
  131. package/src/hooks/usePersistentEditHistory.test.ts +33 -0
  132. package/src/hooks/usePersistentEditHistory.ts +92 -58
  133. package/src/hooks/usePreviewPersistence.ts +25 -7
  134. package/src/hooks/useRazorSplit.history.test.tsx +148 -18
  135. package/src/hooks/useRazorSplit.test.ts +269 -0
  136. package/src/hooks/useRazorSplit.testHelpers.ts +64 -0
  137. package/src/hooks/useRazorSplit.ts +107 -36
  138. package/src/hooks/useRenderClipContent.ts +24 -6
  139. package/src/hooks/useStudioContextValue.ts +15 -5
  140. package/src/hooks/useStudioUrlState.ts +2 -4
  141. package/src/hooks/useTimelineEditing.test.tsx +20 -29
  142. package/src/hooks/useTimelineEditingTypes.ts +6 -0
  143. package/src/hooks/useTimelineGroupEditing.ts +18 -4
  144. package/src/index.ts +1 -1
  145. package/src/player/components/ImageThumbnail.test.tsx +173 -0
  146. package/src/player/components/ImageThumbnail.tsx +160 -0
  147. package/src/player/components/PlayheadIndicator.tsx +50 -5
  148. package/src/player/components/Timeline.test.ts +102 -19
  149. package/src/player/components/Timeline.tsx +194 -194
  150. package/src/player/components/TimelineCanvas.tsx +207 -521
  151. package/src/player/components/TimelineClip.tsx +4 -1
  152. package/src/player/components/TimelineClipDiamonds.tsx +9 -40
  153. package/src/player/components/TimelineEmptyState.tsx +3 -1
  154. package/src/player/components/TimelineLanes.tsx +487 -0
  155. package/src/player/components/TimelineOverlays.tsx +122 -0
  156. package/src/player/components/VideoThumbnail.test.tsx +152 -0
  157. package/src/player/components/VideoThumbnail.tsx +28 -7
  158. package/src/player/components/thumbnailUtils.test.ts +127 -0
  159. package/src/player/components/thumbnailUtils.ts +54 -0
  160. package/src/player/components/timelineCallbacks.ts +18 -18
  161. package/src/player/components/timelineClipChildren.tsx +39 -0
  162. package/src/player/components/timelineClipDragCommit.test.ts +943 -0
  163. package/src/player/components/timelineClipDragCommit.ts +401 -0
  164. package/src/player/components/timelineClipDragPreview.test.ts +144 -0
  165. package/src/player/components/timelineClipDragPreview.ts +323 -38
  166. package/src/player/components/timelineClipDragTypes.ts +65 -0
  167. package/src/player/components/timelineCollision.test.ts +42 -2
  168. package/src/player/components/timelineDragDrop.ts +69 -60
  169. package/src/player/components/timelineEditCapabilities.ts +52 -0
  170. package/src/player/components/timelineEditing.test.ts +1 -1
  171. package/src/player/components/timelineEditing.ts +76 -59
  172. package/src/player/components/timelineGroupEditing.test.ts +126 -0
  173. package/src/player/components/timelineGroupEditing.ts +133 -0
  174. package/src/player/components/timelineGroupResizeCommit.ts +54 -0
  175. package/src/player/components/timelineLayout.test.ts +95 -23
  176. package/src/player/components/timelineLayout.ts +120 -61
  177. package/src/player/components/timelineMarquee.test.ts +197 -0
  178. package/src/player/components/timelineMarquee.ts +112 -0
  179. package/src/player/components/timelineOptimisticRevision.ts +42 -0
  180. package/src/player/components/timelineTheme.ts +6 -1
  181. package/src/player/components/useResolvedTimelineEditCallbacks.ts +25 -3
  182. package/src/player/components/useTimelineClipDrag.resize.test.tsx +241 -0
  183. package/src/player/components/useTimelineClipDrag.ts +380 -386
  184. package/src/player/components/useTimelineEditPinning.ts +121 -0
  185. package/src/player/components/useTimelineGeometry.ts +129 -0
  186. package/src/player/components/useTimelinePlayhead.ts +4 -16
  187. package/src/player/components/useTimelineRangeSelection.ts +341 -50
  188. package/src/player/components/useTimelineStackingSync.test.tsx +67 -0
  189. package/src/player/components/useTimelineStackingSync.ts +83 -0
  190. package/src/player/hooks/useExpandedTimelineElements.ts +0 -3
  191. package/src/player/hooks/usePlaybackKeyboard.ts +5 -0
  192. package/src/player/hooks/useTimelinePlayer.ts +48 -53
  193. package/src/player/hooks/useTimelinePlayerLoop.ts +85 -0
  194. package/src/player/index.ts +3 -2
  195. package/src/player/lib/time.test.ts +19 -19
  196. package/src/player/lib/time.ts +6 -2
  197. package/src/player/lib/timelineDOM.test.ts +25 -98
  198. package/src/player/lib/timelineDOM.ts +12 -81
  199. package/src/player/lib/timelineElementHelpers.ts +54 -5
  200. package/src/player/lib/timelineIframeHelpers.ts +2 -0
  201. package/src/player/store/playerStore.ts +62 -3
  202. package/src/styles/studio.css +27 -0
  203. package/src/utils/assetPreviewStore.ts +33 -0
  204. package/src/utils/blockInstaller.ts +10 -2
  205. package/src/utils/editHistory.test.ts +35 -0
  206. package/src/utils/gsapSoftReload.test.ts +119 -1
  207. package/src/utils/gsapSoftReload.ts +153 -0
  208. package/src/utils/mediaTypes.ts +3 -2
  209. package/src/utils/resizeDebug.ts +55 -0
  210. package/src/utils/rootDuration.test.ts +90 -1
  211. package/src/utils/rootDuration.ts +76 -13
  212. package/src/utils/sdkCutover.ts +4 -0
  213. package/src/utils/studioFileHistory.ts +37 -7
  214. package/src/utils/studioHelpers.ts +62 -0
  215. package/src/utils/studioPreviewHelpers.test.ts +65 -8
  216. package/src/utils/studioPreviewHelpers.ts +10 -0
  217. package/src/utils/studioTelemetry.ts +4 -1
  218. package/src/utils/studioUrlState.test.ts +0 -1
  219. package/src/utils/timelineAssetDrop.ts +62 -0
  220. package/src/utils/timelineDiscovery.ts +0 -17
  221. package/dist/assets/index-BRwkMj0w.js +0 -423
  222. package/dist/assets/index-Dq7FEg0K.css +0 -1
  223. package/dist/chunk-SOTCF4DF.js.map +0 -1
  224. package/src/components/StudioPreviewArea.tsx +0 -500
  225. package/src/components/nle/NLELayout.test.ts +0 -12
  226. package/src/components/nle/NLELayout.tsx +0 -591
  227. package/src/player/components/TimelineLayerGutter.tsx +0 -61
  228. package/src/player/components/TimelineSelectionOverlays.tsx +0 -55
  229. package/src/player/components/timelineMarqueeSelection.test.ts +0 -87
  230. package/src/player/components/timelineSnapTargets.test.ts +0 -144
  231. package/src/player/components/timelineSnapTargets.ts +0 -164
  232. package/src/player/components/useTimelineClipDrag.test.tsx +0 -506
  233. package/src/player/components/useTimelineClipGroupDrag.ts +0 -417
  234. package/src/player/components/useTimelineMarqueeSelection.test.tsx +0 -231
  235. package/src/player/components/useTimelineMarqueeSelection.ts +0 -276
  236. package/src/utils/timelineDiscovery.test.ts +0 -90
  237. /package/dist/{domEditingLayers-2ECJK24D.js.map → domEditingLayers-6LQGKPOI.js.map} +0 -0
@@ -2,7 +2,7 @@
2
2
  * Block drop/add handlers for the Studio.
3
3
  * Extracted from App.tsx to keep file sizes under the 600-line limit.
4
4
  */
5
- import { useCallback, useMemo, useState } from "react";
5
+ import { useCallback, useMemo, useRef, useState } from "react";
6
6
  import type { TimelineElement } from "../player";
7
7
  import { usePlayerStore } from "../player";
8
8
  import { addBlockToProject } from "../utils/blockInstaller";
@@ -83,17 +83,41 @@ export function useBlockHandlers({
83
83
  ],
84
84
  );
85
85
 
86
+ // Block installs hit the server and end in a full preview reload; without a
87
+ // guard, repeat drops while one is in flight stack duplicate installs.
88
+ const installingBlockRef = useRef(false);
89
+ const runBlockInstall = useCallback(
90
+ async <T>(blockName: string, install: () => Promise<T>): Promise<T | null> => {
91
+ if (installingBlockRef.current) {
92
+ blockCtx.showToast("A block is already installing — one moment…", "info");
93
+ return null;
94
+ }
95
+ installingBlockRef.current = true;
96
+ blockCtx.showToast(`Adding ${blockName}…`, "info");
97
+ try {
98
+ return await install();
99
+ } finally {
100
+ installingBlockRef.current = false;
101
+ }
102
+ },
103
+ [blockCtx],
104
+ );
105
+
86
106
  const handleAddBlock = useCallback(
87
107
  (blockName: string) => {
88
108
  if (!projectId) return;
109
+ // fallow-ignore-next-line complexity
89
110
  void (async () => {
90
- const result = await addBlockToProject({
91
- projectId,
92
- blockName,
93
- ...blockCtx,
94
- previewIframe: previewIframeRef.current,
95
- currentTime: usePlayerStore.getState().currentTime,
96
- });
111
+ const result = await runBlockInstall(blockName, () =>
112
+ addBlockToProject({
113
+ projectId,
114
+ blockName,
115
+ ...blockCtx,
116
+ previewIframe: previewIframeRef.current,
117
+ currentTime: usePlayerStore.getState().currentTime,
118
+ }),
119
+ );
120
+ if (result === null) return;
97
121
  const params = result?.block.type === "hyperframes:block" ? result.block.params : undefined;
98
122
  if (params?.length) {
99
123
  setActiveBlockParams({
@@ -107,37 +131,41 @@ export function useBlockHandlers({
107
131
  }
108
132
  })();
109
133
  },
110
- [projectId, blockCtx, previewIframeRef, setRightCollapsed, setRightPanelTab],
134
+ [projectId, blockCtx, previewIframeRef, runBlockInstall, setRightCollapsed, setRightPanelTab],
111
135
  );
112
136
 
113
137
  const handleTimelineBlockDrop = useCallback(
114
138
  (blockName: string, placement: { start: number; track: number }) => {
115
139
  if (!projectId) return;
116
- void addBlockToProject({
117
- projectId,
118
- blockName,
119
- placement,
120
- ...blockCtx,
121
- previewIframe: previewIframeRef.current,
122
- currentTime: usePlayerStore.getState().currentTime,
123
- });
140
+ void runBlockInstall(blockName, () =>
141
+ addBlockToProject({
142
+ projectId,
143
+ blockName,
144
+ placement,
145
+ ...blockCtx,
146
+ previewIframe: previewIframeRef.current,
147
+ currentTime: usePlayerStore.getState().currentTime,
148
+ }),
149
+ );
124
150
  },
125
- [projectId, blockCtx, previewIframeRef],
151
+ [projectId, blockCtx, previewIframeRef, runBlockInstall],
126
152
  );
127
153
 
128
154
  const handlePreviewBlockDrop = useCallback(
129
155
  (blockName: string, position: { left: number; top: number }) => {
130
156
  if (!projectId) return;
131
- void addBlockToProject({
132
- projectId,
133
- blockName,
134
- visualPosition: position,
135
- ...blockCtx,
136
- previewIframe: previewIframeRef.current,
137
- currentTime: usePlayerStore.getState().currentTime,
138
- });
157
+ void runBlockInstall(blockName, () =>
158
+ addBlockToProject({
159
+ projectId,
160
+ blockName,
161
+ visualPosition: position,
162
+ ...blockCtx,
163
+ previewIframe: previewIframeRef.current,
164
+ currentTime: usePlayerStore.getState().currentTime,
165
+ }),
166
+ );
139
167
  },
140
- [projectId, blockCtx, previewIframeRef],
168
+ [projectId, blockCtx, previewIframeRef, runBlockInstall],
141
169
  );
142
170
 
143
171
  return {
@@ -1,26 +1,50 @@
1
1
  import { useCallback, useEffect, useRef, type RefObject } from "react";
2
2
 
3
3
  /**
4
- * Shared dismiss logic for context menus: closes on outside click or Escape.
5
- * Returns a ref to attach to the menu container element.
4
+ * Shared dismiss logic for context menus: closes on ANY pointerdown outside the
5
+ * menu (mouse, pen, or touch), or Escape.
6
+ *
7
+ * Two failure modes this guards against, both seen in the canvas editor:
8
+ *
9
+ * 1. The menu lives inside DomEditOverlay, whose own pointer handlers call
10
+ * `event.stopPropagation()` on several branches (marquee start, shift-select).
11
+ * A bubble-phase `mousedown`/`pointerdown` listener on `document` never fires
12
+ * for those events — the overlay eats them first — so the menu stayed open.
13
+ * Listening in the CAPTURE phase runs this dismiss BEFORE any bubble-phase
14
+ * stopPropagation, so an outside press always closes the menu.
15
+ *
16
+ * 2. `mousedown` alone misses pointer/touch-only gestures. `pointerdown` is the
17
+ * superset (fires for mouse, pen, and touch) and is exactly the event the
18
+ * overlay itself acts on, so hooking it here dismisses on the same press that
19
+ * starts a canvas gesture — no second click required.
6
20
  */
7
21
  export function useContextMenuDismiss(onClose: () => void): RefObject<HTMLDivElement | null> {
8
22
  const menuRef = useRef<HTMLDivElement>(null);
9
23
 
10
24
  const dismiss = useCallback(
11
- (e: MouseEvent | KeyboardEvent) => {
12
- if (e instanceof KeyboardEvent && e.key !== "Escape") return;
13
- if (e instanceof MouseEvent && menuRef.current?.contains(e.target as Node)) return;
25
+ (e: PointerEvent | MouseEvent | KeyboardEvent) => {
26
+ if (e instanceof KeyboardEvent) {
27
+ if (e.key === "Escape") onClose();
28
+ return;
29
+ }
30
+ // Any press inside the menu is a menu interaction — leave it open (the
31
+ // item's own handler will close it after acting).
32
+ if (menuRef.current?.contains(e.target as Node)) return;
14
33
  onClose();
15
34
  },
16
35
  [onClose],
17
36
  );
18
37
 
19
38
  useEffect(() => {
20
- document.addEventListener("mousedown", dismiss);
39
+ // Capture phase so overlay/iframe-side handlers that stopPropagation on the
40
+ // bubble phase can't swallow the dismiss. `pointerdown` covers mouse + touch
41
+ // + pen; keep `mousedown` too for any synthetic-mouse path that skips it.
42
+ document.addEventListener("pointerdown", dismiss, true);
43
+ document.addEventListener("mousedown", dismiss, true);
21
44
  document.addEventListener("keydown", dismiss);
22
45
  return () => {
23
- document.removeEventListener("mousedown", dismiss);
46
+ document.removeEventListener("pointerdown", dismiss, true);
47
+ document.removeEventListener("mousedown", dismiss, true);
24
48
  document.removeEventListener("keydown", dismiss);
25
49
  };
26
50
  }, [dismiss]);
@@ -6,6 +6,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
6
6
  import type { MutableRefObject } from "react";
7
7
  import type { DomEditSelection, DomEditTextField } from "../components/editor/domEditing";
8
8
  import type { ImportedFontAsset } from "../components/editor/fontAssets";
9
+ import { usePlayerStore } from "../player";
9
10
  import { StudioSaveHttpError } from "../utils/studioSaveDiagnostics";
10
11
  import { trackStudioEvent } from "../utils/studioTelemetry";
11
12
  import { useDomEditCommits } from "./useDomEditCommits";
@@ -27,6 +28,7 @@ interface RenderedDomEditCommits {
27
28
  hook: ReturnType<typeof useDomEditCommits>;
28
29
  showToast: ReturnType<typeof makeShowToast>;
29
30
  recordEdit: ReturnType<typeof vi.fn<() => Promise<void>>>;
31
+ reloadPreview: ReturnType<typeof vi.fn>;
30
32
  cleanup: () => void;
31
33
  }
32
34
 
@@ -209,6 +211,7 @@ function renderDomEditCommits(
209
211
  const previewIframeRef: MutableRefObject<HTMLIFrameElement | null> = { current: iframe };
210
212
  const projectIdRef: MutableRefObject<string | null> = { current: "p1" };
211
213
  const domEditSaveTimestampRef: MutableRefObject<number> = { current: 0 };
214
+ const reloadPreview = vi.fn();
212
215
 
213
216
  function Probe() {
214
217
  captured.current = useDomEditCommits({
@@ -223,7 +226,7 @@ function renderDomEditCommits(
223
226
  importedFontAssetsRef: { current: options.importedFontAssets ?? [] },
224
227
  projectId: "p1",
225
228
  projectIdRef,
226
- reloadPreview: vi.fn(),
229
+ reloadPreview,
227
230
  domEditSelection: selection,
228
231
  applyDomSelection: vi.fn(),
229
232
  clearDomSelection: vi.fn(),
@@ -244,6 +247,7 @@ function renderDomEditCommits(
244
247
  hook: captured.current,
245
248
  showToast,
246
249
  recordEdit,
250
+ reloadPreview,
247
251
  cleanup: () => {
248
252
  act(() => {
249
253
  root.unmount();
@@ -253,6 +257,155 @@ function renderDomEditCommits(
253
257
  } satisfies RenderedDomEditCommits;
254
258
  }
255
259
 
260
+ describe("useDomEditCommits z-index reorder persistence", () => {
261
+ beforeEach(() => {
262
+ ensureCssEscape();
263
+ vi.clearAllMocks();
264
+ vi.unstubAllGlobals();
265
+ document.body.replaceChildren();
266
+ });
267
+
268
+ it("persists an N-element reorder with one batch POST, one undo entry, and one reload", async () => {
269
+ const original =
270
+ '<div id="a" style="z-index: 1"></div><div id="b" style="z-index: 2"></div><div id="c" style="z-index: 3"></div>';
271
+ const after =
272
+ '<div id="a" style="z-index: 3"></div><div id="b" style="z-index: 2"></div><div id="c" style="z-index: 1"></div>';
273
+ const fetchMock = vi.fn(
274
+ async (
275
+ input: Parameters<typeof fetch>[0],
276
+ _init?: Parameters<typeof fetch>[1],
277
+ ): Promise<Response> => {
278
+ const url = requestUrl(input);
279
+ if (url.includes("/api/projects/p1/files/")) return jsonResponse({ content: original });
280
+ if (url.includes("/file-mutations/patch-elements-batch/")) {
281
+ return jsonResponse({
282
+ ok: true,
283
+ changed: true,
284
+ matched: [true, true, true],
285
+ content: after,
286
+ });
287
+ }
288
+ if (url.includes("/file-mutations/patch-element/")) {
289
+ return jsonResponse({ ok: true, changed: true, matched: true, content: after });
290
+ }
291
+ throw new Error(`Unexpected fetch: ${url}`);
292
+ },
293
+ );
294
+ vi.stubGlobal("fetch", fetchMock);
295
+ const { iframe, element } = createPreviewElement(
296
+ '<div data-hf-id="hf-card"></div><div id="b"></div><div id="c"></div>',
297
+ );
298
+ element.id = "a";
299
+ const elements = [
300
+ element,
301
+ iframe.contentDocument!.getElementById("b")!,
302
+ iframe.contentDocument!.getElementById("c")!,
303
+ ];
304
+ const rendered = renderDomEditCommits(createSelection(element), iframe);
305
+
306
+ try {
307
+ await act(async () => {
308
+ await rendered.hook.handleDomZIndexReorderCommit(
309
+ elements.map((item, index) => ({
310
+ element: item,
311
+ zIndex: 3 - index,
312
+ id: item.id,
313
+ sourceFile: "index.html",
314
+ })),
315
+ "z-reorder:test",
316
+ );
317
+ });
318
+
319
+ const batchPosts = fetchMock.mock.calls.filter(([input]) =>
320
+ requestUrl(input).includes("/file-mutations/patch-elements-batch/"),
321
+ );
322
+ const singlePosts = fetchMock.mock.calls.filter(([input]) =>
323
+ requestUrl(input).includes("/file-mutations/patch-element/"),
324
+ );
325
+ expect(batchPosts).toHaveLength(1);
326
+ expect(singlePosts).toHaveLength(0);
327
+ expect(JSON.parse(String(batchPosts[0]?.[1]?.body))).toEqual({
328
+ patches: expect.arrayContaining([
329
+ expect.objectContaining({ target: expect.objectContaining({ id: "a" }) }),
330
+ expect.objectContaining({ target: expect.objectContaining({ id: "b" }) }),
331
+ expect.objectContaining({ target: expect.objectContaining({ id: "c" }) }),
332
+ ]),
333
+ });
334
+ expect(rendered.recordEdit).toHaveBeenCalledTimes(1);
335
+ expect(rendered.recordEdit).toHaveBeenCalledWith({
336
+ label: "Reorder layers",
337
+ kind: "manual",
338
+ coalesceKey: "z-reorder:test",
339
+ files: { "index.html": { before: original, after } },
340
+ });
341
+ expect(rendered.reloadPreview).toHaveBeenCalledTimes(1);
342
+ } finally {
343
+ rendered.cleanup();
344
+ }
345
+ });
346
+
347
+ it("rolls back live state after a failed batch POST without a disk write-back", async () => {
348
+ const original = '<div id="a" style="z-index: 7"></div><div id="b"></div>';
349
+ const fetchMock = vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
350
+ const url = requestUrl(input);
351
+ if (url.includes("/api/projects/p1/files/")) return jsonResponse({ content: original });
352
+ if (url.includes("/file-mutations/patch-elements-batch/")) {
353
+ return jsonResponse({ error: "batch rejected" }, 500);
354
+ }
355
+ throw new Error(`Unexpected fetch: ${url}`);
356
+ });
357
+ vi.stubGlobal("fetch", fetchMock);
358
+ const writeProjectFile = vi.fn(async () => {});
359
+ const { iframe, element } = createPreviewElement(
360
+ '<div data-hf-id="hf-card" style="z-index: 7"></div><div id="b"></div>',
361
+ );
362
+ element.id = "a";
363
+ const second = iframe.contentDocument!.getElementById("b")!;
364
+ usePlayerStore.getState().setElements([
365
+ { id: "a", tag: "div", start: 0, duration: 1, track: 0, zIndex: 7, hasExplicitZIndex: true },
366
+ { id: "b", tag: "div", start: 0, duration: 1, track: 1, zIndex: 0, hasExplicitZIndex: false },
367
+ ]);
368
+ const rendered = renderDomEditCommits(createSelection(element), iframe, { writeProjectFile });
369
+
370
+ try {
371
+ let rejection: unknown;
372
+ await act(async () => {
373
+ try {
374
+ await rendered.hook.handleDomZIndexReorderCommit([
375
+ { element, zIndex: 2, id: "a", sourceFile: "index.html", key: "a" },
376
+ { element: second, zIndex: 1, id: "b", sourceFile: "index.html", key: "b" },
377
+ ]);
378
+ } catch (error) {
379
+ rejection = error;
380
+ }
381
+ });
382
+
383
+ expect(rejection).toBeInstanceOf(Error);
384
+ expect((rejection as Error).message).toContain("batch rejected");
385
+ expect(element.style.zIndex).toBe("7");
386
+ expect(second.style.zIndex).toBe("");
387
+ expect(
388
+ usePlayerStore
389
+ .getState()
390
+ .elements.map(({ zIndex, hasExplicitZIndex }) => ({ zIndex, hasExplicitZIndex })),
391
+ ).toEqual([
392
+ { zIndex: 7, hasExplicitZIndex: true },
393
+ { zIndex: 0, hasExplicitZIndex: false },
394
+ ]);
395
+ expect(writeProjectFile).not.toHaveBeenCalled();
396
+ expect(rendered.recordEdit).not.toHaveBeenCalled();
397
+ expect(rendered.reloadPreview).not.toHaveBeenCalled();
398
+ expect(
399
+ fetchMock.mock.calls.filter(([input]) =>
400
+ requestUrl(input).includes("/file-mutations/patch-elements-batch/"),
401
+ ),
402
+ ).toHaveLength(1);
403
+ } finally {
404
+ rendered.cleanup();
405
+ }
406
+ });
407
+ });
408
+
256
409
  async function commitStyleAgainst(response: Parameters<typeof stubPatchFetch>[0]) {
257
410
  stubPatchFetch(response);
258
411
  const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
@@ -4,11 +4,19 @@ import { FONT_EXT } from "../utils/mediaTypes";
4
4
 
5
5
  import { trackStudioEvent } from "../utils/studioTelemetry";
6
6
  import { primaryFontFamilyValue } from "../utils/studioFontHelpers";
7
- import { createStudioSaveHttpError } from "../utils/studioSaveDiagnostics";
7
+ import {
8
+ createStudioSaveHttpError,
9
+ StudioSaveHttpError,
10
+ trackStudioSaveFailure,
11
+ } from "../utils/studioSaveDiagnostics";
8
12
  import { buildDomEditPatchTarget, type DomEditSelection } from "../components/editor/domEditing";
9
13
  import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/editor/fontAssets";
10
14
  import type { EditHistoryKind } from "../utils/editHistory";
11
- import type { PersistDomEditOperations } from "./domEditCommitTypes";
15
+ import type {
16
+ CommitDomEditPatchBatches,
17
+ DomEditPatchBatch,
18
+ PersistDomEditOperations,
19
+ } from "./domEditCommitTypes";
12
20
  import type { PatchOperation } from "../utils/sourcePatcher";
13
21
  import {
14
22
  DomEditPersistUnsafeValueError,
@@ -20,6 +28,7 @@ import { useDomEditTextCommits } from "./useDomEditTextCommits";
20
28
  import { useDomGeometryCommits } from "./useDomGeometryCommits";
21
29
  import { useElementLifecycleOps } from "./useElementLifecycleOps";
22
30
  import { formatFieldsSuffix } from "./gsapScriptCommitHelpers";
31
+ import { readProjectFileContent } from "../utils/studioFileHistory";
23
32
 
24
33
  // ── Helpers ──
25
34
 
@@ -48,9 +57,36 @@ interface RecordEditInput {
48
57
  label: string;
49
58
  kind: EditHistoryKind;
50
59
  coalesceKey?: string;
60
+ coalesceMs?: number;
51
61
  files: Record<string, { before: string; after: string }>;
52
62
  }
53
63
 
64
+ async function patchElementBatch(projectId: string, batch: DomEditPatchBatch) {
65
+ const before = await readProjectFileContent(projectId, batch.sourceFile);
66
+ const response = await fetch(
67
+ `/api/projects/${encodeURIComponent(projectId)}/file-mutations/patch-elements-batch/${encodeURIComponent(batch.sourceFile)}`,
68
+ {
69
+ method: "POST",
70
+ headers: { "Content-Type": "application/json" },
71
+ body: JSON.stringify({ patches: batch.patches }),
72
+ },
73
+ );
74
+ if (!response.ok) {
75
+ const rejection = await readErrorResponseBody(response);
76
+ throw new StudioSaveHttpError(formatPatchRejectionMessage(rejection), response.status);
77
+ }
78
+ const result = (await response.json()) as {
79
+ changed?: boolean;
80
+ content?: string;
81
+ };
82
+ return {
83
+ sourceFile: batch.sourceFile,
84
+ changed: result.changed === true,
85
+ before,
86
+ after: typeof result.content === "string" ? result.content : before,
87
+ };
88
+ }
89
+
54
90
  export interface UseDomEditCommitsParams {
55
91
  activeCompPath: string | null;
56
92
  previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
@@ -271,6 +307,7 @@ export function useDomEditCommits({
271
307
  label: options?.label ?? "Edit layer",
272
308
  kind: "manual",
273
309
  coalesceKey: options?.coalesceKey,
310
+ coalesceMs: options?.coalesceMs,
274
311
  files: { [targetPath]: { before: originalContent, after: finalContent } },
275
312
  });
276
313
  forceReloadSdkSession?.();
@@ -292,6 +329,66 @@ export function useDomEditCommits({
292
329
  ],
293
330
  );
294
331
 
332
+ const commitDomEditPatchBatches: CommitDomEditPatchBatches = useCallback(
333
+ (batches, options) =>
334
+ queueDomEditSave(async () => {
335
+ const pid = projectIdRef.current;
336
+ if (!pid) throw new Error("No active project");
337
+ const unsafeFields = batches.flatMap((batch) =>
338
+ batch.patches.flatMap((patch) => findUnsafeDomPatchValues(patch)),
339
+ );
340
+ if (unsafeFields.length > 0) {
341
+ showToast("Couldn't save edit because it contains invalid layout values", "error");
342
+ throw new DomEditPersistUnsafeValueError(
343
+ `DOM patch contains unsafe values: ${formatUnsafeFieldList(unsafeFields)}`,
344
+ { alreadyToasted: true },
345
+ );
346
+ }
347
+
348
+ domEditSaveTimestampRef.current = Date.now();
349
+ const results = await Promise.all(batches.map((batch) => patchElementBatch(pid, batch)));
350
+ const files = Object.fromEntries(
351
+ results
352
+ .filter((result) => result.changed)
353
+ .map((result) => [result.sourceFile, { before: result.before, after: result.after }]),
354
+ );
355
+ if (Object.keys(files).length === 0) return;
356
+ await editHistory.recordEdit({
357
+ label: options.label,
358
+ kind: "manual",
359
+ coalesceKey: options.coalesceKey,
360
+ files,
361
+ });
362
+ forceReloadSdkSession?.();
363
+ reloadPreview();
364
+ }).catch((error) => {
365
+ const alreadyToasted =
366
+ (error instanceof StudioSaveHttpError ||
367
+ error instanceof DomEditPersistUnsafeValueError) &&
368
+ error.alreadyToasted;
369
+ if (!alreadyToasted) {
370
+ showToast(error instanceof Error ? error.message : "Failed to reorder layers", "error");
371
+ }
372
+ trackStudioSaveFailure({
373
+ source: "dom_edit",
374
+ error,
375
+ filePath: batches.map((batch) => batch.sourceFile).join(","),
376
+ mutationType: "z-reorder",
377
+ label: options.label,
378
+ });
379
+ throw error;
380
+ }),
381
+ [
382
+ domEditSaveTimestampRef,
383
+ editHistory,
384
+ forceReloadSdkSession,
385
+ projectIdRef,
386
+ queueDomEditSave,
387
+ reloadPreview,
388
+ showToast,
389
+ ],
390
+ );
391
+
295
392
  // ── Text & style commits (delegated to useDomEditTextCommits) ──
296
393
 
297
394
  const {
@@ -352,7 +449,7 @@ export function useDomEditCommits({
352
449
  onTrySdkDelete,
353
450
  onReorderShadow,
354
451
  forceReloadSdkSession,
355
- commitPositionPatchToHtml,
452
+ commitDomEditPatchBatches,
356
453
  });
357
454
 
358
455
  return {
@@ -15,6 +15,7 @@ interface UseDomEditPositionPatchCommitParams {
15
15
  interface PositionPatchOptions {
16
16
  label: string;
17
17
  coalesceKey: string;
18
+ coalesceMs?: number;
18
19
  skipRefresh?: boolean;
19
20
  }
20
21
 
@@ -30,6 +31,7 @@ export function useDomEditPositionPatchCommit({
30
31
  await persistDomEditOperations(selection, patches, {
31
32
  label: options.label,
32
33
  coalesceKey: options.coalesceKey,
34
+ coalesceMs: options.coalesceMs,
33
35
  skipRefresh: options.skipRefresh ?? true,
34
36
  });
35
37
  }).catch((error) => {
@@ -1,6 +1,6 @@
1
1
  import { useCallback } from "react";
2
2
  import { trackStudioEvent } from "../utils/studioTelemetry";
3
- import type { TimelineElement } from "../player";
3
+ import type { SelectElementOptions, TimelineElement } from "../player";
4
4
  import type { ImportedFontAsset } from "../components/editor/fontAssets";
5
5
  import type { EditHistoryKind } from "../utils/editHistory";
6
6
  import type { RightPanelTab } from "../utils/studioHelpers";
@@ -38,7 +38,7 @@ export interface UseDomEditSessionParams {
38
38
  compositionLoading: boolean;
39
39
  previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
40
40
  timelineElements: TimelineElement[];
41
- setSelectedTimelineElementId: (id: string | null) => void;
41
+ setSelectedTimelineElementId: (id: string | null, options?: SelectElementOptions) => void;
42
42
  setRightCollapsed: (collapsed: boolean) => void;
43
43
  setRightPanelTab: (tab: RightPanelTab) => void;
44
44
  showToast: (message: string, tone?: "error" | "info") => void;