@hyperframes/studio 0.7.55 → 0.7.57

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (274) hide show
  1. package/dist/assets/hyperframes-player--Z69cEkE.js +459 -0
  2. package/dist/assets/{index-CPetwHFV.js → index-Bf1x1y8H.js} +1 -1
  3. package/dist/assets/index-DfmYkU44.js +423 -0
  4. package/dist/assets/{index-BXaqaVKt.js → index-KsfE1bUu.js} +1 -1
  5. package/dist/assets/index-_pqzyxB1.css +1 -0
  6. package/dist/{chunk-SOTCF4DF.js → chunk-5QSIMBEJ.js} +2 -1
  7. package/dist/chunk-5QSIMBEJ.js.map +1 -0
  8. package/dist/{domEditingLayers-2ECJK24D.js → domEditingLayers-6LQGKPOI.js} +2 -2
  9. package/dist/index.d.ts +172 -144
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +43613 -40346
  12. package/dist/index.js.map +1 -1
  13. package/package.json +7 -7
  14. package/src/App.tsx +106 -96
  15. package/src/components/EditorShell.tsx +253 -0
  16. package/src/components/StudioGlobalDragOverlay.tsx +1 -3
  17. package/src/components/StudioLeftSidebar.tsx +13 -3
  18. package/src/components/StudioRightPanel.tsx +8 -3
  19. package/src/components/TimelineToolbar.test.tsx +2 -2
  20. package/src/components/TimelineToolbar.tsx +179 -121
  21. package/src/components/editor/CanvasContextMenu.test.tsx +152 -1
  22. package/src/components/editor/CanvasContextMenu.tsx +33 -18
  23. package/src/components/editor/DomEditCropHandles.test.tsx +74 -3
  24. package/src/components/editor/DomEditCropHandles.tsx +64 -29
  25. package/src/components/editor/DomEditOverlay.test.ts +245 -279
  26. package/src/components/editor/DomEditOverlay.tsx +126 -125
  27. package/src/components/editor/DomEditOverlayHover.test.tsx +90 -0
  28. package/src/components/editor/DomEditRotateHandle.tsx +38 -18
  29. package/src/components/editor/DomEditSelectionChrome.test.tsx +80 -0
  30. package/src/components/editor/DomEditSelectionChrome.tsx +257 -0
  31. package/src/components/editor/LayersPanel.test.ts +66 -2
  32. package/src/components/editor/LayersPanel.tsx +53 -2
  33. package/src/components/editor/OffCanvasIndicators.test.tsx +34 -0
  34. package/src/components/editor/OffCanvasIndicators.tsx +49 -10
  35. package/src/components/editor/SnapGuideOverlay.tsx +28 -23
  36. package/src/components/editor/SnapToolbar.test.tsx +0 -1
  37. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +214 -0
  38. package/src/components/editor/anchoredResizeReleaseShift.test.ts +106 -10
  39. package/src/components/editor/canvasContextMenuZOrder.test.ts +186 -2
  40. package/src/components/editor/canvasContextMenuZOrder.ts +144 -22
  41. package/src/components/editor/domEditNudge.test.ts +80 -0
  42. package/src/components/editor/domEditNudge.ts +44 -0
  43. package/src/components/editor/domEditOverlayGeometry.test.ts +167 -1
  44. package/src/components/editor/domEditOverlayGeometry.ts +280 -20
  45. package/src/components/editor/domEditOverlayGestures.ts +44 -41
  46. package/src/components/editor/domEditOverlayStartGesture.ts +62 -2
  47. package/src/components/editor/domEditResizeLocal.test.ts +131 -0
  48. package/src/components/editor/domEditResizeLocal.ts +125 -0
  49. package/src/components/editor/domEditingDom.ts +1 -1
  50. package/src/components/editor/manualEditsDomPatches.test.ts +47 -0
  51. package/src/components/editor/manualOffsetDrag.ts +55 -12
  52. package/src/components/editor/offCanvasIndicatorGeometry.ts +31 -10
  53. package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +25 -1
  54. package/src/components/editor/propertyPanelColorGradingSection.tsx +15 -21
  55. package/src/components/editor/resizeDraft.ts +108 -0
  56. package/src/components/editor/snapEngine.test.ts +12 -78
  57. package/src/components/editor/snapEngine.ts +13 -53
  58. package/src/components/editor/useCanvasContextMenuState.ts +92 -0
  59. package/src/components/editor/useDomEditNudge.test.tsx +228 -0
  60. package/src/components/editor/useDomEditNudge.ts +254 -0
  61. package/src/components/editor/useDomEditOverlayGestures.ts +115 -116
  62. package/src/components/editor/useDomEditOverlayRects.ts +9 -2
  63. package/src/components/editor/useZOrderCrossedFlash.tsx +68 -0
  64. package/src/components/nle/AssetPreviewOverlay.test.tsx +106 -0
  65. package/src/components/nle/AssetPreviewOverlay.tsx +171 -0
  66. package/src/components/nle/NLEContext.test.ts +144 -0
  67. package/src/components/nle/NLEContext.tsx +328 -0
  68. package/src/components/nle/NLEPreview.tsx +1 -1
  69. package/src/components/nle/PreviewOverlays.tsx +260 -0
  70. package/src/components/nle/PreviewPane.tsx +163 -0
  71. package/src/components/nle/TimelinePane.test.ts +60 -0
  72. package/src/components/nle/TimelinePane.tsx +293 -0
  73. package/src/components/nle/TimelineResizeDivider.tsx +8 -2
  74. package/src/components/nle/useCompositionStack.test.tsx +44 -0
  75. package/src/components/nle/useCompositionStack.ts +17 -9
  76. package/src/components/nle/useTimelineEditCallbacks.ts +217 -0
  77. package/src/components/sidebar/AssetCard.test.tsx +106 -0
  78. package/src/components/sidebar/AssetCard.tsx +343 -0
  79. package/src/components/sidebar/AssetContextMenu.tsx +15 -30
  80. package/src/components/sidebar/AssetsTab.test.ts +96 -0
  81. package/src/components/sidebar/AssetsTab.tsx +71 -206
  82. package/src/components/sidebar/AudioRow.tsx +56 -2
  83. package/src/components/sidebar/BlocksTab.tsx +8 -1
  84. package/src/components/sidebar/LeftSidebar.tsx +4 -1
  85. package/src/components/sidebar/assetHelpers.ts +29 -0
  86. package/src/contexts/DomEditContext.tsx +9 -0
  87. package/src/contexts/StudioContext.tsx +9 -8
  88. package/src/contexts/TimelineEditContext.tsx +3 -7
  89. package/src/hooks/deleteSelectedKeyframes.ts +35 -0
  90. package/src/hooks/domEditCommitTypes.ts +23 -1
  91. package/src/hooks/domSelectionTestHarness.ts +14 -0
  92. package/src/hooks/gestureTransaction.test.ts +311 -0
  93. package/src/hooks/gestureTransaction.ts +199 -0
  94. package/src/hooks/gsapDragCommit.test.ts +141 -41
  95. package/src/hooks/gsapDragCommit.ts +64 -74
  96. package/src/hooks/gsapDragStaticSetHelpers.ts +9 -21
  97. package/src/hooks/gsapResizeIntercept.test.ts +69 -0
  98. package/src/hooks/gsapResizeIntercept.ts +38 -10
  99. package/src/hooks/gsapRuntimeBridge.test.ts +49 -13
  100. package/src/hooks/gsapRuntimeBridge.ts +4 -4
  101. package/src/hooks/gsapScriptCommitTypes.ts +16 -5
  102. package/src/hooks/gsapShared.test.ts +19 -1
  103. package/src/hooks/gsapShared.ts +14 -0
  104. package/src/hooks/patchDocumentRootDuration.test.ts +71 -0
  105. package/src/hooks/timelineEditingGsap.ts +14 -0
  106. package/src/hooks/timelineEditingHelpers.test.ts +125 -1
  107. package/src/hooks/timelineEditingHelpers.ts +53 -292
  108. package/src/hooks/timelineMoveAdapter.test.ts +81 -0
  109. package/src/hooks/timelineMoveAdapter.ts +39 -0
  110. package/src/hooks/timelineTimingSync.test.ts +150 -0
  111. package/src/hooks/timelineTimingSync.ts +412 -0
  112. package/src/hooks/useAnimatedPropertyCommit.test.tsx +36 -8
  113. package/src/hooks/useAnimatedPropertyCommit.ts +31 -26
  114. package/src/hooks/useAppHotkeys.ts +16 -25
  115. package/src/hooks/useBlockHandlers.ts +55 -27
  116. package/src/hooks/useCaptionDetection.ts +2 -0
  117. package/src/hooks/useCompositionDimensions.ts +32 -5
  118. package/src/hooks/useContextMenuDismiss.ts +31 -7
  119. package/src/hooks/useDomEditCommits.test.tsx +245 -1
  120. package/src/hooks/useDomEditCommits.ts +162 -3
  121. package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
  122. package/src/hooks/useDomEditSession.ts +2 -2
  123. package/src/hooks/useDomEditTextCommits.test.tsx +135 -0
  124. package/src/hooks/useDomEditTextCommits.ts +8 -3
  125. package/src/hooks/useDomEditWiring.ts +7 -9
  126. package/src/hooks/useDomGeometryCommits.ts +17 -2
  127. package/src/hooks/useDomSelection.test.ts +2 -36
  128. package/src/hooks/useDomSelection.ts +81 -51
  129. package/src/hooks/useDomSelectionSelectionGuards.test.ts +276 -0
  130. package/src/hooks/useElementLifecycleOps.test.tsx +423 -0
  131. package/src/hooks/useElementLifecycleOps.ts +53 -71
  132. package/src/hooks/useElementPicker.ts +7 -8
  133. package/src/hooks/useEnableKeyframes.test.ts +142 -1
  134. package/src/hooks/useEnableKeyframes.ts +73 -29
  135. package/src/hooks/useGestureCommit.test.tsx +130 -0
  136. package/src/hooks/useGestureCommit.ts +32 -8
  137. package/src/hooks/useGroupCommits.ts +7 -28
  138. package/src/hooks/useGsapAwareEditing.test.tsx +239 -0
  139. package/src/hooks/useGsapAwareEditing.ts +115 -20
  140. package/src/hooks/useGsapKeyframeOps.test.tsx +59 -0
  141. package/src/hooks/useGsapKeyframeOps.ts +38 -10
  142. package/src/hooks/useGsapScriptCommits.test.tsx +40 -1
  143. package/src/hooks/useGsapScriptCommits.ts +134 -50
  144. package/src/hooks/useGsapSelectionHandlers.ts +49 -16
  145. package/src/hooks/useMusicBeatAnalysis.ts +72 -36
  146. package/src/hooks/usePersistentEditHistory.test.ts +33 -0
  147. package/src/hooks/usePersistentEditHistory.ts +92 -58
  148. package/src/hooks/usePreviewPersistence.ts +25 -7
  149. package/src/hooks/useRazorSplit.history.test.tsx +148 -18
  150. package/src/hooks/useRazorSplit.test.ts +269 -0
  151. package/src/hooks/useRazorSplit.testHelpers.ts +64 -0
  152. package/src/hooks/useRazorSplit.ts +107 -36
  153. package/src/hooks/useRenderClipContent.ts +24 -6
  154. package/src/hooks/useStudioContextValue.ts +15 -5
  155. package/src/hooks/useStudioUrlState.ts +2 -4
  156. package/src/hooks/useTimelineAssetDropOps.ts +175 -0
  157. package/src/hooks/useTimelineEditing.test.tsx +398 -241
  158. package/src/hooks/useTimelineEditing.ts +181 -265
  159. package/src/hooks/useTimelineEditingTypes.ts +6 -0
  160. package/src/hooks/useTimelineGroupEditing.ts +167 -132
  161. package/src/index.ts +1 -1
  162. package/src/player/components/ImageThumbnail.test.tsx +173 -0
  163. package/src/player/components/ImageThumbnail.tsx +160 -0
  164. package/src/player/components/PlayerControls.tsx +5 -5
  165. package/src/player/components/PlayheadIndicator.tsx +55 -5
  166. package/src/player/components/Timeline.test.ts +183 -23
  167. package/src/player/components/Timeline.tsx +194 -194
  168. package/src/player/components/TimelineCanvas.tsx +218 -523
  169. package/src/player/components/TimelineClip.tsx +4 -1
  170. package/src/player/components/TimelineClipDiamonds.tsx +9 -40
  171. package/src/player/components/TimelineEmptyState.tsx +3 -1
  172. package/src/player/components/TimelineLanes.tsx +487 -0
  173. package/src/player/components/TimelineOverlays.tsx +122 -0
  174. package/src/player/components/TimelineRuler.tsx +58 -48
  175. package/src/player/components/VideoThumbnail.test.tsx +152 -0
  176. package/src/player/components/VideoThumbnail.tsx +28 -7
  177. package/src/player/components/thumbnailUtils.test.ts +127 -0
  178. package/src/player/components/thumbnailUtils.ts +54 -0
  179. package/src/player/components/timelineCallbacks.ts +18 -18
  180. package/src/player/components/timelineClipChildren.tsx +39 -0
  181. package/src/player/components/timelineClipDragCommit.test.ts +1026 -0
  182. package/src/player/components/timelineClipDragCommit.ts +498 -0
  183. package/src/player/components/timelineClipDragPreview.test.ts +144 -0
  184. package/src/player/components/timelineClipDragPreview.ts +323 -38
  185. package/src/player/components/timelineClipDragTypes.ts +65 -0
  186. package/src/player/components/timelineCollision.test.ts +42 -2
  187. package/src/player/components/timelineDragDrop.ts +69 -60
  188. package/src/player/components/timelineEditCapabilities.ts +52 -0
  189. package/src/player/components/timelineEditing.test.ts +1 -1
  190. package/src/player/components/timelineEditing.ts +76 -59
  191. package/src/player/components/timelineGroupEditing.test.ts +126 -0
  192. package/src/player/components/timelineGroupEditing.ts +133 -0
  193. package/src/player/components/timelineGroupResizeCommit.ts +54 -0
  194. package/src/player/components/timelineLayout.test.ts +95 -23
  195. package/src/player/components/timelineLayout.ts +207 -75
  196. package/src/player/components/timelineMarquee.test.ts +197 -0
  197. package/src/player/components/timelineMarquee.ts +112 -0
  198. package/src/player/components/timelineOptimisticRevision.ts +42 -0
  199. package/src/player/components/timelineRevealScroll.test.ts +133 -0
  200. package/src/player/components/timelineRevealScroll.ts +91 -0
  201. package/src/player/components/timelineStackingSync.test.ts +60 -0
  202. package/src/player/components/timelineStackingSync.ts +27 -2
  203. package/src/player/components/timelineTheme.ts +6 -1
  204. package/src/player/components/timelineTrackPersistPipeline.test.ts +168 -0
  205. package/src/player/components/timelineZones.test.ts +122 -310
  206. package/src/player/components/timelineZones.ts +82 -135
  207. package/src/player/components/useResolvedTimelineEditCallbacks.ts +25 -3
  208. package/src/player/components/useTimelineClipDrag.resize.test.tsx +241 -0
  209. package/src/player/components/useTimelineClipDrag.ts +380 -386
  210. package/src/player/components/useTimelineEditPinning.ts +121 -0
  211. package/src/player/components/useTimelineGeometry.ts +129 -0
  212. package/src/player/components/useTimelinePlayhead.ts +4 -16
  213. package/src/player/components/useTimelineRangeSelection.ts +341 -50
  214. package/src/player/components/useTimelineRevealClip.ts +56 -0
  215. package/src/player/components/useTimelineStackingSync.test.tsx +77 -0
  216. package/src/player/components/useTimelineStackingSync.ts +94 -0
  217. package/src/player/hooks/useExpandedTimelineElements.ts +7 -3
  218. package/src/player/hooks/usePlaybackKeyboard.ts +5 -0
  219. package/src/player/hooks/useTimelinePlayer.seek.test.ts +55 -0
  220. package/src/player/hooks/useTimelinePlayer.test.ts +36 -3
  221. package/src/player/hooks/useTimelinePlayer.ts +52 -53
  222. package/src/player/hooks/useTimelinePlayerLoop.ts +85 -0
  223. package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -1
  224. package/src/player/index.ts +5 -2
  225. package/src/player/lib/runtimeProtocol.test.ts +48 -0
  226. package/src/player/lib/runtimeProtocol.ts +65 -0
  227. package/src/player/lib/time.test.ts +19 -19
  228. package/src/player/lib/time.ts +6 -2
  229. package/src/player/lib/timelineDOM.test.ts +25 -98
  230. package/src/player/lib/timelineDOM.ts +21 -81
  231. package/src/player/lib/timelineElementHelpers.ts +73 -5
  232. package/src/player/lib/timelineIframeHelpers.ts +4 -4
  233. package/src/player/store/playerStore.test.ts +38 -13
  234. package/src/player/store/playerStore.ts +99 -3
  235. package/src/styles/studio.css +27 -0
  236. package/src/utils/assetPreviewDismiss.test.ts +27 -0
  237. package/src/utils/assetPreviewDismiss.ts +29 -0
  238. package/src/utils/assetPreviewStore.ts +33 -0
  239. package/src/utils/blockInstaller.ts +10 -2
  240. package/src/utils/editHistory.test.ts +35 -0
  241. package/src/utils/gsapSoftReload.test.ts +119 -1
  242. package/src/utils/gsapSoftReload.ts +153 -0
  243. package/src/utils/mediaTypes.ts +3 -2
  244. package/src/utils/resizeDebug.ts +55 -0
  245. package/src/utils/rootDuration.test.ts +90 -1
  246. package/src/utils/rootDuration.ts +76 -13
  247. package/src/utils/sdkCutover.ts +4 -0
  248. package/src/utils/studioFileHistory.ts +37 -7
  249. package/src/utils/studioHelpers.ts +62 -0
  250. package/src/utils/studioPreviewHelpers.test.ts +65 -8
  251. package/src/utils/studioPreviewHelpers.ts +10 -0
  252. package/src/utils/studioTelemetry.ts +4 -1
  253. package/src/utils/studioUrlState.test.ts +0 -1
  254. package/src/utils/timelineAssetDrop.test.ts +144 -52
  255. package/src/utils/timelineAssetDrop.ts +67 -29
  256. package/src/utils/timelineDiscovery.ts +0 -17
  257. package/dist/assets/hyperframes-player-BBrKzTGd.js +0 -459
  258. package/dist/assets/index-BRwkMj0w.js +0 -423
  259. package/dist/assets/index-Dq7FEg0K.css +0 -1
  260. package/dist/chunk-SOTCF4DF.js.map +0 -1
  261. package/src/components/StudioPreviewArea.tsx +0 -500
  262. package/src/components/nle/NLELayout.test.ts +0 -12
  263. package/src/components/nle/NLELayout.tsx +0 -591
  264. package/src/player/components/TimelineLayerGutter.tsx +0 -61
  265. package/src/player/components/TimelineSelectionOverlays.tsx +0 -55
  266. package/src/player/components/timelineMarqueeSelection.test.ts +0 -87
  267. package/src/player/components/timelineSnapTargets.test.ts +0 -144
  268. package/src/player/components/timelineSnapTargets.ts +0 -164
  269. package/src/player/components/useTimelineClipDrag.test.tsx +0 -506
  270. package/src/player/components/useTimelineClipGroupDrag.ts +0 -417
  271. package/src/player/components/useTimelineMarqueeSelection.test.tsx +0 -231
  272. package/src/player/components/useTimelineMarqueeSelection.ts +0 -276
  273. package/src/utils/timelineDiscovery.test.ts +0 -90
  274. /package/dist/{domEditingLayers-2ECJK24D.js.map → domEditingLayers-6LQGKPOI.js.map} +0 -0
