@hyperframes/studio 0.7.54 → 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 (262) hide show
  1. package/dist/assets/{index-uBY329wb.js → index-BWnOxAiH.js} +1 -1
  2. package/dist/assets/{index-CMHYjEZ5.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 +40582 -37803
  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/CanvasContextMenu.test.tsx +115 -0
  21. package/src/components/editor/CanvasContextMenu.tsx +198 -0
  22. package/src/components/editor/DomEditCropHandles.test.tsx +74 -3
  23. package/src/components/editor/DomEditCropHandles.tsx +64 -29
  24. package/src/components/editor/DomEditOverlay.test.ts +245 -279
  25. package/src/components/editor/DomEditOverlay.tsx +158 -126
  26. package/src/components/editor/DomEditOverlayHover.test.tsx +90 -0
  27. package/src/components/editor/DomEditRotateHandle.tsx +38 -18
  28. package/src/components/editor/DomEditSelectionChrome.test.tsx +80 -0
  29. package/src/components/editor/DomEditSelectionChrome.tsx +257 -0
  30. package/src/components/editor/LayersPanel.test.ts +66 -2
  31. package/src/components/editor/LayersPanel.tsx +43 -1
  32. package/src/components/editor/OffCanvasIndicators.test.tsx +34 -0
  33. package/src/components/editor/OffCanvasIndicators.tsx +49 -10
  34. package/src/components/editor/SnapGuideOverlay.tsx +28 -23
  35. package/src/components/editor/SnapToolbar.test.tsx +0 -1
  36. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +214 -0
  37. package/src/components/editor/anchoredResizeReleaseShift.test.ts +208 -0
  38. package/src/components/editor/canvasContextMenuZOrder.test.ts +435 -0
  39. package/src/components/editor/canvasContextMenuZOrder.ts +352 -0
  40. package/src/components/editor/domEditNudge.test.ts +80 -0
  41. package/src/components/editor/domEditNudge.ts +44 -0
  42. package/src/components/editor/domEditOverlayGeometry.test.ts +167 -1
  43. package/src/components/editor/domEditOverlayGeometry.ts +280 -20
  44. package/src/components/editor/domEditOverlayGestures.ts +44 -41
  45. package/src/components/editor/domEditOverlayStartGesture.ts +62 -2
  46. package/src/components/editor/domEditResizeLocal.test.ts +131 -0
  47. package/src/components/editor/domEditResizeLocal.ts +125 -0
  48. package/src/components/editor/domEditingDom.ts +1 -1
  49. package/src/components/editor/manualEditsDomPatches.test.ts +47 -0
  50. package/src/components/editor/manualOffsetDrag.ts +55 -12
  51. package/src/components/editor/offCanvasIndicatorGeometry.ts +31 -10
  52. package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +25 -1
  53. package/src/components/editor/resizeDraft.ts +108 -0
  54. package/src/components/editor/snapEngine.test.ts +12 -78
  55. package/src/components/editor/snapEngine.ts +13 -53
  56. package/src/components/editor/useDomEditNudge.test.tsx +228 -0
  57. package/src/components/editor/useDomEditNudge.ts +254 -0
  58. package/src/components/editor/useDomEditOverlayGestures.ts +115 -116
  59. package/src/components/editor/useDomEditOverlayRects.ts +9 -2
  60. package/src/components/nle/AssetPreviewOverlay.tsx +147 -0
  61. package/src/components/nle/NLEContext.test.ts +144 -0
  62. package/src/components/nle/NLEContext.tsx +328 -0
  63. package/src/components/nle/NLEPreview.tsx +1 -1
  64. package/src/components/nle/PreviewOverlays.tsx +240 -0
  65. package/src/components/nle/PreviewPane.tsx +163 -0
  66. package/src/components/nle/TimelinePane.test.ts +60 -0
  67. package/src/components/nle/TimelinePane.tsx +293 -0
  68. package/src/components/nle/TimelineResizeDivider.tsx +8 -2
  69. package/src/components/nle/useCompositionStack.test.tsx +44 -0
  70. package/src/components/nle/useCompositionStack.ts +17 -9
  71. package/src/components/nle/useTimelineEditCallbacks.ts +217 -0
  72. package/src/components/sidebar/AssetCard.tsx +326 -0
  73. package/src/components/sidebar/AssetContextMenu.tsx +15 -30
  74. package/src/components/sidebar/AssetsTab.test.ts +96 -0
  75. package/src/components/sidebar/AssetsTab.tsx +71 -206
  76. package/src/components/sidebar/AudioRow.tsx +39 -2
  77. package/src/components/sidebar/BlocksTab.tsx +8 -1
  78. package/src/components/sidebar/LeftSidebar.tsx +4 -1
  79. package/src/components/sidebar/assetHelpers.ts +29 -0
  80. package/src/contexts/DomEditContext.tsx +9 -0
  81. package/src/contexts/StudioContext.tsx +9 -8
  82. package/src/contexts/TimelineEditContext.tsx +3 -7
  83. package/src/hooks/deleteSelectedKeyframes.ts +35 -0
  84. package/src/hooks/domEditCommitTypes.ts +12 -1
  85. package/src/hooks/domSelectionTestHarness.ts +14 -0
  86. package/src/hooks/gestureTransaction.test.ts +311 -0
  87. package/src/hooks/gestureTransaction.ts +199 -0
  88. package/src/hooks/gsapDragCommit.test.ts +141 -41
  89. package/src/hooks/gsapDragCommit.ts +64 -74
  90. package/src/hooks/gsapDragStaticSetHelpers.ts +9 -21
  91. package/src/hooks/gsapResizeIntercept.test.ts +69 -0
  92. package/src/hooks/gsapResizeIntercept.ts +38 -10
  93. package/src/hooks/gsapRuntimeBridge.test.ts +49 -13
  94. package/src/hooks/gsapRuntimeBridge.ts +4 -4
  95. package/src/hooks/gsapScriptCommitTypes.ts +16 -5
  96. package/src/hooks/gsapShared.test.ts +19 -1
  97. package/src/hooks/gsapShared.ts +14 -0
  98. package/src/hooks/patchDocumentRootDuration.test.ts +71 -0
  99. package/src/hooks/timelineEditingGsap.ts +14 -0
  100. package/src/hooks/timelineEditingHelpers.test.ts +34 -1
  101. package/src/hooks/timelineEditingHelpers.ts +22 -48
  102. package/src/hooks/timelineMoveAdapter.test.ts +77 -0
  103. package/src/hooks/timelineMoveAdapter.ts +37 -0
  104. package/src/hooks/useAnimatedPropertyCommit.test.tsx +36 -8
  105. package/src/hooks/useAnimatedPropertyCommit.ts +31 -26
  106. package/src/hooks/useAppHotkeys.ts +16 -25
  107. package/src/hooks/useBlockHandlers.ts +55 -27
  108. package/src/hooks/useContextMenuDismiss.ts +31 -7
  109. package/src/hooks/useDomEditCommits.test.tsx +154 -1
  110. package/src/hooks/useDomEditCommits.ts +100 -3
  111. package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
  112. package/src/hooks/useDomEditSession.ts +2 -2
  113. package/src/hooks/useDomEditTextCommits.test.tsx +135 -0
  114. package/src/hooks/useDomEditTextCommits.ts +8 -3
  115. package/src/hooks/useDomEditWiring.ts +7 -9
  116. package/src/hooks/useDomGeometryCommits.ts +17 -2
  117. package/src/hooks/useDomSelection.test.ts +2 -36
  118. package/src/hooks/useDomSelection.ts +81 -51
  119. package/src/hooks/useDomSelectionSelectionGuards.test.ts +276 -0
  120. package/src/hooks/useElementLifecycleOps.test.tsx +258 -0
  121. package/src/hooks/useElementLifecycleOps.ts +40 -71
  122. package/src/hooks/useEnableKeyframes.test.ts +142 -1
  123. package/src/hooks/useEnableKeyframes.ts +73 -29
  124. package/src/hooks/useGestureCommit.test.tsx +130 -0
  125. package/src/hooks/useGestureCommit.ts +32 -8
  126. package/src/hooks/useGroupCommits.ts +7 -28
  127. package/src/hooks/useGsapAwareEditing.test.tsx +239 -0
  128. package/src/hooks/useGsapAwareEditing.ts +115 -20
  129. package/src/hooks/useGsapKeyframeOps.test.tsx +59 -0
  130. package/src/hooks/useGsapKeyframeOps.ts +38 -10
  131. package/src/hooks/useGsapScriptCommits.test.tsx +40 -1
  132. package/src/hooks/useGsapScriptCommits.ts +134 -50
  133. package/src/hooks/useGsapSelectionHandlers.ts +49 -16
  134. package/src/hooks/useMusicBeatAnalysis.ts +72 -36
  135. package/src/hooks/usePersistentEditHistory.test.ts +33 -0
  136. package/src/hooks/usePersistentEditHistory.ts +92 -58
  137. package/src/hooks/usePreviewPersistence.ts +25 -7
  138. package/src/hooks/useRazorSplit.history.test.tsx +303 -0
  139. package/src/hooks/useRazorSplit.test.ts +269 -0
  140. package/src/hooks/useRazorSplit.testHelpers.ts +64 -0
  141. package/src/hooks/useRazorSplit.ts +107 -36
  142. package/src/hooks/useRenderClipContent.ts +24 -6
  143. package/src/hooks/useStudioContextValue.ts +15 -5
  144. package/src/hooks/useStudioUrlState.ts +2 -4
  145. package/src/hooks/useTimelineEditing.test.tsx +20 -29
  146. package/src/hooks/useTimelineEditingTypes.ts +6 -0
  147. package/src/hooks/useTimelineGroupEditing.ts +18 -4
  148. package/src/index.ts +1 -1
  149. package/src/player/components/ImageThumbnail.test.tsx +173 -0
  150. package/src/player/components/ImageThumbnail.tsx +160 -0
  151. package/src/player/components/PlayheadIndicator.tsx +50 -5
  152. package/src/player/components/Timeline.test.ts +102 -19
  153. package/src/player/components/Timeline.tsx +194 -194
  154. package/src/player/components/TimelineCanvas.tsx +207 -521
  155. package/src/player/components/TimelineClip.tsx +4 -1
  156. package/src/player/components/TimelineClipDiamonds.tsx +9 -40
  157. package/src/player/components/TimelineEmptyState.tsx +3 -1
  158. package/src/player/components/TimelineLanes.tsx +487 -0
  159. package/src/player/components/TimelineOverlays.tsx +122 -0
  160. package/src/player/components/VideoThumbnail.test.tsx +152 -0
  161. package/src/player/components/VideoThumbnail.tsx +28 -7
  162. package/src/player/components/thumbnailUtils.test.ts +127 -0
  163. package/src/player/components/thumbnailUtils.ts +54 -0
  164. package/src/player/components/timelineCallbacks.ts +18 -18
  165. package/src/player/components/timelineClipChildren.tsx +39 -0
  166. package/src/player/components/timelineClipDragCommit.test.ts +943 -0
  167. package/src/player/components/timelineClipDragCommit.ts +401 -0
  168. package/src/player/components/timelineClipDragPreview.test.ts +144 -0
  169. package/src/player/components/timelineClipDragPreview.ts +323 -38
  170. package/src/player/components/timelineClipDragTypes.ts +65 -0
  171. package/src/player/components/timelineCollision.test.ts +477 -0
  172. package/src/player/components/timelineCollision.ts +263 -0
  173. package/src/player/components/timelineDragDrop.ts +69 -60
  174. package/src/player/components/timelineEditCapabilities.ts +52 -0
  175. package/src/player/components/timelineEditing.test.ts +1 -1
  176. package/src/player/components/timelineEditing.ts +76 -59
  177. package/src/player/components/timelineGroupEditing.test.ts +126 -0
  178. package/src/player/components/timelineGroupEditing.ts +133 -0
  179. package/src/player/components/timelineGroupResizeCommit.ts +54 -0
  180. package/src/player/components/timelineLayout.test.ts +95 -23
  181. package/src/player/components/timelineLayout.ts +120 -61
  182. package/src/player/components/timelineMarquee.test.ts +197 -0
  183. package/src/player/components/timelineMarquee.ts +112 -0
  184. package/src/player/components/timelineMultiDragPreview.test.ts +127 -0
  185. package/src/player/components/timelineMultiDragPreview.ts +106 -0
  186. package/src/player/components/timelineOptimisticRevision.ts +42 -0
  187. package/src/player/components/timelineSnapping.test.ts +134 -0
  188. package/src/player/components/timelineSnapping.ts +110 -0
  189. package/src/player/components/timelineStackingSync.test.ts +244 -0
  190. package/src/player/components/timelineStackingSync.ts +331 -0
  191. package/src/player/components/timelineTheme.ts +6 -1
  192. package/src/player/components/timelineZones.test.ts +425 -0
  193. package/src/player/components/timelineZones.ts +198 -0
  194. package/src/player/components/timelineZoom.test.ts +67 -0
  195. package/src/player/components/timelineZoom.ts +51 -0
  196. package/src/player/components/useResolvedTimelineEditCallbacks.ts +25 -3
  197. package/src/player/components/useTimelineClipDrag.resize.test.tsx +241 -0
  198. package/src/player/components/useTimelineClipDrag.ts +380 -386
  199. package/src/player/components/useTimelineEditPinning.ts +121 -0
  200. package/src/player/components/useTimelineGeometry.ts +129 -0
  201. package/src/player/components/useTimelinePlayhead.ts +4 -16
  202. package/src/player/components/useTimelineRangeSelection.ts +341 -50
  203. package/src/player/components/useTimelineStackingSync.test.tsx +67 -0
  204. package/src/player/components/useTimelineStackingSync.ts +83 -0
  205. package/src/player/hooks/useExpandedTimelineElements.ts +0 -3
  206. package/src/player/hooks/usePlaybackKeyboard.ts +5 -0
  207. package/src/player/hooks/useTimelinePlayer.ts +48 -53
  208. package/src/player/hooks/useTimelinePlayerLoop.ts +85 -0
  209. package/src/player/hooks/useTimelineSyncCallbacks.test.ts +219 -0
  210. package/src/player/hooks/useTimelineSyncCallbacks.ts +104 -4
  211. package/src/player/index.ts +3 -2
  212. package/src/player/lib/time.test.ts +19 -19
  213. package/src/player/lib/time.ts +6 -2
  214. package/src/player/lib/timelineDOM.test.ts +25 -98
  215. package/src/player/lib/timelineDOM.ts +12 -81
  216. package/src/player/lib/timelineElementHelpers.ts +54 -5
  217. package/src/player/lib/timelineIframeHelpers.ts +2 -0
  218. package/src/player/store/playerStore.ts +62 -3
  219. package/src/styles/studio.css +27 -0
  220. package/src/utils/assetClickBehavior.test.ts +104 -0
  221. package/src/utils/assetClickBehavior.ts +75 -0
  222. package/src/utils/assetPreviewStore.ts +33 -0
  223. package/src/utils/blockInstaller.ts +10 -2
  224. package/src/utils/canvasNudgeGate.test.ts +31 -0
  225. package/src/utils/canvasNudgeGate.ts +32 -0
  226. package/src/utils/editHistory.test.ts +35 -0
  227. package/src/utils/gsapSoftReload.test.ts +119 -1
  228. package/src/utils/gsapSoftReload.ts +153 -0
  229. package/src/utils/mediaTypes.ts +3 -2
  230. package/src/utils/resizeDebug.ts +55 -0
  231. package/src/utils/rootDuration.test.ts +90 -1
  232. package/src/utils/rootDuration.ts +76 -13
  233. package/src/utils/sdkCutover.ts +4 -0
  234. package/src/utils/studioFileHistory.ts +37 -7
  235. package/src/utils/studioHelpers.ts +62 -0
  236. package/src/utils/studioPreviewHelpers.test.ts +65 -8
  237. package/src/utils/studioPreviewHelpers.ts +10 -0
  238. package/src/utils/studioTelemetry.ts +4 -1
  239. package/src/utils/studioUiPreferences.test.ts +38 -0
  240. package/src/utils/studioUiPreferences.ts +27 -0
  241. package/src/utils/studioUrlState.test.ts +0 -1
  242. package/src/utils/timelineAssetDrop.ts +62 -0
  243. package/src/utils/timelineDiscovery.ts +0 -17
  244. package/src/utils/timelineInspector.test.ts +121 -0
  245. package/src/utils/timelineInspector.ts +32 -1
  246. package/dist/assets/index-Dq7FEg0K.css +0 -1
  247. package/dist/assets/index-pRhCpGPz.js +0 -423
  248. package/dist/chunk-SOTCF4DF.js.map +0 -1
  249. package/src/components/StudioPreviewArea.tsx +0 -500
  250. package/src/components/nle/NLELayout.test.ts +0 -12
  251. package/src/components/nle/NLELayout.tsx +0 -591
  252. package/src/player/components/TimelineLayerGutter.tsx +0 -61
  253. package/src/player/components/TimelineSelectionOverlays.tsx +0 -55
  254. package/src/player/components/timelineMarqueeSelection.test.ts +0 -87
  255. package/src/player/components/timelineSnapTargets.test.ts +0 -144
  256. package/src/player/components/timelineSnapTargets.ts +0 -164
  257. package/src/player/components/useTimelineClipDrag.test.tsx +0 -506
  258. package/src/player/components/useTimelineClipGroupDrag.ts +0 -417
  259. package/src/player/components/useTimelineMarqueeSelection.test.tsx +0 -231
  260. package/src/player/components/useTimelineMarqueeSelection.ts +0 -276
  261. package/src/utils/timelineDiscovery.test.ts +0 -90
  262. /package/dist/{domEditingLayers-2ECJK24D.js.map → domEditingLayers-6LQGKPOI.js.map} +0 -0
@@ -0,0 +1,276 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
+ import { installReactActEnvironment, makeSelection } from "./domSelectionTestHarness";
7
+ import type { DomEditSelection } from "../components/editor/domEditing";
8
+ import type { SelectElementOptions, TimelineElement } from "../player";
9
+ import { usePlayerStore } from "../player";
10
+
11
+ installReactActEnvironment();
12
+
13
+ // ── Module mocks ──
14
+ // Control the async selection-resolution ordering so the race guard can be
15
+ // exercised deterministically, and neutralise the DOM re-apply side effect.
16
+ vi.mock("../components/editor/manualEdits", () => ({
17
+ reapplyPositionEditsAfterSeek: () => undefined,
18
+ }));
19
+
20
+ const deferreds = new Map<string, { promise: Promise<DomEditSelection>; resolve: () => void }>();
21
+
22
+ function deferredFor(el: HTMLElement): Promise<DomEditSelection> {
23
+ const id = el.id;
24
+ let resolveFn: () => void = () => undefined;
25
+ const promise = new Promise<DomEditSelection>((resolve) => {
26
+ resolveFn = () => resolve(makeSelection(id.toUpperCase(), el));
27
+ });
28
+ deferreds.set(id, { promise, resolve: resolveFn });
29
+ return promise;
30
+ }
31
+
32
+ vi.mock("../components/editor/domEditing", async (importOriginal) => {
33
+ const actual = await importOriginal<typeof import("../components/editor/domEditing")>();
34
+ return {
35
+ ...actual,
36
+ findElementForTimelineElement: (doc: Document, element: { id?: string }) =>
37
+ element.id ? doc.getElementById(element.id) : null,
38
+ resolveDomEditSelection: (startEl: HTMLElement | null) =>
39
+ startEl ? deferredFor(startEl) : Promise.resolve(null),
40
+ };
41
+ });
42
+
43
+ // Imported after the mocks so the hook picks up the mocked modules.
44
+ const { useDomSelection } = await import("./useDomSelection");
45
+
46
+ interface HarnessProps {
47
+ rightPanelTab: "design" | "variables";
48
+ setRightPanelTab: (tab: "design" | "variables") => void;
49
+ iframe: HTMLIFrameElement | null;
50
+ timelineElements: TimelineElement[];
51
+ setSelectedTimelineElementId?: (id: string | null, options?: SelectElementOptions) => void;
52
+ }
53
+
54
+ function renderHarness(props: HarnessProps) {
55
+ const host = document.createElement("div");
56
+ document.body.append(host);
57
+ const root = createRoot(host);
58
+ let currentHook: ReturnType<typeof useDomSelection> | null = null;
59
+
60
+ function Harness() {
61
+ currentHook = useDomSelection({
62
+ projectId: "project-1",
63
+ activeCompPath: "index.html",
64
+ isMasterView: false,
65
+ compIdToSrc: new Map(),
66
+ captionEditMode: false,
67
+ previewIframeRef: { current: props.iframe },
68
+ timelineElements: props.timelineElements,
69
+ setSelectedTimelineElementId: props.setSelectedTimelineElementId ?? vi.fn(),
70
+ setRightCollapsed: vi.fn(),
71
+ setRightPanelTab: props.setRightPanelTab,
72
+ previewIframe: props.iframe,
73
+ refreshKey: 0,
74
+ rightPanelTab: props.rightPanelTab,
75
+ });
76
+ return null;
77
+ }
78
+
79
+ act(() => root.render(React.createElement(Harness)));
80
+
81
+ return {
82
+ current: () => {
83
+ if (!currentHook) throw new Error("Expected hook result");
84
+ return currentHook;
85
+ },
86
+ cleanup: () => {
87
+ act(() => root.unmount());
88
+ host.remove();
89
+ },
90
+ };
91
+ }
92
+
93
+ describe("useDomSelection — Variables tab preservation", () => {
94
+ it("does not yank the user off the Variables tab when selecting on canvas", () => {
95
+ const setRightPanelTab = vi.fn();
96
+ const el = document.createElement("div");
97
+ el.id = "headline";
98
+ const harness = renderHarness({
99
+ rightPanelTab: "variables",
100
+ setRightPanelTab,
101
+ iframe: null,
102
+ timelineElements: [],
103
+ });
104
+
105
+ act(() => harness.current().applyDomSelection(makeSelection("Headline", el)));
106
+
107
+ expect(setRightPanelTab).not.toHaveBeenCalled();
108
+ harness.cleanup();
109
+ });
110
+
111
+ it("switches to the Design tab when not on Variables (control)", () => {
112
+ const setRightPanelTab = vi.fn();
113
+ const el = document.createElement("div");
114
+ el.id = "headline";
115
+ const harness = renderHarness({
116
+ rightPanelTab: "design",
117
+ setRightPanelTab,
118
+ iframe: null,
119
+ timelineElements: [],
120
+ });
121
+
122
+ act(() => harness.current().applyDomSelection(makeSelection("Headline", el)));
123
+
124
+ expect(setRightPanelTab).toHaveBeenCalledWith("design");
125
+ harness.cleanup();
126
+ });
127
+ });
128
+
129
+ describe("useDomSelection — timeline-select race guard", () => {
130
+ beforeEach(() => deferreds.clear());
131
+ afterEach(() => deferreds.clear());
132
+
133
+ it("a stale async resolution never clobbers a newer selection", async () => {
134
+ const iframe = document.createElement("iframe");
135
+ document.body.append(iframe);
136
+ const doc = iframe.contentDocument!;
137
+ const elA = doc.createElement("div");
138
+ elA.id = "a";
139
+ const elB = doc.createElement("div");
140
+ elB.id = "b";
141
+ doc.body.append(elA, elB);
142
+
143
+ const elementA: TimelineElement = { id: "a", tag: "div", start: 0, duration: 1, track: 0 };
144
+ const elementB: TimelineElement = { id: "b", tag: "div", start: 0, duration: 1, track: 0 };
145
+
146
+ const harness = renderHarness({
147
+ rightPanelTab: "design",
148
+ setRightPanelTab: vi.fn(),
149
+ iframe,
150
+ timelineElements: [elementA, elementB],
151
+ });
152
+
153
+ // Fire A then B; both suspend on their (pending) resolveDomEditSelection.
154
+ let pA: Promise<void> = Promise.resolve();
155
+ let pB: Promise<void> = Promise.resolve();
156
+ act(() => {
157
+ pA = harness.current().handleTimelineElementSelect(elementA);
158
+ pB = harness.current().handleTimelineElementSelect(elementB);
159
+ });
160
+
161
+ // Resolve the NEWER select (B) first, then the older one (A) last.
162
+ await act(async () => {
163
+ deferreds.get("b")?.resolve();
164
+ await pB;
165
+ });
166
+ await act(async () => {
167
+ deferreds.get("a")?.resolve();
168
+ await pA;
169
+ });
170
+
171
+ // The stale A resolution must be dropped: B wins.
172
+ expect(harness.current().domEditSelection?.id).toBe("b");
173
+ harness.cleanup();
174
+ iframe.remove();
175
+ });
176
+ });
177
+
178
+ describe("useDomSelection — marquee multi-select survives the late async primary", () => {
179
+ beforeEach(() => {
180
+ deferreds.clear();
181
+ const store = usePlayerStore.getState();
182
+ store.setSelectedElementId(null);
183
+ store.clearSelectedElementIds();
184
+ });
185
+ afterEach(() => {
186
+ deferreds.clear();
187
+ const store = usePlayerStore.getState();
188
+ store.setSelectedElementId(null);
189
+ store.clearSelectedElementIds();
190
+ });
191
+
192
+ function timelineEl(id: string): TimelineElement {
193
+ return { id, domId: id, tag: "div", start: 0, duration: 1, track: 0 };
194
+ }
195
+
196
+ // Reproduces the reported regression: a marquee over N clips leaves N members in
197
+ // selectedElementIds, then the inspector-open notify (finishMarquee →
198
+ // handleTimelineElementSelect → applyDomSelection) resolves LATE and writes the
199
+ // primary again. Before the fix that late write collapsed the set to one clip.
200
+ it("keeps all N members when a late primary-set targets a set member", async () => {
201
+ const iframe = document.createElement("iframe");
202
+ document.body.append(iframe);
203
+ const doc = iframe.contentDocument!;
204
+ for (const id of ["a", "b", "c"]) {
205
+ const el = doc.createElement("div");
206
+ el.id = id;
207
+ doc.body.append(el);
208
+ }
209
+
210
+ const store = usePlayerStore.getState();
211
+ // Marquee end state: primary written first, then the full set (real ordering).
212
+ store.setSelectedElementId("a");
213
+ store.setSelectedElementIds(new Set(["a", "b", "c"]));
214
+
215
+ const harness = renderHarness({
216
+ rightPanelTab: "design",
217
+ setRightPanelTab: vi.fn(),
218
+ iframe,
219
+ timelineElements: [timelineEl("a"), timelineEl("b"), timelineEl("c")],
220
+ // Wire the real store write so applyDomSelection's collapse decision is exercised.
221
+ setSelectedTimelineElementId: usePlayerStore.getState().setSelectedElementId,
222
+ });
223
+
224
+ let pending: Promise<void> = Promise.resolve();
225
+ act(() => {
226
+ pending = harness.current().handleTimelineElementSelect(timelineEl("a"));
227
+ });
228
+ await act(async () => {
229
+ deferreds.get("a")?.resolve();
230
+ await pending;
231
+ });
232
+
233
+ expect(usePlayerStore.getState().selectedElementIds.size).toBe(3);
234
+ expect(usePlayerStore.getState().selectedElementId).toBe("a");
235
+ harness.cleanup();
236
+ iframe.remove();
237
+ });
238
+
239
+ it("collapses the set when a late primary-set targets a non-member (fresh click)", async () => {
240
+ const iframe = document.createElement("iframe");
241
+ document.body.append(iframe);
242
+ const doc = iframe.contentDocument!;
243
+ for (const id of ["a", "b", "d"]) {
244
+ const el = doc.createElement("div");
245
+ el.id = id;
246
+ doc.body.append(el);
247
+ }
248
+
249
+ const store = usePlayerStore.getState();
250
+ // Stale set left over from a previous gesture.
251
+ store.setSelectedElementId("a");
252
+ store.setSelectedElementIds(new Set(["a", "b"]));
253
+
254
+ const harness = renderHarness({
255
+ rightPanelTab: "design",
256
+ setRightPanelTab: vi.fn(),
257
+ iframe,
258
+ timelineElements: [timelineEl("a"), timelineEl("b"), timelineEl("d")],
259
+ setSelectedTimelineElementId: usePlayerStore.getState().setSelectedElementId,
260
+ });
261
+
262
+ let pending: Promise<void> = Promise.resolve();
263
+ act(() => {
264
+ pending = harness.current().handleTimelineElementSelect(timelineEl("d"));
265
+ });
266
+ await act(async () => {
267
+ deferreds.get("d")?.resolve();
268
+ await pending;
269
+ });
270
+
271
+ expect(usePlayerStore.getState().selectedElementIds.size).toBe(0);
272
+ expect(usePlayerStore.getState().selectedElementId).toBe("d");
273
+ harness.cleanup();
274
+ iframe.remove();
275
+ });
276
+ });
@@ -0,0 +1,258 @@
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
+ }
22
+
23
+ interface CapturedBatchCall {
24
+ batches: DomEditPatchBatch[];
25
+ options: BatchOptions;
26
+ }
27
+
28
+ type ReorderCommit = (
29
+ entries: Array<{
30
+ element: HTMLElement;
31
+ zIndex: number;
32
+ id?: string;
33
+ selector?: string;
34
+ selectorIndex?: number;
35
+ sourceFile: string;
36
+ key?: string;
37
+ }>,
38
+ coalesceKeyOverride?: string,
39
+ ) => Promise<void>;
40
+
41
+ function renderReorderHook(
42
+ capturedCalls: CapturedBatchCall[],
43
+ onReady: (commit: ReorderCommit) => void,
44
+ ) {
45
+ function Harness() {
46
+ const { handleDomZIndexReorderCommit } = useElementLifecycleOps({
47
+ activeCompPath: "index.html",
48
+ showToast: vi.fn(),
49
+ writeProjectFile: vi.fn(async () => {}),
50
+ domEditSaveTimestampRef: { current: 0 },
51
+ editHistory: { recordEdit: vi.fn(async () => {}) },
52
+ projectIdRef: { current: null },
53
+ reloadPreview: vi.fn(),
54
+ clearDomSelection: vi.fn(),
55
+ commitDomEditPatchBatches: async (batches, options) => {
56
+ capturedCalls.push({ batches, options });
57
+ },
58
+ });
59
+ onReady(handleDomZIndexReorderCommit);
60
+ return null;
61
+ }
62
+ return mountReactHarness(<Harness />);
63
+ }
64
+
65
+ /** Append the element, mount the reorder hook, and run one commit through act. */
66
+ async function runReorderCommit(el: HTMLElement, entries: Parameters<ReorderCommit>[0]) {
67
+ document.body.appendChild(el);
68
+
69
+ const captured: CapturedBatchCall[] = [];
70
+ let commit: ReorderCommit | undefined;
71
+ const root = renderReorderHook(captured, (fn) => (commit = fn));
72
+
73
+ await act(async () => {
74
+ commit!(entries);
75
+ });
76
+
77
+ return { captured, root };
78
+ }
79
+
80
+ describe("useElementLifecycleOps — z-index reorder payload", () => {
81
+ // Regression: an id-less canvas element (e.g. a caption `.sub` div, which
82
+ // carries only data-hf-id + class) once had its absent id coerced to `null`
83
+ // (`entry.id ?? null`). The DOM-patch guard rejects a null `body.target.id`,
84
+ // so "move to back" toasted "unsafe values" and nothing persisted. The target
85
+ // id must be `undefined` (dropped on the wire), letting hfId / selector match.
86
+ it("never sends a null target id for an id-less element", async () => {
87
+ const el = document.createElement("div");
88
+ el.className = "sub clip";
89
+ el.setAttribute("data-hf-id", "hf-card");
90
+
91
+ const { captured, root } = await runReorderCommit(el, [
92
+ {
93
+ element: el,
94
+ zIndex: 0,
95
+ // id intentionally absent — the id-less element case.
96
+ selector: ".sub.clip",
97
+ selectorIndex: 3,
98
+ sourceFile: "index.html",
99
+ },
100
+ ]);
101
+
102
+ const target = captured[0]?.batches[0]?.patches[0]?.target;
103
+ expect(captured).toHaveLength(1);
104
+ expect(target?.id).toBeUndefined();
105
+ expect(target?.id).not.toBeNull();
106
+ // The element stays addressable via hfId (and selector) instead.
107
+ expect(target?.hfId).toBe("hf-card");
108
+
109
+ act(() => root.unmount());
110
+ });
111
+
112
+ it("preserves a real id when the element has one", async () => {
113
+ const el = document.createElement("video");
114
+ el.id = "v-hero";
115
+ el.setAttribute("data-hf-id", "hf-ezl2");
116
+
117
+ const { captured, root } = await runReorderCommit(el, [
118
+ { element: el, zIndex: 2, id: "v-hero", selector: "#v-hero", sourceFile: "index.html" },
119
+ ]);
120
+
121
+ expect(captured).toHaveLength(1);
122
+ expect(captured[0]?.batches[0]?.patches[0]?.target.id).toBe("v-hero");
123
+
124
+ act(() => root.unmount());
125
+ });
126
+
127
+ it("threads the lane gesture key into z-index persistence", async () => {
128
+ const el = document.createElement("div");
129
+ el.id = "clip-a";
130
+ document.body.appendChild(el);
131
+ const captured: CapturedBatchCall[] = [];
132
+ let commit: ReorderCommit | undefined;
133
+ const root = renderReorderHook(captured, (fn) => (commit = fn));
134
+
135
+ await act(async () => {
136
+ await commit!(
137
+ [{ element: el, zIndex: 4, id: "clip-a", sourceFile: "index.html" }],
138
+ "clip-lane-move:7",
139
+ );
140
+ });
141
+
142
+ expect(captured).toHaveLength(1);
143
+ expect(captured[0]?.options.coalesceKey).toBe("clip-lane-move:7");
144
+ act(() => root.unmount());
145
+ });
146
+
147
+ it("creates one batch per source file in a multi-file reorder", async () => {
148
+ const elements = ["clip-a", "clip-b", "clip-c"].map((id) => {
149
+ const element = document.createElement("div");
150
+ element.id = id;
151
+ document.body.appendChild(element);
152
+ return element;
153
+ });
154
+ const captured: CapturedBatchCall[] = [];
155
+ let commit: ReorderCommit | undefined;
156
+ const root = renderReorderHook(captured, (fn) => (commit = fn));
157
+
158
+ await act(async () => {
159
+ await commit!(
160
+ elements.map((element, index) => ({
161
+ element,
162
+ zIndex: index + 1,
163
+ id: element.id,
164
+ sourceFile: index < 2 ? "index.html" : "compositions/scene.html",
165
+ })),
166
+ );
167
+ });
168
+
169
+ expect(captured).toHaveLength(1);
170
+ expect(
171
+ captured[0]?.batches.map(({ sourceFile, patches }) => [sourceFile, patches.length]),
172
+ ).toEqual([
173
+ ["index.html", 2],
174
+ ["compositions/scene.html", 1],
175
+ ]);
176
+ act(() => root.unmount());
177
+ });
178
+
179
+ it("rolls back only live and store state after an atomic reorder failure", async () => {
180
+ const writeProjectFile = vi.fn(async () => {});
181
+ const recordEdit = vi.fn(async () => {});
182
+ const forceReloadSdkSession = vi.fn();
183
+ const originalError = new Error("second patch failed");
184
+ const elements = ["clip-a", "clip-b", "clip-c"].map((id, index) => {
185
+ const element = document.createElement("div");
186
+ element.id = id;
187
+ element.style.zIndex = String(index + 10);
188
+ document.body.appendChild(element);
189
+ return element;
190
+ });
191
+ usePlayerStore.getState().setElements(
192
+ elements.map((element, index) => ({
193
+ id: element.id,
194
+ tag: "div",
195
+ start: 0,
196
+ duration: 1,
197
+ track: index,
198
+ zIndex: index + 10,
199
+ hasExplicitZIndex: false,
200
+ })),
201
+ );
202
+
203
+ let commit: ReorderCommit | undefined;
204
+ function Harness() {
205
+ const { handleDomZIndexReorderCommit } = useElementLifecycleOps({
206
+ activeCompPath: "index.html",
207
+ showToast: vi.fn(),
208
+ writeProjectFile,
209
+ domEditSaveTimestampRef: { current: 0 },
210
+ editHistory: { recordEdit },
211
+ projectIdRef: { current: "demo" },
212
+ reloadPreview: vi.fn(),
213
+ clearDomSelection: vi.fn(),
214
+ forceReloadSdkSession,
215
+ commitDomEditPatchBatches: vi.fn(async () => {
216
+ throw originalError;
217
+ }),
218
+ });
219
+ commit = handleDomZIndexReorderCommit;
220
+ return null;
221
+ }
222
+ const root = mountReactHarness(<Harness />);
223
+
224
+ let rejection: unknown;
225
+ await act(async () => {
226
+ try {
227
+ await commit!(
228
+ elements.map((element, index) => ({
229
+ element,
230
+ zIndex: 3 - index,
231
+ id: element.id,
232
+ sourceFile: "index.html",
233
+ key: element.id,
234
+ })),
235
+ "clip-lane-move:failure",
236
+ );
237
+ } catch (error) {
238
+ rejection = error;
239
+ }
240
+ });
241
+
242
+ expect(rejection).toBe(originalError);
243
+ expect(elements.map((element) => element.style.zIndex)).toEqual(["10", "11", "12"]);
244
+ expect(
245
+ usePlayerStore
246
+ .getState()
247
+ .elements.map(({ zIndex, hasExplicitZIndex }) => ({ zIndex, hasExplicitZIndex })),
248
+ ).toEqual([
249
+ { zIndex: 10, hasExplicitZIndex: false },
250
+ { zIndex: 11, hasExplicitZIndex: false },
251
+ { zIndex: 12, hasExplicitZIndex: false },
252
+ ]);
253
+ expect(writeProjectFile).not.toHaveBeenCalled();
254
+ expect(recordEdit).not.toHaveBeenCalled();
255
+ expect(forceReloadSdkSession).not.toHaveBeenCalled();
256
+ act(() => root.unmount());
257
+ });
258
+ });