@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
@@ -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,246 @@ 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 NO iframe 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
+ // FIX: a z-only reorder must NOT remount the preview iframe ("the blink").
342
+ // The live DOM + store already hold the final state and the server matched
343
+ // every style-only patch, so the reload is provably redundant.
344
+ expect(rendered.reloadPreview).not.toHaveBeenCalled();
345
+ } finally {
346
+ rendered.cleanup();
347
+ }
348
+ });
349
+
350
+ it("falls back to reloading when the server response omits matched[]", async () => {
351
+ // Without a matched[] confirmation the persist can't be proven in sync with
352
+ // the live DOM — the skip-reload path must not engage.
353
+ const original = '<div id="a" style="z-index: 1"></div>';
354
+ const after = '<div id="a" style="z-index: 2"></div>';
355
+ const fetchMock = vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
356
+ const url = requestUrl(input);
357
+ if (url.includes("/api/projects/p1/files/")) return jsonResponse({ content: original });
358
+ if (url.includes("/file-mutations/patch-elements-batch/")) {
359
+ return jsonResponse({ ok: true, changed: true, content: after });
360
+ }
361
+ throw new Error(`Unexpected fetch: ${url}`);
362
+ });
363
+ vi.stubGlobal("fetch", fetchMock);
364
+ const { iframe, element } = createPreviewElement();
365
+ element.id = "a";
366
+ const rendered = renderDomEditCommits(createSelection(element), iframe);
367
+
368
+ try {
369
+ await act(async () => {
370
+ await rendered.hook.handleDomZIndexReorderCommit([
371
+ { element, zIndex: 2, id: "a", sourceFile: "index.html" },
372
+ ]);
373
+ });
374
+
375
+ expect(rendered.recordEdit).toHaveBeenCalledTimes(1);
376
+ expect(rendered.reloadPreview).toHaveBeenCalledTimes(1);
377
+ } finally {
378
+ rendered.cleanup();
379
+ }
380
+ });
381
+
382
+ it("warns and reports telemetry for unmatched batch patches without throwing", async () => {
383
+ // The server reports per-patch matched[]: #b was not found in the source.
384
+ // The matched subset persisted, so the commit must complete (no rollback of
385
+ // applied state) while surfacing the partial failure. An unmatched target
386
+ // also means the live DOM shows z-order the disk lacks, so the skip-reload
387
+ // path must NOT engage — the reload reconverges the preview with disk.
388
+ const original = '<div id="a" style="z-index: 1"></div>';
389
+ const after = '<div id="a" style="z-index: 2"></div>';
390
+ const fetchMock = vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
391
+ const url = requestUrl(input);
392
+ if (url.includes("/api/projects/p1/files/")) return jsonResponse({ content: original });
393
+ if (url.includes("/file-mutations/patch-elements-batch/")) {
394
+ return jsonResponse({ ok: true, changed: true, matched: [true, false], content: after });
395
+ }
396
+ throw new Error(`Unexpected fetch: ${url}`);
397
+ });
398
+ vi.stubGlobal("fetch", fetchMock);
399
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
400
+ const { iframe, element } = createPreviewElement(
401
+ '<div data-hf-id="hf-card"></div><div id="b"></div>',
402
+ );
403
+ element.id = "a";
404
+ const second = iframe.contentDocument!.getElementById("b")!;
405
+ const rendered = renderDomEditCommits(createSelection(element), iframe);
406
+
407
+ try {
408
+ await act(async () => {
409
+ await rendered.hook.handleDomZIndexReorderCommit([
410
+ { element, zIndex: 2, id: "a", sourceFile: "index.html" },
411
+ { element: second, zIndex: 1, id: "b", sourceFile: "index.html" },
412
+ ]);
413
+ });
414
+
415
+ // No throw: the applied live state stays, the matched subset is recorded.
416
+ expect(element.style.zIndex).toBe("2");
417
+ expect(second.style.zIndex).toBe("1");
418
+ expect(rendered.recordEdit).toHaveBeenCalledTimes(1);
419
+ expect(rendered.reloadPreview).toHaveBeenCalledTimes(1);
420
+ expect(warnSpy).toHaveBeenCalledWith(
421
+ expect.stringContaining("could not match 1 patch target(s) in index.html"),
422
+ "b",
423
+ );
424
+ expect(trackStudioEvent).toHaveBeenCalledWith(
425
+ "save_failure",
426
+ expect.objectContaining({
427
+ mutation_type: "z-reorder-unmatched",
428
+ file_path: "index.html",
429
+ error_message: expect.stringContaining("b"),
430
+ }),
431
+ );
432
+ } finally {
433
+ warnSpy.mockRestore();
434
+ rendered.cleanup();
435
+ }
436
+ });
437
+
438
+ it("rolls back live state after a failed batch POST without a disk write-back", async () => {
439
+ const original = '<div id="a" style="z-index: 7"></div><div id="b"></div>';
440
+ const fetchMock = vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
441
+ const url = requestUrl(input);
442
+ if (url.includes("/api/projects/p1/files/")) return jsonResponse({ content: original });
443
+ if (url.includes("/file-mutations/patch-elements-batch/")) {
444
+ return jsonResponse({ error: "batch rejected" }, 500);
445
+ }
446
+ throw new Error(`Unexpected fetch: ${url}`);
447
+ });
448
+ vi.stubGlobal("fetch", fetchMock);
449
+ const writeProjectFile = vi.fn(async () => {});
450
+ const { iframe, element } = createPreviewElement(
451
+ '<div data-hf-id="hf-card" style="z-index: 7"></div><div id="b"></div>',
452
+ );
453
+ element.id = "a";
454
+ const second = iframe.contentDocument!.getElementById("b")!;
455
+ usePlayerStore.getState().setElements([
456
+ { id: "a", tag: "div", start: 0, duration: 1, track: 0, zIndex: 7, hasExplicitZIndex: true },
457
+ { id: "b", tag: "div", start: 0, duration: 1, track: 1, zIndex: 0, hasExplicitZIndex: false },
458
+ ]);
459
+ const rendered = renderDomEditCommits(createSelection(element), iframe, { writeProjectFile });
460
+
461
+ try {
462
+ let rejection: unknown;
463
+ await act(async () => {
464
+ try {
465
+ await rendered.hook.handleDomZIndexReorderCommit([
466
+ { element, zIndex: 2, id: "a", sourceFile: "index.html", key: "a" },
467
+ { element: second, zIndex: 1, id: "b", sourceFile: "index.html", key: "b" },
468
+ ]);
469
+ } catch (error) {
470
+ rejection = error;
471
+ }
472
+ });
473
+
474
+ expect(rejection).toBeInstanceOf(Error);
475
+ expect((rejection as Error).message).toContain("batch rejected");
476
+ expect(element.style.zIndex).toBe("7");
477
+ expect(second.style.zIndex).toBe("");
478
+ expect(
479
+ usePlayerStore
480
+ .getState()
481
+ .elements.map(({ zIndex, hasExplicitZIndex }) => ({ zIndex, hasExplicitZIndex })),
482
+ ).toEqual([
483
+ { zIndex: 7, hasExplicitZIndex: true },
484
+ { zIndex: 0, hasExplicitZIndex: false },
485
+ ]);
486
+ expect(writeProjectFile).not.toHaveBeenCalled();
487
+ expect(rendered.recordEdit).not.toHaveBeenCalled();
488
+ expect(rendered.reloadPreview).not.toHaveBeenCalled();
489
+ expect(
490
+ fetchMock.mock.calls.filter(([input]) =>
491
+ requestUrl(input).includes("/file-mutations/patch-elements-batch/"),
492
+ ),
493
+ ).toHaveLength(1);
494
+ } finally {
495
+ rendered.cleanup();
496
+ }
497
+ });
498
+ });
499
+
256
500
  async function commitStyleAgainst(response: Parameters<typeof stubPatchFetch>[0]) {
257
501
  stubPatchFetch(response);
258
502
  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,87 @@ 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
+ /** Human-readable identifier for a batch patch target (for the unmatched warning). */
65
+ function describeBatchPatchTarget(patch: DomEditPatchBatch["patches"][number]): string {
66
+ return patch.target.id ?? patch.target.hfId ?? patch.target.selector ?? "(unaddressed)";
67
+ }
68
+
69
+ /**
70
+ * Surface server-reported unmatched patches. The matched subset already
71
+ * persisted, so this must NOT throw (a throw would roll back applied state) —
72
+ * warn and emit save-failure telemetry with a distinct reason instead.
73
+ */
74
+ function reportUnmatchedBatchPatches(batch: DomEditPatchBatch, matched: boolean[]): void {
75
+ const unmatchedIds = batch.patches
76
+ .filter((_, index) => matched[index] === false)
77
+ .map(describeBatchPatchTarget);
78
+ if (unmatchedIds.length === 0) return;
79
+ console.warn(
80
+ `[studio] z-index reorder: server could not match ${unmatchedIds.length} patch target(s) in ` +
81
+ `${batch.sourceFile} (their z-order will revert on reload):`,
82
+ unmatchedIds.join(", "),
83
+ );
84
+ trackStudioSaveFailure({
85
+ source: "dom_edit",
86
+ error: new Error(`Batch patch target(s) unmatched: ${unmatchedIds.join(", ")}`),
87
+ filePath: batch.sourceFile,
88
+ mutationType: "z-reorder-unmatched",
89
+ });
90
+ }
91
+
92
+ async function patchElementBatch(projectId: string, batch: DomEditPatchBatch) {
93
+ const before = await readProjectFileContent(projectId, batch.sourceFile);
94
+ const response = await fetch(
95
+ `/api/projects/${encodeURIComponent(projectId)}/file-mutations/patch-elements-batch/${encodeURIComponent(batch.sourceFile)}`,
96
+ {
97
+ method: "POST",
98
+ headers: { "Content-Type": "application/json" },
99
+ body: JSON.stringify({ patches: batch.patches }),
100
+ },
101
+ );
102
+ if (!response.ok) {
103
+ const rejection = await readErrorResponseBody(response);
104
+ throw new StudioSaveHttpError(formatPatchRejectionMessage(rejection), response.status);
105
+ }
106
+ const result = (await response.json()) as {
107
+ changed?: boolean;
108
+ matched?: boolean[];
109
+ content?: string;
110
+ };
111
+ if (Array.isArray(result.matched)) reportUnmatchedBatchPatches(batch, result.matched);
112
+ return {
113
+ sourceFile: batch.sourceFile,
114
+ changed: result.changed === true,
115
+ // Skip-reload safety: the persist is only provably in sync with the live
116
+ // DOM when the server confirmed EVERY patch target matched. A missing /
117
+ // short matched[] is treated as unknown (false) so the caller falls back
118
+ // to reloading rather than silently diverging from disk.
119
+ allMatched:
120
+ Array.isArray(result.matched) &&
121
+ result.matched.length === batch.patches.length &&
122
+ result.matched.every(Boolean),
123
+ before,
124
+ after: typeof result.content === "string" ? result.content : before,
125
+ };
126
+ }
127
+
128
+ /**
129
+ * A batch is reload-skippable only when it is style-only: every operation is an
130
+ * `inline-style` write. The z-reorder commit applies those exact styles to the
131
+ * live iframe DOM synchronously, so persisting them adds nothing the preview
132
+ * doesn't already show. Any other op type (attribute / text-content / …) can
133
+ * have server-side semantics the live DOM hasn't mirrored — reload for those.
134
+ */
135
+ function batchesAreInlineStyleOnly(batches: DomEditPatchBatch[]): boolean {
136
+ return batches.every((batch) =>
137
+ batch.patches.every((patch) => patch.operations.every((op) => op.type === "inline-style")),
138
+ );
139
+ }
140
+
54
141
  export interface UseDomEditCommitsParams {
55
142
  activeCompPath: string | null;
56
143
  previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
@@ -271,6 +358,7 @@ export function useDomEditCommits({
271
358
  label: options?.label ?? "Edit layer",
272
359
  kind: "manual",
273
360
  coalesceKey: options?.coalesceKey,
361
+ coalesceMs: options?.coalesceMs,
274
362
  files: { [targetPath]: { before: originalContent, after: finalContent } },
275
363
  });
276
364
  forceReloadSdkSession?.();
@@ -292,6 +380,77 @@ export function useDomEditCommits({
292
380
  ],
293
381
  );
294
382
 
383
+ const commitDomEditPatchBatches: CommitDomEditPatchBatches = useCallback(
384
+ (batches, options) =>
385
+ queueDomEditSave(async () => {
386
+ const pid = projectIdRef.current;
387
+ if (!pid) throw new Error("No active project");
388
+ const unsafeFields = batches.flatMap((batch) =>
389
+ batch.patches.flatMap((patch) => findUnsafeDomPatchValues(patch)),
390
+ );
391
+ if (unsafeFields.length > 0) {
392
+ showToast("Couldn't save edit because it contains invalid layout values", "error");
393
+ throw new DomEditPersistUnsafeValueError(
394
+ `DOM patch contains unsafe values: ${formatUnsafeFieldList(unsafeFields)}`,
395
+ { alreadyToasted: true },
396
+ );
397
+ }
398
+
399
+ domEditSaveTimestampRef.current = Date.now();
400
+ const results = await Promise.all(batches.map((batch) => patchElementBatch(pid, batch)));
401
+ const files = Object.fromEntries(
402
+ results
403
+ .filter((result) => result.changed)
404
+ .map((result) => [result.sourceFile, { before: result.before, after: result.after }]),
405
+ );
406
+ if (Object.keys(files).length === 0) return;
407
+ await editHistory.recordEdit({
408
+ label: options.label,
409
+ kind: "manual",
410
+ coalesceKey: options.coalesceKey,
411
+ files,
412
+ });
413
+ forceReloadSdkSession?.();
414
+ // A z-only reorder already applied its inline styles to the live iframe
415
+ // DOM (and the store) synchronously, so remounting the iframe here only
416
+ // produces a visible blink. Skip the reload when the caller asked for it
417
+ // AND the persist is provably in sync: style-only ops, every target
418
+ // matched. Any unmatched patch means the live DOM now shows state disk
419
+ // doesn't hold — reload so the preview reconverges. (The SSE/file-watcher
420
+ // reload is independently suppressed by domEditSaveTimestampRef above.)
421
+ const skipSafe =
422
+ options.skipReload === true &&
423
+ batchesAreInlineStyleOnly(batches) &&
424
+ results.every((result) => result.allMatched);
425
+ if (!skipSafe) reloadPreview();
426
+ }).catch((error) => {
427
+ const alreadyToasted =
428
+ (error instanceof StudioSaveHttpError ||
429
+ error instanceof DomEditPersistUnsafeValueError) &&
430
+ error.alreadyToasted;
431
+ if (!alreadyToasted) {
432
+ showToast(error instanceof Error ? error.message : "Failed to reorder layers", "error");
433
+ }
434
+ trackStudioSaveFailure({
435
+ source: "dom_edit",
436
+ error,
437
+ filePath: batches.map((batch) => batch.sourceFile).join(","),
438
+ mutationType: "z-reorder",
439
+ label: options.label,
440
+ });
441
+ throw error;
442
+ }),
443
+ [
444
+ domEditSaveTimestampRef,
445
+ editHistory,
446
+ forceReloadSdkSession,
447
+ projectIdRef,
448
+ queueDomEditSave,
449
+ reloadPreview,
450
+ showToast,
451
+ ],
452
+ );
453
+
295
454
  // ── Text & style commits (delegated to useDomEditTextCommits) ──
296
455
 
297
456
  const {
@@ -352,7 +511,7 @@ export function useDomEditCommits({
352
511
  onTrySdkDelete,
353
512
  onReorderShadow,
354
513
  forceReloadSdkSession,
355
- commitPositionPatchToHtml,
514
+ commitDomEditPatchBatches,
356
515
  });
357
516
 
358
517
  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;
@@ -0,0 +1,135 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from "react";
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+ import type { DomEditSelection, DomEditTextField } from "../components/editor/domEditing";
5
+ import { mountReactHarness } from "./domSelectionTestHarness";
6
+ import { useDomEditTextCommits, type UseDomEditTextCommitsParams } from "./useDomEditTextCommits";
7
+
8
+ Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
9
+
10
+ interface Deferred<T> {
11
+ promise: Promise<T>;
12
+ resolve: (value: T | PromiseLike<T>) => void;
13
+ reject: (reason?: unknown) => void;
14
+ }
15
+
16
+ function createDeferred<T>(): Deferred<T> {
17
+ let resolve: Deferred<T>["resolve"] | undefined;
18
+ let reject: Deferred<T>["reject"] | undefined;
19
+ const promise = new Promise<T>((resolvePromise, rejectPromise) => {
20
+ resolve = resolvePromise;
21
+ reject = rejectPromise;
22
+ });
23
+ if (!resolve || !reject) throw new Error("deferred callbacks were not initialized");
24
+ return { promise, resolve, reject };
25
+ }
26
+
27
+ function textField(value: string): DomEditTextField {
28
+ return {
29
+ key: "self",
30
+ label: "Text",
31
+ value,
32
+ tagName: "div",
33
+ attributes: [],
34
+ inlineStyles: {},
35
+ computedStyles: {},
36
+ source: "self",
37
+ };
38
+ }
39
+
40
+ function selectionFor(element: HTMLElement): DomEditSelection {
41
+ return {
42
+ id: element.id,
43
+ element,
44
+ label: "Card",
45
+ tagName: "div",
46
+ sourceFile: "index.html",
47
+ compositionPath: "index.html",
48
+ isCompositionHost: false,
49
+ isInsideLockedComposition: false,
50
+ boundingBox: { x: 0, y: 0, width: 100, height: 100 },
51
+ textContent: element.textContent,
52
+ dataAttributes: {},
53
+ inlineStyles: {},
54
+ computedStyles: {},
55
+ textFields: [textField(element.textContent ?? "")],
56
+ capabilities: {
57
+ canSelect: true,
58
+ canEditStyles: true,
59
+ canCrop: true,
60
+ canMove: true,
61
+ canResize: true,
62
+ canApplyManualOffset: true,
63
+ canApplyManualSize: true,
64
+ canApplyManualRotation: true,
65
+ },
66
+ };
67
+ }
68
+
69
+ let cleanup: (() => void) | null = null;
70
+
71
+ function renderTextCommitHook(params: UseDomEditTextCommitsParams) {
72
+ const captured: { hook: ReturnType<typeof useDomEditTextCommits> | null } = { hook: null };
73
+ function TextCommitProbe() {
74
+ captured.hook = useDomEditTextCommits(params);
75
+ return null;
76
+ }
77
+ const root = mountReactHarness(<TextCommitProbe />);
78
+ cleanup = () => act(() => root.unmount());
79
+ if (!captured.hook) throw new Error("hook did not initialize");
80
+ return captured.hook;
81
+ }
82
+
83
+ afterEach(() => {
84
+ cleanup?.();
85
+ cleanup = null;
86
+ document.body.replaceChildren();
87
+ vi.restoreAllMocks();
88
+ });
89
+
90
+ describe("useDomEditTextCommits", () => {
91
+ it("does not let a stale failed fields commit revert newer text", async () => {
92
+ const iframe = document.createElement("iframe");
93
+ document.body.append(iframe);
94
+ const doc = iframe.contentDocument;
95
+ if (!doc) throw new Error("expected iframe document");
96
+ doc.body.innerHTML = '<div id="card">Original</div>';
97
+ const element = doc.getElementById("card");
98
+ const HTMLElementCtor = doc.defaultView?.HTMLElement;
99
+ if (!HTMLElementCtor || !(element instanceof HTMLElementCtor)) {
100
+ throw new Error("expected preview element");
101
+ }
102
+ vi.spyOn(console, "warn").mockImplementation(() => {});
103
+ const selection = selectionFor(element);
104
+ const stalePersist = createDeferred<void>();
105
+ const persistDomEditOperations = vi
106
+ .fn()
107
+ .mockImplementationOnce(() => stalePersist.promise)
108
+ .mockResolvedValueOnce(undefined);
109
+ const hook = renderTextCommitHook({
110
+ activeCompPath: "index.html",
111
+ previewIframeRef: { current: iframe },
112
+ showToast: vi.fn(),
113
+ domEditSelection: selection,
114
+ applyDomSelection: vi.fn(),
115
+ refreshDomEditSelectionFromPreview: vi.fn(),
116
+ buildDomSelectionFromTarget: vi.fn(async () => null),
117
+ persistDomEditOperations,
118
+ resolveImportedFontAsset: () => null,
119
+ });
120
+
121
+ let staleCommit: Promise<void> | undefined;
122
+ act(() => {
123
+ staleCommit = hook.commitDomTextFields(selection, [textField("Stale")]);
124
+ });
125
+ await act(async () => {
126
+ await hook.commitDomTextFields(selection, [textField("Newest")]);
127
+ });
128
+ stalePersist.reject(new Error("stale request failed"));
129
+ await act(async () => {
130
+ await staleCommit;
131
+ });
132
+
133
+ expect(element.innerHTML).toBe("Newest");
134
+ });
135
+ });