@@ -0,0 +1,423 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import { usePlayerStore } from "../player";
6
+ import type { DomEditPatchBatch } from "./domEditCommitTypes";
7
+ import { useElementLifecycleOps } from "./useElementLifecycleOps";
8
+ import { mountReactHarness } from "./domSelectionTestHarness";
9
+
10
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
11
+
12
+ afterEach(() => {
13
+ document.body.innerHTML = "";
14
+ usePlayerStore.getState().setElements([]);
15
+ vi.unstubAllGlobals();
16
+ });
17
+
18
+ interface BatchOptions {
19
+ label: string;
20
+ coalesceKey: string;
21
+ skipReload?: boolean;
22
+ }
23
+
24
+ interface CapturedBatchCall {
25
+ batches: DomEditPatchBatch[];
26
+ options: BatchOptions;
27
+ }
28
+
29
+ type ReorderCommit = (
30
+ entries: Array<{
31
+ element: HTMLElement;
32
+ zIndex: number;
33
+ id?: string;
34
+ selector?: string;
35
+ selectorIndex?: number;
36
+ sourceFile: string;
37
+ key?: string;
38
+ }>,
39
+ coalesceKeyOverride?: string,
40
+ actionKind?: string,
41
+ ) => Promise<void>;
42
+
43
+ function renderReorderHook(
44
+ capturedCalls: CapturedBatchCall[],
45
+ onReady: (commit: ReorderCommit) => void,
46
+ ) {
47
+ function Harness() {
48
+ const { handleDomZIndexReorderCommit } = useElementLifecycleOps({
49
+ activeCompPath: "index.html",
50
+ showToast: vi.fn(),
51
+ writeProjectFile: vi.fn(async () => {}),
52
+ domEditSaveTimestampRef: { current: 0 },
53
+ editHistory: { recordEdit: vi.fn(async () => {}) },
54
+ projectIdRef: { current: null },
55
+ reloadPreview: vi.fn(),
56
+ clearDomSelection: vi.fn(),
57
+ commitDomEditPatchBatches: async (batches, options) => {
58
+ capturedCalls.push({ batches, options });
59
+ },
60
+ });
61
+ onReady(handleDomZIndexReorderCommit);
62
+ return null;
63
+ }
64
+ return mountReactHarness(<Harness />);
65
+ }
66
+
67
+ /** Append the element, mount the reorder hook, and run one commit through act. */
68
+ async function runReorderCommit(el: HTMLElement, entries: Parameters<ReorderCommit>[0]) {
69
+ document.body.appendChild(el);
70
+
71
+ const captured: CapturedBatchCall[] = [];
72
+ let commit: ReorderCommit | undefined;
73
+ const root = renderReorderHook(captured, (fn) => (commit = fn));
74
+
75
+ await act(async () => {
76
+ commit!(entries);
77
+ });
78
+
79
+ return { captured, root };
80
+ }
81
+
82
+ describe("useElementLifecycleOps — z-index reorder payload", () => {
83
+ // Regression: an id-less canvas element (e.g. a caption `.sub` div, which
84
+ // carries only data-hf-id + class) once had its absent id coerced to `null`
85
+ // (`entry.id ?? null`). The DOM-patch guard rejects a null `body.target.id`,
86
+ // so "move to back" toasted "unsafe values" and nothing persisted. The target
87
+ // id must be `undefined` (dropped on the wire), letting hfId / selector match.
88
+ it("never sends a null target id for an id-less element", async () => {
89
+ const el = document.createElement("div");
90
+ el.className = "sub clip";
91
+ el.setAttribute("data-hf-id", "hf-card");
92
+
93
+ const { captured, root } = await runReorderCommit(el, [
94
+ {
95
+ element: el,
96
+ zIndex: 0,
97
+ // id intentionally absent — the id-less element case.
98
+ selector: ".sub.clip",
99
+ selectorIndex: 3,
100
+ sourceFile: "index.html",
101
+ },
102
+ ]);
103
+
104
+ const target = captured[0]?.batches[0]?.patches[0]?.target;
105
+ expect(captured).toHaveLength(1);
106
+ expect(target?.id).toBeUndefined();
107
+ expect(target?.id).not.toBeNull();
108
+ // The element stays addressable via hfId (and selector) instead.
109
+ expect(target?.hfId).toBe("hf-card");
110
+
111
+ act(() => root.unmount());
112
+ });
113
+
114
+ it("requests skipReload on every z-reorder persist (live DOM already final)", async () => {
115
+ // The commit applies the z-index (and any injected position) to the live
116
+ // iframe DOM and the store synchronously, so the persisted style-only patch
117
+ // adds nothing the preview doesn't already show — the batch commit is asked
118
+ // to skip the iframe remount. commitDomEditPatchBatches still falls back to
119
+ // reloading when the server can't confirm every patch target matched.
120
+ const el = document.createElement("div");
121
+ el.id = "clip-z";
122
+
123
+ const { captured, root } = await runReorderCommit(el, [
124
+ { element: el, zIndex: 4, id: "clip-z", sourceFile: "index.html" },
125
+ ]);
126
+
127
+ expect(captured).toHaveLength(1);
128
+ expect(captured[0]?.options.skipReload).toBe(true);
129
+
130
+ act(() => root.unmount());
131
+ });
132
+
133
+ it("preserves a real id when the element has one", async () => {
134
+ const el = document.createElement("video");
135
+ el.id = "v-hero";
136
+ el.setAttribute("data-hf-id", "hf-ezl2");
137
+
138
+ const { captured, root } = await runReorderCommit(el, [
139
+ { element: el, zIndex: 2, id: "v-hero", selector: "#v-hero", sourceFile: "index.html" },
140
+ ]);
141
+
142
+ expect(captured).toHaveLength(1);
143
+ expect(captured[0]?.batches[0]?.patches[0]?.target.id).toBe("v-hero");
144
+
145
+ act(() => root.unmount());
146
+ });
147
+
148
+ it("threads the lane gesture key into z-index persistence", async () => {
149
+ const el = document.createElement("div");
150
+ el.id = "clip-a";
151
+ document.body.appendChild(el);
152
+ const captured: CapturedBatchCall[] = [];
153
+ let commit: ReorderCommit | undefined;
154
+ const root = renderReorderHook(captured, (fn) => (commit = fn));
155
+
156
+ await act(async () => {
157
+ await commit!(
158
+ [{ element: el, zIndex: 4, id: "clip-a", sourceFile: "index.html" }],
159
+ "clip-lane-move:7",
160
+ );
161
+ });
162
+
163
+ expect(captured).toHaveLength(1);
164
+ expect(captured[0]?.options.coalesceKey).toBe("clip-lane-move:7");
165
+ act(() => root.unmount());
166
+ });
167
+
168
+ it("creates one batch per source file in a multi-file reorder", async () => {
169
+ const elements = ["clip-a", "clip-b", "clip-c"].map((id) => {
170
+ const element = document.createElement("div");
171
+ element.id = id;
172
+ document.body.appendChild(element);
173
+ return element;
174
+ });
175
+ const captured: CapturedBatchCall[] = [];
176
+ let commit: ReorderCommit | undefined;
177
+ const root = renderReorderHook(captured, (fn) => (commit = fn));
178
+
179
+ await act(async () => {
180
+ await commit!(
181
+ elements.map((element, index) => ({
182
+ element,
183
+ zIndex: index + 1,
184
+ id: element.id,
185
+ sourceFile: index < 2 ? "index.html" : "compositions/scene.html",
186
+ })),
187
+ );
188
+ });
189
+
190
+ expect(captured).toHaveLength(1);
191
+ expect(
192
+ captured[0]?.batches.map(({ sourceFile, patches }) => [sourceFile, patches.length]),
193
+ ).toEqual([
194
+ ["index.html", 2],
195
+ ["compositions/scene.html", 1],
196
+ ]);
197
+ act(() => root.unmount());
198
+ });
199
+
200
+ it("keeps distinct actions in distinct default coalesce keys", async () => {
201
+ const el = document.createElement("div");
202
+ el.id = "clip-a";
203
+ document.body.appendChild(el);
204
+ const captured: CapturedBatchCall[] = [];
205
+ let commit: ReorderCommit | undefined;
206
+ const root = renderReorderHook(captured, (fn) => (commit = fn));
207
+
208
+ await act(async () => {
209
+ await commit!(
210
+ [{ element: el, zIndex: 1, id: "clip-a", sourceFile: "index.html" }],
211
+ undefined,
212
+ "bring-forward",
213
+ );
214
+ await commit!(
215
+ [{ element: el, zIndex: 0, id: "clip-a", sourceFile: "index.html" }],
216
+ undefined,
217
+ "send-backward",
218
+ );
219
+ });
220
+
221
+ // Same element set, different actions — the keys must differ so the two
222
+ // edits never coalesce into one undo step within the coalesce window.
223
+ expect(captured).toHaveLength(2);
224
+ expect(captured[0]?.options.coalesceKey).toBe("z-reorder:bring-forward:clip-a");
225
+ expect(captured[1]?.options.coalesceKey).toBe("z-reorder:send-backward:clip-a");
226
+ act(() => root.unmount());
227
+ });
228
+
229
+ it("updates the store zIndex synchronously for entries that carry a store key", async () => {
230
+ const el = document.createElement("div");
231
+ el.id = "clip-a";
232
+ document.body.appendChild(el);
233
+ usePlayerStore.getState().setElements([
234
+ {
235
+ id: "clip-a",
236
+ key: "index.html#clip-a",
237
+ tag: "div",
238
+ start: 0,
239
+ duration: 1,
240
+ track: 0,
241
+ zIndex: 0,
242
+ hasExplicitZIndex: false,
243
+ },
244
+ ]);
245
+
246
+ let commit: ReorderCommit | undefined;
247
+ let resolveBatch: (() => void) | undefined;
248
+ function Harness() {
249
+ const { handleDomZIndexReorderCommit } = useElementLifecycleOps({
250
+ activeCompPath: "index.html",
251
+ showToast: vi.fn(),
252
+ writeProjectFile: vi.fn(async () => {}),
253
+ domEditSaveTimestampRef: { current: 0 },
254
+ editHistory: { recordEdit: vi.fn(async () => {}) },
255
+ projectIdRef: { current: null },
256
+ reloadPreview: vi.fn(),
257
+ clearDomSelection: vi.fn(),
258
+ // Persist stays pending so the assertion below can only be satisfied
259
+ // by the SYNCHRONOUS store update (the lane-sync path's requirement).
260
+ commitDomEditPatchBatches: () => new Promise((resolve) => (resolveBatch = resolve)),
261
+ });
262
+ commit = handleDomZIndexReorderCommit;
263
+ return null;
264
+ }
265
+ const root = mountReactHarness(<Harness />);
266
+
267
+ let pending: Promise<void> | undefined;
268
+ act(() => {
269
+ pending = commit!([
270
+ {
271
+ element: el,
272
+ zIndex: 5,
273
+ id: "clip-a",
274
+ sourceFile: "index.html",
275
+ key: "index.html#clip-a",
276
+ },
277
+ ]);
278
+ });
279
+
280
+ expect(usePlayerStore.getState().elements[0]).toMatchObject({
281
+ zIndex: 5,
282
+ hasExplicitZIndex: true,
283
+ });
284
+
285
+ resolveBatch?.();
286
+ await act(async () => pending);
287
+ act(() => root.unmount());
288
+ });
289
+
290
+ // The canvas context-menu path: the menu no longer pre-applies styles, so the
291
+ // hook sees the PRISTINE element — prior styles are captured before any
292
+ // mutation and a failed persist restores them exactly (previously the menu's
293
+ // optimistic write made the "rollback" restore the already-mutated values,
294
+ // and the never-persisted position patch silently reverted on reload).
295
+ it("rolls back a static, inline-style-free element to pristine styles on failure", async () => {
296
+ const el = document.createElement("div");
297
+ el.id = "clip-a";
298
+ el.style.position = "static"; // happy-dom computes "" for unset position
299
+ document.body.appendChild(el);
300
+ const failure = new Error("persist failed");
301
+
302
+ let commit: ReorderCommit | undefined;
303
+ function Harness() {
304
+ const { handleDomZIndexReorderCommit } = useElementLifecycleOps({
305
+ activeCompPath: "index.html",
306
+ showToast: vi.fn(),
307
+ writeProjectFile: vi.fn(async () => {}),
308
+ domEditSaveTimestampRef: { current: 0 },
309
+ editHistory: { recordEdit: vi.fn(async () => {}) },
310
+ projectIdRef: { current: null },
311
+ reloadPreview: vi.fn(),
312
+ clearDomSelection: vi.fn(),
313
+ commitDomEditPatchBatches: vi.fn(async () => {
314
+ // The live styles were applied by the hook before persist ran.
315
+ expect(el.style.zIndex).toBe("2");
316
+ expect(el.style.position).toBe("relative");
317
+ throw failure;
318
+ }),
319
+ });
320
+ commit = handleDomZIndexReorderCommit;
321
+ return null;
322
+ }
323
+ const root = mountReactHarness(<Harness />);
324
+
325
+ let rejection: unknown;
326
+ await act(async () => {
327
+ try {
328
+ await commit!(
329
+ [{ element: el, zIndex: 2, id: "clip-a", sourceFile: "index.html" }],
330
+ undefined,
331
+ "bring-forward",
332
+ );
333
+ } catch (error) {
334
+ rejection = error;
335
+ }
336
+ });
337
+
338
+ expect(rejection).toBe(failure);
339
+ expect(el.style.zIndex).toBe("");
340
+ expect(el.style.position).toBe("static");
341
+ act(() => root.unmount());
342
+ });
343
+
344
+ it("rolls back only live and store state after an atomic reorder failure", async () => {
345
+ const writeProjectFile = vi.fn(async () => {});
346
+ const recordEdit = vi.fn(async () => {});
347
+ const forceReloadSdkSession = vi.fn();
348
+ const originalError = new Error("second patch failed");
349
+ const elements = ["clip-a", "clip-b", "clip-c"].map((id, index) => {
350
+ const element = document.createElement("div");
351
+ element.id = id;
352
+ element.style.zIndex = String(index + 10);
353
+ document.body.appendChild(element);
354
+ return element;
355
+ });
356
+ usePlayerStore.getState().setElements(
357
+ elements.map((element, index) => ({
358
+ id: element.id,
359
+ tag: "div",
360
+ start: 0,
361
+ duration: 1,
362
+ track: index,
363
+ zIndex: index + 10,
364
+ hasExplicitZIndex: false,
365
+ })),
366
+ );
367
+
368
+ let commit: ReorderCommit | undefined;
369
+ function Harness() {
370
+ const { handleDomZIndexReorderCommit } = useElementLifecycleOps({
371
+ activeCompPath: "index.html",
372
+ showToast: vi.fn(),
373
+ writeProjectFile,
374
+ domEditSaveTimestampRef: { current: 0 },
375
+ editHistory: { recordEdit },
376
+ projectIdRef: { current: "demo" },
377
+ reloadPreview: vi.fn(),
378
+ clearDomSelection: vi.fn(),
379
+ forceReloadSdkSession,
380
+ commitDomEditPatchBatches: vi.fn(async () => {
381
+ throw originalError;
382
+ }),
383
+ });
384
+ commit = handleDomZIndexReorderCommit;
385
+ return null;
386
+ }
387
+ const root = mountReactHarness(<Harness />);
388
+
389
+ let rejection: unknown;
390
+ await act(async () => {
391
+ try {
392
+ await commit!(
393
+ elements.map((element, index) => ({
394
+ element,
395
+ zIndex: 3 - index,
396
+ id: element.id,
397
+ sourceFile: "index.html",
398
+ key: element.id,
399
+ })),
400
+ "clip-lane-move:failure",
401
+ );
402
+ } catch (error) {
403
+ rejection = error;
404
+ }
405
+ });
406
+
407
+ expect(rejection).toBe(originalError);
408
+ expect(elements.map((element) => element.style.zIndex)).toEqual(["10", "11", "12"]);
409
+ expect(
410
+ usePlayerStore
411
+ .getState()
412
+ .elements.map(({ zIndex, hasExplicitZIndex }) => ({ zIndex, hasExplicitZIndex })),
413
+ ).toEqual([
414
+ { zIndex: 10, hasExplicitZIndex: false },
415
+ { zIndex: 11, hasExplicitZIndex: false },
416
+ { zIndex: 12, hasExplicitZIndex: false },
417
+ ]);
418
+ expect(writeProjectFile).not.toHaveBeenCalled();
419
+ expect(recordEdit).not.toHaveBeenCalled();
420
+ expect(forceReloadSdkSession).not.toHaveBeenCalled();
421
+ act(() => root.unmount());
422
+ });
423
+ });
@@ -1,42 +1,26 @@
1
1
  import { useCallback } from "react";
