@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
@@ -11,10 +11,10 @@ import {
11
11
  hasDomEditRotationChanged,
12
12
  resolveDomEditCoordinateScale,
13
13
  resolveDomEditGroupOverlayRect,
14
- resolveDomEditResizeGesture,
15
14
  resolveDomEditRotationGesture,
16
15
  } from "./DomEditOverlay";
17
16
  import type { DomEditSelection } from "./domEditing";
17
+ import { resolveResizeCenterAnchorOffset } from "./domEditOverlayGestures";
18
18
 
19
19
  // React 19 warns unless the test environment opts into act().
20
20
  globalThis.IS_REACT_ACT_ENVIRONMENT = true;
@@ -84,21 +84,37 @@ vi.mock("./useDomEditOverlayRects", async () => {
84
84
  };
85
85
  });
86
86
 
87
+ const previewHelperSpies = vi.hoisted(() => ({
88
+ getPreviewTargetFromPointer: vi.fn<() => HTMLElement | null>(() => null),
89
+ }));
90
+
91
+ vi.mock("../../utils/studioPreviewHelpers", async () => {
92
+ const actual = await vi.importActual<typeof import("../../utils/studioPreviewHelpers")>(
93
+ "../../utils/studioPreviewHelpers",
94
+ );
95
+ return {
96
+ ...actual,
97
+ getPreviewTargetFromPointer: previewHelperSpies.getPreviewTargetFromPointer,
98
+ };
99
+ });
100
+
87
101
  vi.mock("./domEditOverlayGeometry", async () => {
88
102
  const actual = await vi.importActual<typeof import("./domEditOverlayGeometry")>(
89
103
  "./domEditOverlayGeometry",
90
104
  );
91
105
 
106
+ const stubRect = {
107
+ left: 24,
108
+ top: 36,
109
+ width: 180,
110
+ height: 72,
111
+ editScaleX: 1,
112
+ editScaleY: 1,
113
+ };
92
114
  return {
93
115
  ...actual,
94
- toOverlayRect: () => ({
95
- left: 24,
96
- top: 36,
97
- width: 180,
98
- height: 72,
99
- editScaleX: 1,
100
- editScaleY: 1,
101
- }),
116
+ toOverlayRect: () => stubRect,
117
+ orientedOverlayRect: () => stubRect,
102
118
  };
103
119
  });
104
120
 
@@ -126,6 +142,103 @@ function createOverlayProps(args: {
126
142
  };
127
143
  }
128
144
 