2
2
  import { usePlayerStore } from "../player";
3
- import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
3
+ import {
4
+ readProjectFileContent,
5
+ saveProjectFilesWithHistory,
6
+ type DomEditCommitBaseParams,
7
+ } from "../utils/studioFileHistory";
4
8
  import { createStudioSaveHttpError } from "../utils/studioSaveDiagnostics";
5
9
  import {
6
10
  buildDomEditPatchTarget,
7
11
  readHfId,
8
12
  type DomEditSelection,
9
13
  } from "../components/editor/domEditing";
10
- import type { PatchOperation } from "../utils/sourcePatcher";
11
- import type { EditHistoryKind } from "../utils/editHistory";
12
-
13
- interface RecordEditInput {
14
- label: string;
15
- kind: EditHistoryKind;
16
- coalesceKey?: string;
17
- files: Record<string, { before: string; after: string }>;
18
- }
14
+ import type { CommitDomEditPatchBatches, DomEditPatchBatch } from "./domEditCommitTypes";
19
15
 
20
- interface UseElementLifecycleOpsParams {
21
- activeCompPath: string | null;
22
- showToast: (message: string, tone?: "error" | "info") => void;
23
- writeProjectFile: (path: string, content: string) => Promise<void>;
24
- domEditSaveTimestampRef: React.MutableRefObject<number>;
25
- editHistory: { recordEdit: (entry: RecordEditInput) => Promise<void> };
26
- projectIdRef: React.MutableRefObject<string | null>;
27
- reloadPreview: () => void;
28
- clearDomSelection: () => void;
16
+ interface UseElementLifecycleOpsParams extends DomEditCommitBaseParams {
29
17
  /** Route delete through SDK when session resolves the hf-id; returns true if handled. */
30
18
  onTrySdkDelete?: (hfId: string, originalContent: string, targetPath: string) => Promise<boolean>;
31
19
  /** Resolver-shadow tripwire for the reordered targets (telemetry-only, decoupled from cutover). */
32
20
  onReorderShadow?: (targets: string[]) => void;
33
21
  /** Resync the SDK session after a server-fallback delete. */
34
22
  forceReloadSdkSession?: () => void;
35
- commitPositionPatchToHtml: (
36
- selection: DomEditSelection,
37
- patches: PatchOperation[],
38
- options: { label: string; coalesceKey: string; skipRefresh?: boolean },
39
- ) => Promise<void>;
23
+ commitDomEditPatchBatches: CommitDomEditPatchBatches;
40
24
  /** Stage 7 Step 3b: called after a successful server-side element delete (shadow). */
41
25
  onElementDeleted?: (selection: DomEditSelection) => void;
42
26
  }
@@ -53,7 +37,7 @@ export function useElementLifecycleOps({
53
37
  onTrySdkDelete,
54
38
  onReorderShadow,
55
39
  forceReloadSdkSession,
56
- commitPositionPatchToHtml,
40
+ commitDomEditPatchBatches,
57
41
  onElementDeleted,
58
42
  }: UseElementLifecycleOpsParams) {
59
43
  // fallow-ignore-next-line complexity
@@ -66,17 +50,7 @@ export function useElementLifecycleOps({
66
50
 
67
51
  const targetPath = selection.sourceFile || activeCompPath || "index.html";
68
52
  try {
69
- const response = await fetch(
70
- `/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
71
- );
72
- if (!response.ok) {
73
- throw await createStudioSaveHttpError(response, `Failed to read ${targetPath}`);
74
- }
75
-
76
- const data = (await response.json()) as { content?: string };
77
- const originalContent = data.content;
78
- if (typeof originalContent !== "string")
79
- throw new Error(`Missing file contents for ${targetPath}`);
53
+ const originalContent = await readProjectFileContent(pid, targetPath);
80
54
 
81
55
  const patchTarget = buildDomEditPatchTarget(selection);
82
56
  if (!patchTarget.id && !patchTarget.selector && !patchTarget.hfId) {
@@ -156,8 +130,8 @@ export function useElementLifecycleOps({
156
130
  ],
157
131
  );
158
132
 
159
- // ponytail: z-index reorder writes inline-style patches via commitPositionPatchToHtml
160
- // persistDomEditOperations onTrySdkPersist, so it is already SDK-cut-over as setStyle.
133
+ // ponytail: z-index reorder folds every element patch for a source file in memory and sends
134
+ // one patch-elements-batch request, so each file is persisted with one atomic disk write.
161
135
  // No SDK reorder/reparent op exists; DOM sibling order stays server-authoritative if ever needed.
162
136
  const handleDomZIndexReorderCommit = useCallback(
163
137
  // fallow-ignore-next-line complexity
@@ -171,6 +145,8 @@ export function useElementLifecycleOps({
171
145
  sourceFile: string;
172
146
  key?: string;
173
147
  }>,
148
+ gestureCoalesceKey?: string,
149
+ actionKind?: string,
174
150
  ) => {
175
151
  if (entries.length === 0) return Promise.resolve();
176
152
  // Resolver shadow (telemetry-only, decoupled from cutover): record whether
@@ -178,11 +154,16 @@ export function useElementLifecycleOps({
178
154
  onReorderShadow?.(
179
155
  entries.map((e) => readHfId(e.element)).filter((id): id is string => id != null),
180
156
  );
181
- const coalesceKey = `z-reorder:${entries.map((e) => e.id ?? e.selector ?? e.element.getAttribute("data-hf-id") ?? "el").join(":")}`;
182
- const saves: Array<Promise<void>> = [];
157
+ // The default key carries the action kind so two DIFFERENT actions on the
158
+ // same element set (e.g. "bring-forward" then "send-backward" within the
159
+ // coalesce window) never merge into one undo step. Callers that share a
160
+ // gesture (lane moves) pass an explicit gestureCoalesceKey instead.
161
+ const coalesceKey =
162
+ gestureCoalesceKey ??
163
+ `z-reorder:${actionKind ?? "reorder"}:${entries.map((e) => e.id ?? e.selector ?? e.element.getAttribute("data-hf-id") ?? "el").join(":")}`;
164
+ const patchesBySourceFile = new Map<string, DomEditPatchBatch["patches"]>();
183
165
  const rollbacks: Array<() => void> = [];
184
- for (let i = 0; i < entries.length; i++) {
185
- const entry = entries[i];
166
+ for (const entry of entries) {
186
167
  const priorZIndex = entry.element.style.zIndex;
187
168
  const priorPosition = entry.element.style.position;
188
169
  const priorStoreEntry = entry.key
@@ -218,39 +199,40 @@ export function useElementLifecycleOps({
218
199
  });
219
200
  }
220
201
  });
221
- saves.push(
222
- commitPositionPatchToHtml(
223
- {
224
- element: entry.element,
225
- id: entry.id ?? null,
226
- hfId: readHfId(entry.element),
227
- selector: entry.selector,
228
- selectorIndex: entry.selectorIndex,
229
- sourceFile: entry.sourceFile,
230
- } as unknown as DomEditSelection,
231
- patches,
232
- {
233
- label: "Reorder layers",
234
- coalesceKey,
235
- skipRefresh: i < entries.length - 1,
236
- },
237
- ),
238
- );
202
+ const filePatches = patchesBySourceFile.get(entry.sourceFile) ?? [];
203
+ filePatches.push({
204
+ target: buildDomEditPatchTarget({
205
+ id: entry.id,
206
+ hfId: readHfId(entry.element),
207
+ selector: entry.selector,
208
+ selectorIndex: entry.selectorIndex,
209
+ }),
210
+ operations: patches,
211
+ });
212
+ patchesBySourceFile.set(entry.sourceFile, filePatches);
239
213
  }
240
- // Resolves once every z-index patch is persisted so a same-file timing write
214
+ const batches = [...patchesBySourceFile].map(([sourceFile, patches]) => ({
215
+ sourceFile,
216
+ patches,
217
+ }));
218
+ // Resolves once every source-file batch is persisted so a same-file timing write
241
219
  // can be ordered after it (see applyTimelineStackingReorder callers).
242
- return Promise.allSettled(saves).then((settled) => {
243
- const rejected = settled.find(
244
- (result): result is PromiseRejectedResult => result.status === "rejected",
245
- );
246
- if (rejected) {
247
- for (const rollback of rollbacks) rollback();
248
- return Promise.reject(rejected.reason);
249
- }
250
- return undefined;
220
+ //
221
+ // skipReload: the live iframe DOM and the player store already hold the
222
+ // final z state (applied synchronously above), and the persisted patch is
223
+ // inline-style-only — a full iframe remount would only blink the preview.
224
+ // commitDomEditPatchBatches still falls back to reloading whenever the
225
+ // server reports an unmatched patch target (live DOM ≠ disk).
226
+ return commitDomEditPatchBatches(batches, {
227
+ label: "Reorder layers",
228
+ coalesceKey,
229
+ skipReload: true,
230
+ }).catch((error) => {
231
+ for (const rollback of rollbacks) rollback();
232
+ throw error;
251
233
  });
252
234
  },
253
- [commitPositionPatchToHtml, onReorderShadow],
235
+ [commitDomEditPatchBatches, onReorderShadow],
254
236
  );
255
237
 
256
238
  return {
@@ -1,6 +1,10 @@
1
1
  import { useState, useCallback, useRef } from "react";
2
2
  import { useMountEffect } from "./useMountEffect";
3
3
  import { resolveSourceFile, applyPatch } from "../utils/sourcePatcher";
4
+ import {
5
+ acceptStudioRuntimeMessage,
6
+ postRuntimeControlMessage,
7
+ } from "../player/lib/runtimeProtocol";
4
8
 
5
9
  export interface PickedElement {
6
10
  id: string | null;
@@ -65,10 +69,7 @@ export function useElementPicker(
65
69
 
66
70
  const enablePick = useCallback(() => {
67
71
  try {
68
- getActiveIframe()?.contentWindow?.postMessage(
69
- { source: "hf-parent", type: "control", action: "enable-pick-mode" },
70
- "*",
71
- );
72
+ postRuntimeControlMessage(getActiveIframe()?.contentWindow, "enable-pick-mode");
72
73
  setIsPickMode(true);
73
74
  } catch {
74
75
  /* cross-origin */
@@ -77,10 +78,7 @@ export function useElementPicker(
77
78
 
78
79
  const disablePick = useCallback(() => {
79
80
  try {
80
- getActiveIframe()?.contentWindow?.postMessage(
81
- { source: "hf-parent", type: "control", action: "disable-pick-mode" },
82
- "*",
83
- );
81
+ postRuntimeControlMessage(getActiveIframe()?.contentWindow, "disable-pick-mode");
84
82
  } catch {
85
83
  /* cross-origin */
86
84
  }
@@ -96,6 +94,7 @@ export function useElementPicker(
96
94
  const handleMessage = (e: MessageEvent) => {
97
95
  const data = e.data;
98
96
  if (data?.source !== "hf-preview") return;
97
+ if (!acceptStudioRuntimeMessage(data)) return;
99
98
  // Accept events from either the primary iframe or the active override
100
99
  const activeIframe = getActiveIframe();
101
100
  if (!activeIframe) return;