145
+ /**
146
+ * Stub element-level getBoundingClientRect to a fixed 800×450 rect (happy-dom
147
+ * returns all-zeros for unlaid-out elements, which gates the RAF compRect
148
+ * update). Returns a restore function to call in teardown.
149
+ */
150
+ function stubViewportRect(): () => void {
151
+ const original = Element.prototype.getBoundingClientRect;
152
+ Element.prototype.getBoundingClientRect = function (): DOMRect {
153
+ return {
154
+ left: 0,
155
+ top: 0,
156
+ right: 800,
157
+ bottom: 450,
158
+ width: 800,
159
+ height: 450,
160
+ x: 0,
161
+ y: 0,
162
+ toJSON: () => ({}),
163
+ };
164
+ };
165
+ return () => {
166
+ Element.prototype.getBoundingClientRect = original;
167
+ };
168
+ }
169
+
170
+ /**
171
+ * Flush the mount's RAF ticks so the compRect update lands. Two animation-frame
172
+ * ticks: the first scheduled by useMountEffect's update(), the second by
173
+ * update()'s tail recursion.
174
+ */
175
+ async function flushOverlayRaf(): Promise<void> {
176
+ await act(async () => {
177
+ await new Promise<void>((resolve) => {
178
+ requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
179
+ });
180
+ });
181
+ }
182
+
183
+ /** A fully-populated DomEditSelection with per-test overrides (capabilities are
184
+ * merged so a test can flip a single flag without restating the whole set). */
185
+ function makeDomEditSelection(
186
+ overrides: Partial<DomEditSelection> = {},
187
+ capabilityOverrides: Partial<DomEditSelection["capabilities"]> = {},
188
+ ): DomEditSelection {
189
+ const base: DomEditSelection = {
190
+ element: document.createElement("div"),
191
+ id: "hero-title",
192
+ selector: ".hero-title",
193
+ selectorIndex: 0,
194
+ sourceFile: "index.html",
195
+ tagName: "div",
196
+ label: "Hero Title",
197
+ textContent: "Hello",
198
+ textFields: [],
199
+ capabilities: {
200
+ canEditText: true,
201
+ canEditLayout: true,
202
+ canMove: true,
203
+ canApplyManualOffset: true,
204
+ canApplyManualSize: false,
205
+ canApplyManualRotation: false,
206
+ canAdjustOpacity: true,
207
+ canAdjustFill: true,
208
+ canAdjustBorderRadius: true,
209
+ canAdjustStroke: true,
210
+ canAdjustShadow: true,
211
+ canAdjustZIndex: true,
212
+ },
213
+ computedStyle: {
214
+ display: "block",
215
+ position: "absolute",
216
+ },
217
+ };
218
+ return {
219
+ ...base,
220
+ ...overrides,
221
+ capabilities: { ...base.capabilities, ...capabilityOverrides },
222
+ };
223
+ }
224
+
225
+ /** Query the composition-canvas overlay and assert it mounted. */
226
+ function getOverlay(host: HTMLElement): HTMLDivElement {
227
+ const overlay = host.querySelector<HTMLDivElement>('[aria-label="Composition canvas"]');
228
+ expect(overlay).toBeTruthy();
229
+ if (!overlay) throw new Error("Expected composition canvas overlay");
230
+ return overlay;
231
+ }
232
+
233
+ /** Dispatch a left-button pointerdown at (clientX, clientY) inside act(). */
234
+ function dispatchOverlayPointerDown(target: Element, clientX = 120, clientY = 80): void {
235
+ act(() => {
236
+ target.dispatchEvent(
237
+ new PointerEvent("pointerdown", { bubbles: true, button: 0, clientX, clientY }),
238
+ );
239
+ });
240
+ }
241
+
129
242
  describe("focusDomEditOverlayElement", () => {
130
243
  it("focuses the canvas overlay without scrolling", () => {
131
244
  const calls: Array<FocusOptions | undefined> = [];
@@ -144,41 +257,84 @@ describe("DomEditOverlay", () => {
144
257
  gestureSpies.onPointerMove.mockClear();
145
258
  gestureSpies.onPointerUp.mockClear();
146
259
  gestureSpies.clearPointerState.mockClear();
260
+ previewHelperSpies.getPreviewTargetFromPointer.mockReset();
261
+ previewHelperSpies.getPreviewTargetFromPointer.mockReturnValue(null);
262
+ });
263
+
264
+ it("selects on the first click over an element even before a hover is resolved", async () => {
265
+ // Regression: this used to start a marquee whenever hoverSelectionRef was null.
266
+ // The RAF hover loop populates that ref ASYNCHRONOUSLY, so a genuine first
267
+ // click over an element read null and was misread as empty canvas — the
268
+ // marquee swallowed the selecting onMouseDown, so nothing selected until the
269
+ // SECOND click. With a synchronous pointer hit-test finding an element, the
270
+ // marquee must NOT start and onCanvasMouseDown must fire on the first click.
271
+ const restoreRect = stubViewportRect();
272
+ const originalPointerCapture = HTMLDivElement.prototype.setPointerCapture;
273
+ HTMLDivElement.prototype.setPointerCapture = () => {};
274
+
275
+ // An element IS under the pointer, but no hover has been resolved yet.
276
+ previewHelperSpies.getPreviewTargetFromPointer.mockReturnValue(document.createElement("div"));
277
+
278
+ const host = document.createElement("div");
279
+ document.body.append(host);
280
+ const root = createRoot(host);
281
+ const iframeRef = { current: document.createElement("iframe") as HTMLIFrameElement | null };
282
+ const onCanvasMouseDown = vi.fn();
283
+ const onMarqueeSelect = vi.fn();
284
+
285
+ function Harness() {
286
+ return React.createElement(DomEditOverlay, {
287
+ ...createOverlayProps({
288
+ iframeRef,
289
+ selection: null,
290
+ hoverSelection: null,
291
+ onSelectionChange: () => {},
292
+ }),
293
+ onCanvasMouseDown,
294
+ onMarqueeSelect,
295
+ });
296
+ }
297
+
298
+ act(() => {
299
+ root.render(React.createElement(Harness));
300
+ });
301
+ await flushOverlayRaf();
302
+
303
+ const overlay = getOverlay(host);
304
+
305
+ act(() => {
306
+ overlay.dispatchEvent(
307
+ new PointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 120, clientY: 80 }),
308
+ );
309
+ overlay.dispatchEvent(
310
+ new MouseEvent("mousedown", { bubbles: true, button: 0, clientX: 120, clientY: 80 }),
311
+ );
312
+ });
313
+
314
+ // No marquee started; the click reached the selecting mouse-down handler.
315
+ expect(onMarqueeSelect).not.toHaveBeenCalled();
316
+ expect(onCanvasMouseDown).toHaveBeenCalledTimes(1);
317
+
318
+ act(() => {
319
+ root.unmount();
320
+ });
321
+ HTMLDivElement.prototype.setPointerCapture = originalPointerCapture;
322
+ restoreRect();
323
+ host.remove();
147
324
  });
148
325
 
149
326
  it("does not start a drag from a stale hover target on canvas pointer-down", () => {
150
327
  const host = document.createElement("div");
151
328
  document.body.append(host);
152
329
  const root = createRoot(host);
153
- const selection: DomEditSelection = {
154
- element: document.createElement("div"),
330
+ const selection = makeDomEditSelection({
155
331
  id: "cta-label",
156
332
  selector: ".cta-label",
157
- selectorIndex: 0,
158
- sourceFile: "index.html",
159
333
  tagName: "span",
160
334
  label: "CTA Label",
161
335
  textContent: "Add to basket",
162
- textFields: [],
163
- capabilities: {
164
- canEditText: true,
165
- canEditLayout: true,
166
- canMove: true,
167
- canApplyManualOffset: true,
168
- canApplyManualSize: false,
169
- canApplyManualRotation: false,
170
- canAdjustOpacity: true,
171
- canAdjustFill: true,
172
- canAdjustBorderRadius: true,
173
- canAdjustStroke: true,
174
- canAdjustShadow: true,
175
- canAdjustZIndex: true,
176
- },
177
- computedStyle: {
178
- display: "inline",
179
- position: "static",
180
- },
181
- };
336
+ computedStyle: { display: "inline", position: "static" },
337
+ });
182
338
 
183
339
  let currentSelection: DomEditSelection | null = null;
184
340
  const iframeRef = { current: document.createElement("iframe") as HTMLIFrameElement | null };
@@ -202,19 +358,9 @@ describe("DomEditOverlay", () => {
202
358
  root.render(React.createElement(Harness));
203
359
  });
204
360
 
205
- const overlay = host.querySelector('[aria-label="Composition canvas"]') as HTMLDivElement;
206
- expect(overlay).toBeTruthy();
361
+ const overlay = getOverlay(host);
207
362
 
208
- act(() => {
209
- overlay.dispatchEvent(
210
- new PointerEvent("pointerdown", {
211
- bubbles: true,
212
- button: 0,
213
- clientX: 120,
214
- clientY: 80,
215
- }),
216
- );
217
- });
363
+ dispatchOverlayPointerDown(overlay);
218
364
 
219
365
  expect(gestureSpies.startGesture).not.toHaveBeenCalled();
220
366
  expect(currentSelection).toBe(null);
@@ -233,53 +379,12 @@ describe("DomEditOverlay", () => {
233
379
  // box (and other bounded UI) behind `compRect.width > 0` (added in the
234
380
  // keyframes PR a468550f). Stub element-level getBoundingClientRect for
235
381
  // the test so the RAF compRect update produces a real width.
236
- const originalGetBoundingClientRect = Element.prototype.getBoundingClientRect;
237
- Element.prototype.getBoundingClientRect = function (): DOMRect {
238
- return {
239
- left: 0,
240
- top: 0,
241
- right: 800,
242
- bottom: 450,
243
- width: 800,
244
- height: 450,
245
- x: 0,
246
- y: 0,
247
- toJSON: () => ({}),
248
- };
249
- };
382
+ const restoreRect = stubViewportRect();
250
383
 
251
384
  const host = document.createElement("div");
252
385
  document.body.append(host);
253
386
  const root = createRoot(host);
254
- const selection: DomEditSelection = {
255
- element: document.createElement("div"),
256
- id: "hero-title",
257
- selector: ".hero-title",
258
- selectorIndex: 0,
259
- sourceFile: "index.html",
260
- tagName: "div",
261
- label: "Hero Title",
262
- textContent: "Hello",
263
- textFields: [],
264
- capabilities: {
265
- canEditText: true,
266
- canEditLayout: true,
267
- canMove: true,
268
- canApplyManualOffset: true,
269
- canApplyManualSize: false,
270
- canApplyManualRotation: false,
271
- canAdjustOpacity: true,
272
- canAdjustFill: true,
273
- canAdjustBorderRadius: true,
274
- canAdjustStroke: true,
275
- canAdjustShadow: true,
276
- canAdjustZIndex: true,
277
- },
278
- computedStyle: {
279
- display: "block",
280
- position: "absolute",
281
- },
282
- };
387
+ const selection = makeDomEditSelection();
283
388
 
284
389
  let currentSelection: DomEditSelection | null = selection;
285
390
  const iframeRef = { current: document.createElement("iframe") as HTMLIFrameElement | null };
@@ -307,30 +412,16 @@ describe("DomEditOverlay", () => {
307
412
  // Flush the mount's RAF tick so the compRect update lands before the
308
413
  // pointer-down. Two animation-frame ticks: the first scheduled by
309
414
  // useMountEffect's update(), the second by update()'s tail recursion.
310
- await act(async () => {
311
- await new Promise<void>((resolve) => {
312
- requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
313
- });
314
- });
415
+ await flushOverlayRaf();
315
416
 
316
- const overlay = host.querySelector('[aria-label="Composition canvas"]') as HTMLDivElement;
317
- expect(overlay).toBeTruthy();
417
+ getOverlay(host);
318
418
 
319
419
  const selectionBox = host.querySelector(
320
420
  '[data-dom-edit-selection-box="true"]',
321
421
  ) as HTMLDivElement;
322
422
  expect(selectionBox).toBeTruthy();
323
423
 
324
- act(() => {
325
- selectionBox.dispatchEvent(
326
- new PointerEvent("pointerdown", {
327
- bubbles: true,
328
- button: 0,
329
- clientX: 120,
330
- clientY: 80,
331
- }),
332
- );
333
- });
424
+ dispatchOverlayPointerDown(selectionBox);
334
425
 
335
426
  expect(currentSelection).toBe(selection);
336
427
  expect(gestureSpies.startGesture).toHaveBeenCalledWith(
@@ -342,58 +433,17 @@ describe("DomEditOverlay", () => {
342
433
  root.unmount();
343
434
  });
344
435
  HTMLDivElement.prototype.setPointerCapture = originalPointerCapture;
345
- Element.prototype.getBoundingClientRect = originalGetBoundingClientRect;
436
+ restoreRect();
346
437
  host.remove();
347
438
  });
348
439
 
349
440
  it("passes the tracked hover selection when clicking the existing selection box", async () => {
350
- const originalGetBoundingClientRect = Element.prototype.getBoundingClientRect;
351
- Element.prototype.getBoundingClientRect = function (): DOMRect {
352
- return {
353
- left: 0,
354
- top: 0,
355
- right: 800,
356
- bottom: 450,
357
- width: 800,
358
- height: 450,
359
- x: 0,
360
- y: 0,
361
- toJSON: () => ({}),
362
- };
363
- };
441
+ const restoreRect = stubViewportRect();
364
442
 
365
443
  const host = document.createElement("div");
366
444
  document.body.append(host);
367
445
  const root = createRoot(host);
368
- const selection: DomEditSelection = {
369
- element: document.createElement("div"),
370
- id: "hero-title",
371
- selector: ".hero-title",
372
- selectorIndex: 0,
373
- sourceFile: "index.html",
374
- tagName: "div",
375
- label: "Hero Title",
376
- textContent: "Hello",
377
- textFields: [],
378
- capabilities: {
379
- canEditText: true,
380
- canEditLayout: true,
381
- canMove: false,
382
- canApplyManualOffset: false,
383
- canApplyManualSize: false,
384
- canApplyManualRotation: false,
385
- canAdjustOpacity: true,
386
- canAdjustFill: true,
387
- canAdjustBorderRadius: true,
388
- canAdjustStroke: true,
389
- canAdjustShadow: true,
390
- canAdjustZIndex: true,
391
- },
392
- computedStyle: {
393
- display: "block",
394
- position: "absolute",
395
- },
396
- };
446
+ const selection = makeDomEditSelection({}, { canMove: false, canApplyManualOffset: false });
397
447
  const hoverSelection: DomEditSelection = { ...selection, id: "hovered-sibling" };
398
448
  const onCanvasMouseDown = vi.fn();
399
449
  const iframeRef = { current: document.createElement("iframe") as HTMLIFrameElement | null };
@@ -414,11 +464,7 @@ describe("DomEditOverlay", () => {
414
464
  root.render(React.createElement(Harness));
415
465
  });
416
466
 
417
- await act(async () => {
418
- await new Promise<void>((resolve) => {
419
- requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
420
- });
421
- });
467
+ await flushOverlayRaf();
422
468
 
423
469
  const selectionBox = host.querySelector(
424
470
  '[data-dom-edit-selection-box="true"]',
@@ -437,7 +483,7 @@ describe("DomEditOverlay", () => {
437
483
  act(() => {
438
484
  root.unmount();
439
485
  });
440
- Element.prototype.getBoundingClientRect = originalGetBoundingClientRect;
486
+ restoreRect();
441
487
  host.remove();
442
488
  });
443
489
  });
@@ -513,130 +559,10 @@ describe("filterNestedDomEditGroupItems", () => {
513
559
  });
514
560
  });
515
561
 
516
- describe("resolveDomEditResizeGesture", () => {
517
- it("resizes width and height independently by default", () => {
518
- expect(
519
- resolveDomEditResizeGesture({
520
- originWidth: 240,
521
- originHeight: 120,
522
- actualWidth: 240,
523
- actualHeight: 120,
524
- scaleX: 1,
525
- scaleY: 1,
526
- dx: 30,
527
- dy: 12,
528
- uniform: false,
529
- }),
530
- ).toEqual({
531
- overlayWidth: 270,
532
- overlayHeight: 132,
533
- width: 270,
534
- height: 132,
535
- });
536
- });
537
-
538
- it("divides the cursor delta by the element's content scale (rescaled element)", () => {
539
- // Element renders at 2x via a GSAP scale: a 30px cursor delta must grow the
540
- // CSS box by only 15px so the RENDERED box tracks the pointer 1:1.
541
- const next = resolveDomEditResizeGesture({
542
- originWidth: 480, // 240 css x 2 content scale (overlay px at editScale 1)
543
- originHeight: 240,
544
- actualWidth: 240,
545
- actualHeight: 120,
546
- scaleX: 1,
547
- scaleY: 1,
548
- contentScaleX: 2,
549
- contentScaleY: 2,
550
- dx: 30,
551
- dy: 12,
552
- uniform: false,
553
- });
554
- expect(next.width).toBe(255);
555
- expect(next.height).toBe(126);
556
- // The overlay box keeps tracking the raw cursor.
557
- expect(next.overlayWidth).toBe(510);
558
- expect(next.overlayHeight).toBe(252);
559
- });
560
-
561
- it("treats a missing/invalid content scale as 1 (unscaled element)", () => {
562
- const next = resolveDomEditResizeGesture({
563
- originWidth: 240,
564
- originHeight: 120,
565
- actualWidth: 240,
566
- actualHeight: 120,
567
- scaleX: 1,
568
- scaleY: 1,
569
- contentScaleX: 0,
570
- contentScaleY: Number.NaN,
571
- dx: 30,
572
- dy: 12,
573
- uniform: false,
574
- });
575
- expect(next.width).toBe(270);
576
- expect(next.height).toBe(132);
577
- });
578
-
579
- it("snaps width and height to the same value when Shift is held", () => {
580
- expect(
581
- resolveDomEditResizeGesture({
582
- originWidth: 240,
583
- originHeight: 120,
584
- actualWidth: 240,
585
- actualHeight: 120,
586
- scaleX: 1,
587
- scaleY: 1,
588
- dx: 30,
589
- dy: 12,
590
- uniform: true,
591
- }),
592
- ).toEqual({
593
- overlayWidth: 270,
594
- overlayHeight: 270,
595
- width: 270,
596
- height: 270,
597
- });
598
- });
599
-
600
- it("uses the dominant pointer delta for uniform shrink", () => {
601
- expect(
602
- resolveDomEditResizeGesture({
603
- originWidth: 300,
604
- originHeight: 180,
605
- actualWidth: 300,
606
- actualHeight: 180,
607
- scaleX: 1,
608
- scaleY: 1,
609
- dx: 8,
610
- dy: -40,
611
- uniform: true,
612
- }),
613
- ).toMatchObject({
614
- width: 260,
615
- height: 260,
616
- });
617
- });
618
-
619
- it("writes source-local dimensions when the edited source is scaled down in master view", () => {
620
- expect(
621
- resolveDomEditResizeGesture({
622
- originWidth: 100,
623
- originHeight: 50,
624
- actualWidth: 400,
625
- actualHeight: 200,
626
- scaleX: 0.25,
627
- scaleY: 0.25,
628
- dx: 25,
629
- dy: 10,
630
- uniform: false,
631
- }),
632
- ).toEqual({
633
- overlayWidth: 125,
634
- overlayHeight: 60,
635
- width: 500,
636
- height: 240,
637
- });
638
- });
639
- });
562
+ // Note: the resize SIZE math moved from the AABB screen-space
563
+ // resolveDomEditResizeGesture (removed) to the local-space (OBB) model in
564
+ // domEditResizeLocal.ts — see domEditResizeLocal.test.ts, which re-covers the
565
+ // independent-axis, aspect-lock, and scaled-master-view cases plus rotated axes.
640
566
 
641
567
  describe("resolveDomEditRotationGesture", () => {
642
568
  it("rotates by the pointer angle around the element center", () => {
@@ -701,3 +627,43 @@ describe("resolveDomEditRotationGesture", () => {
701
627
  expect(hasDomEditRotationChanged(0, 0)).toBe(false);
702
628
  });
703
629
  });
630
+
631
+ // resolveResizeCenterAnchorOffset is the UNROTATED (AABB) fallback used only when
632
+ // the element's real transformed corners can't be measured. Center-anchored: a
633
+ // width/height change grows the box from its top-left, drifting the center by half
634
+ // the size change per axis, so the pin translates back by that half-delta. It is
635
+ // handle-independent — all four corners scale about the same center.
636
+ describe("resolveResizeCenterAnchorOffset", () => {
637
+ it("grow: translates back by half the size change on both axes", () => {
638
+ expect(
639
+ resolveResizeCenterAnchorOffset({
640
+ originWidth: 200,
641
+ originHeight: 100,
642
+ overlayWidth: 230,
643
+ overlayHeight: 112,
644
+ }),
645
+ ).toEqual({ dx: -15, dy: -6 });
646
+ });
647
+
648
+ it("shrink: translates forward by half the (positive) size change", () => {
649
+ expect(
650
+ resolveResizeCenterAnchorOffset({
651
+ originWidth: 200,
652
+ originHeight: 100,
653
+ overlayWidth: 160,
654
+ overlayHeight: 80,
655
+ }),
656
+ ).toEqual({ dx: 20, dy: 10 });
657
+ });
658
+
659
+ it("no size change: zero offset", () => {
660
+ expect(
661
+ resolveResizeCenterAnchorOffset({
662
+ originWidth: 200,
663
+ originHeight: 100,
664
+ overlayWidth: 200,
665
+ overlayHeight: 100,
666
+ }),
667
+ ).toEqual({ dx: 0, dy: 0 });
668
+ });
669
+ });