@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,352 @@
1
+ /**
2
+ * Pure z-order helpers for the canvas right-click context menu.
3
+ *
4
+ * Layering strategy: z-index + CSS stacking context (position ≠ static).
5
+ * All sibling z-index values are read from the live iframe DOM via
6
+ * element.style.zIndex (inline style, set by the editor) falling back to
7
+ * the computed value. Treat missing / "auto" as 0 for comparison purposes.
8
+ *
9
+ * "Overlapping siblings" = siblings whose bounding rects intersect the
10
+ * target's bounding rect. Forward/backward operate within that set;
11
+ * front/back operate across all siblings.
12
+ *
13
+ * ── Tie-awareness ────────────────────────────────────────────────────────────
14
+ * CSS paint order for elements that share a z-index is DOM document order:
15
+ * the element that comes LATER in the DOM paints ON TOP. The old resolver
16
+ * compared z-index alone, so a target tied with the element visually below it
17
+ * (equal z, target later in DOM) had an empty "below" set and silently
18
+ * no-op'd. This module computes true render order — sort by
19
+ * (zIndex asc, DOM position asc), bottom→top — moves the target one step (or
20
+ * to an end) in that order, then realizes the new order back into z values.
21
+ *
22
+ * The result is a MULTI-element patch: a single-element patch when a
23
+ * strictly-between z value can express the new order given DOM-order
24
+ * tie-breaking, otherwise a minimal renumber of the affected set (emitting
25
+ * patches only for elements whose z actually changes). z is never negative
26
+ * (project convention clamps z ≥ 0).
27
+ */
28
+
29
+ export type ZOrderAction = "bring-forward" | "send-backward" | "bring-to-front" | "send-to-back";
30
+
31
+ /** A resolved change: set `element`'s z-index to `zIndex`. */
32
+ export interface ZOrderPatch {
33
+ element: HTMLElement;
34
+ zIndex: number;
35
+ }
36
+
37
+ interface RenderEntry {
38
+ element: HTMLElement;
39
+ zIndex: number;
40
+ /** Position within the shared parent's children (DOM document order). */
41
+ domIndex: number;
42
+ }
43
+
44
+ /** Parse a z-index string to a number; treats "auto" / empty as 0. */
45
+ export function parseZIndex(value: string | null | undefined): number {
46
+ if (!value || value === "auto") return 0;
47
+ const n = parseInt(value, 10);
48
+ return Number.isFinite(n) ? n : 0;
49
+ }
50
+
51
+ /** Read the effective z-index for an element (inline style preferred). */
52
+ export function readEffectiveZIndex(el: HTMLElement): number {
53
+ const inline = el.style.zIndex;
54
+ if (inline && inline !== "auto") return parseZIndex(inline);
55
+ try {
56
+ const win = el.ownerDocument?.defaultView;
57
+ if (win) return parseZIndex(win.getComputedStyle(el).zIndex);
58
+ } catch {
59
+ /* cross-origin / detached */
60
+ }
61
+ return 0;
62
+ }
63
+
64
+ /**
65
+ * Realm-safe HTMLElement check. The target lives in the preview IFRAME's
66
+ * document, but this module runs in the top window, so `child instanceof
67
+ * HTMLElement` (top-window constructor) is ALWAYS false for iframe elements —
68
+ * which silently emptied the sibling list and left every z-order action
69
+ * permanently disabled. Compare against the element's own realm instead, with
70
+ * a nodeType fallback for detached / cross-realm edge cases.
71
+ */
72
+ function isElementNode(node: Node): node is HTMLElement {
73
+ const view = node.ownerDocument?.defaultView;
74
+ if (view && node instanceof view.HTMLElement) return true;
75
+ return node.nodeType === 1;
76
+ }
77
+
78
+ /**
79
+ * Tags that never paint pixels and so must be excluded from z-order siblings.
80
+ * `<audio>` is the real offender here: a prior renumber wrote a meaningless
81
+ * z-index onto the qa-clean audio element, and counting it as a sibling skews the
82
+ * renumber for the visible elements. `<script>/<style>/<link>/<meta>` are also
83
+ * non-painting and could otherwise pad the family / eat a z slot.
84
+ */
85
+ const NON_PAINTING_TAGS = new Set(["AUDIO", "SCRIPT", "STYLE", "LINK", "META"]);
86
+
87
+ /** A painting element: an element node whose tag actually renders pixels. */
88
+ function isPaintingElement(node: Node): node is HTMLElement {
89
+ return isElementNode(node) && !NON_PAINTING_TAGS.has(node.tagName);
90
+ }
91
+
92
+ /**
93
+ * Collect the target plus every PAINTING HTMLElement sibling (same parent),
94
+ * tagged with DOM document position. Non-painting siblings (audio/script/style/
95
+ * link/meta) are skipped so they neither pad the family nor consume a z slot in
96
+ * the renumber path. Returns the target's own index within the result.
97
+ */
98
+ function getFamily(target: HTMLElement): { entries: RenderEntry[]; targetIndex: number } {
99
+ const parent = target.parentElement;
100
+ if (!parent) return { entries: [], targetIndex: -1 };
101
+ const entries: RenderEntry[] = [];
102
+ let targetIndex = -1;
103
+ let domIndex = 0;
104
+ for (const child of Array.from(parent.children)) {
105
+ // The target is always retained even if its own tag is non-painting.
106
+ if (child !== target && !isPaintingElement(child)) continue;
107
+ if (!isElementNode(child)) continue;
108
+ if (child === target) targetIndex = entries.length;
109
+ entries.push({ element: child, zIndex: readEffectiveZIndex(child), domIndex });
110
+ domIndex += 1;
111
+ }
112
+ return { entries, targetIndex };
113
+ }
114
+
115
+ /** True if two DOM bounding rects intersect (even if touching). */
116
+ function rectsIntersect(
117
+ a: { left: number; top: number; right: number; bottom: number },
118
+ b: { left: number; top: number; right: number; bottom: number },
119
+ ): boolean {
120
+ return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top;
121
+ }
122
+
123
+ /**
124
+ * Restrict a family to the target plus siblings whose bounding rect overlaps
125
+ * the target's rect. The target is always retained. If the target's rect is
126
+ * unavailable or empty (headless / happy-dom returns 0×0), all entries are
127
+ * kept — matching the prior behavior.
128
+ */
129
+ function getOverlappingFamily(target: HTMLElement, entries: RenderEntry[]): RenderEntry[] {
130
+ let targetRect: DOMRect;
131
+ try {
132
+ targetRect = target.getBoundingClientRect();
133
+ } catch {
134
+ return entries;
135
+ }
136
+ if (targetRect.width === 0 && targetRect.height === 0) return entries;
137
+ const tr = {
138
+ left: targetRect.left,
139
+ top: targetRect.top,
140
+ right: targetRect.right,
141
+ bottom: targetRect.bottom,
142
+ };
143
+ return entries.filter((entry) => {
144
+ if (entry.element === target) return true;
145
+ try {
146
+ const r = entry.element.getBoundingClientRect();
147
+ return rectsIntersect(tr, { left: r.left, top: r.top, right: r.right, bottom: r.bottom });
148
+ } catch {
149
+ return false;
150
+ }
151
+ });
152
+ }
153
+
154
+ /** Sort a family into render order (bottom→top): z asc, then DOM position asc. */
155
+ function toRenderOrder(entries: RenderEntry[]): RenderEntry[] {
156
+ return [...entries].sort((a, b) => a.zIndex - b.zIndex || a.domIndex - b.domIndex);
157
+ }
158
+
159
+ /**
160
+ * A z that lands the target strictly between `below` and `above` in render order,
161
+ * or null when no such value exists (a tie-prone gap, or no room below the floor)
162
+ * and the caller must renumber. Equal-z ties break by DOM order, so a plain
163
+ * equality can flip order unpredictably; require a strict gap and clamp at 0.
164
+ */
165
+ function computeBetweenZ(
166
+ below: RenderEntry | undefined,
167
+ above: RenderEntry | undefined,
168
+ ): number | null {
169
+ if (below && above) {
170
+ return above.zIndex - below.zIndex >= 2 ? below.zIndex + 1 : null;
171
+ }
172
+ if (below) return below.zIndex + 1; // move to top
173
+ if (above) {
174
+ const candidate = Math.max(0, above.zIndex - 1); // move to bottom
175
+ return candidate >= above.zIndex ? null : candidate; // no room below → renumber
176
+ }
177
+ return null;
178
+ }
179
+
180
+ /**
181
+ * Realize a desired render order (bottom→top) into z-index patches for the
182
+ * given family, emitting patches ONLY for elements whose z actually changes.
183
+ *
184
+ * Fast path: if the SCOPED z values are all distinct, the render order is fully
185
+ * determined by z alone — a single-element move can be expressed by placing the
186
+ * target's z strictly between its new neighbours (or at an end), so at most one
187
+ * element changes (and it never disturbs an untouched pair, since only the target
188
+ * moves). When ties exist a between value can be impossible, so renumber — but the
189
+ * scoped set is only a SUBSET of the family (the target's overlapping siblings),
190
+ * so a naive 0..n-1 renumber can drop a scoped sibling below an untouched
191
+ * non-scoped one, reordering an untouched pair (#2202). `renumberScoped` keeps the
192
+ * scoped block inside its original z-band, bounded by the non-scoped siblings.
193
+ */
194
+ function realizeOrder(
195
+ currentOrder: RenderEntry[],
196
+ desiredOrder: RenderEntry[],
197
+ target: HTMLElement,
198
+ family: RenderEntry[],
199
+ ): ZOrderPatch[] | null {
200
+ const targetPos = desiredOrder.findIndex((e) => e.element === target);
201
+ if (targetPos === -1) return null;
202
+
203
+ const targetZ = readEffectiveZIndex(target);
204
+
205
+ // ── Fast path: distinct z values → a single between-value move suffices.
206
+ const zValues = currentOrder.map((e) => e.zIndex);
207
+ const hasDupes = zValues.some((v, i) => zValues.indexOf(v) !== i);
208
+ if (!hasDupes) {
209
+ const candidate = computeBetweenZ(desiredOrder[targetPos - 1], desiredOrder[targetPos + 1]);
210
+ if (candidate !== null) {
211
+ if (candidate === targetZ) return null;
212
+ return [{ element: target, zIndex: candidate }];
213
+ }
214
+ // else fall through to renumber
215
+ }
216
+
217
+ return renumberScoped(currentOrder, desiredOrder, target, family);
218
+ }
219
+
220
+ /**
221
+ * Renumber the SCOPED set (the reordered subset) to distinct z, keeping the whole
222
+ * block within the band its members already occupied so no untouched scoped /
223
+ * non-scoped pair is reordered (#2202). The block is placed near its original base
224
+ * `lo`, but clamped to sit strictly above the highest non-scoped sibling below the
225
+ * band and strictly below the lowest non-scoped sibling above it. Only scoped
226
+ * members are patched; non-scoped siblings keep their authored z.
227
+ *
228
+ * If a non-scoped sibling sits INSIDE or tied to the band (no clean bracket), or
229
+ * the bracket is too narrow to hold `n` distinct integers, fall back to a
230
+ * whole-family renumber — less minimal but still preserves every relative order.
231
+ */
232
+ function renumberScoped(
233
+ currentOrder: RenderEntry[],
234
+ desiredOrder: RenderEntry[],
235
+ target: HTMLElement,
236
+ family: RenderEntry[],
237
+ ): ZOrderPatch[] | null {
238
+ const scoped = new Set(desiredOrder.map((e) => e.element));
239
+ const nonScoped = family.filter((e) => !scoped.has(e.element));
240
+ const n = desiredOrder.length;
241
+ const zs = currentOrder.map((e) => e.zIndex);
242
+ const lo = Math.min(...zs);
243
+ const hi = Math.max(...zs);
244
+
245
+ const bracketed = !nonScoped.some((e) => e.zIndex >= lo && e.zIndex <= hi);
246
+ if (bracketed) {
247
+ const below = nonScoped.filter((e) => e.zIndex < lo).map((e) => e.zIndex);
248
+ const above = nonScoped.filter((e) => e.zIndex > hi).map((e) => e.zIndex);
249
+ const minStart = below.length > 0 ? Math.max(...below) + 1 : 0; // z ≥ 0 convention
250
+ const hasUpper = above.length > 0;
251
+ const maxStart = hasUpper ? Math.min(...above) - n : Number.POSITIVE_INFINITY;
252
+ if (minStart <= maxStart) {
253
+ let start = Math.max(lo, minStart);
254
+ if (hasUpper) start = Math.min(start, maxStart);
255
+ const patches: ZOrderPatch[] = [];
256
+ desiredOrder.forEach((entry, i) => {
257
+ if (entry.zIndex !== start + i) patches.push({ element: entry.element, zIndex: start + i });
258
+ });
259
+ return patches.length === 0 ? null : patches;
260
+ }
261
+ }
262
+
263
+ // ── Fallback: renumber the whole family so relative order is still preserved.
264
+ const desiredGlobal = buildGlobalOrder(family, desiredOrder, target);
265
+ const patches: ZOrderPatch[] = [];
266
+ desiredGlobal.forEach((entry, i) => {
267
+ if (entry.zIndex !== i) patches.push({ element: entry.element, zIndex: i });
268
+ });
269
+ return patches.length === 0 ? null : patches;
270
+ }
271
+
272
+ /**
273
+ * A whole-family render order (bottom→top) with the non-scoped siblings kept in
274
+ * their current relative order and the target reinserted beside its new SCOPED
275
+ * neighbour (just above the scoped element below it, else just below the scoped
276
+ * element above it). Used only by the renumber fallback.
277
+ */
278
+ function buildGlobalOrder(
279
+ family: RenderEntry[],
280
+ desiredOrder: RenderEntry[],
281
+ target: HTMLElement,
282
+ ): RenderEntry[] {
283
+ const full = toRenderOrder(family);
284
+ const targetEntry = full.find((e) => e.element === target);
285
+ const rest = full.filter((e) => e.element !== target);
286
+ if (!targetEntry) return rest;
287
+ const targetPos = desiredOrder.findIndex((e) => e.element === target);
288
+ const prev = desiredOrder[targetPos - 1];
289
+ const next = desiredOrder[targetPos + 1];
290
+ const prevIdx = prev ? rest.findIndex((e) => e.element === prev.element) : -1;
291
+ const nextIdx = next ? rest.findIndex((e) => e.element === next.element) : -1;
292
+ if (prevIdx >= 0) rest.splice(prevIdx + 1, 0, targetEntry);
293
+ else if (nextIdx >= 0) rest.splice(nextIdx, 0, targetEntry);
294
+ else rest.unshift(targetEntry);
295
+ return rest;
296
+ }
297
+
298
+ /**
299
+ * Resolve the z-order patches for an action.
300
+ *
301
+ * Returns null when the action is a no-op (target already at the relevant
302
+ * end of its set), otherwise the minimal list of {element, zIndex} changes.
303
+ */
304
+ export function resolveZOrderChange(
305
+ target: HTMLElement,
306
+ action: ZOrderAction,
307
+ ): ZOrderPatch[] | null {
308
+ const { entries } = getFamily(target);
309
+ // Family always includes the target; fewer than 2 means no siblings at all.
310
+ if (entries.length < 2) return null;
311
+
312
+ const scoped =
313
+ action === "bring-to-front" || action === "send-to-back"
314
+ ? entries
315
+ : getOverlappingFamily(target, entries);
316
+ if (scoped.length < 2) return null;
317
+
318
+ const order = toRenderOrder(scoped);
319
+ const pos = order.findIndex((e) => e.element === target);
320
+ if (pos === -1) return null;
321
+
322
+ const desired = [...order];
323
+ const [moved] = desired.splice(pos, 1);
324
+ switch (action) {
325
+ case "bring-forward":
326
+ if (pos >= order.length - 1) return null; // already top of set
327
+ desired.splice(pos + 1, 0, moved);
328
+ break;
329
+ case "send-backward":
330
+ if (pos <= 0) return null; // already bottom of set
331
+ desired.splice(pos - 1, 0, moved);
332
+ break;
333
+ case "bring-to-front":
334
+ if (pos >= order.length - 1) return null;
335
+ desired.push(moved);
336
+ break;
337
+ case "send-to-back":
338
+ if (pos <= 0) return null;
339
+ desired.unshift(moved);
340
+ break;
341
+ }
342
+
343
+ return realizeOrder(order, desired, target, entries);
344
+ }
345
+
346
+ /**
347
+ * Whether a z-order action is available for the target.
348
+ * "disabled" = the element is already at that limit.
349
+ */
350
+ export function isZOrderActionEnabled(target: HTMLElement, action: ZOrderAction): boolean {
351
+ return resolveZOrderChange(target, action) !== null;
352
+ }
@@ -0,0 +1,80 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ CANVAS_NUDGE_SHIFT_STEP_PX,
4
+ CANVAS_NUDGE_STEP_PX,
5
+ canCanvasNudgeTargets,
6
+ resolveCanvasNudgeDelta,
7
+ } from "./domEditNudge";
8
+
9
+ function mockKeyboardEvent(
10
+ key: string,
11
+ overrides: Partial<Pick<KeyboardEvent, "altKey" | "ctrlKey" | "metaKey" | "shiftKey">> = {},
12
+ ): Pick<KeyboardEvent, "altKey" | "ctrlKey" | "metaKey" | "shiftKey" | "key"> {
13
+ return {
14
+ altKey: false,
15
+ ctrlKey: false,
16
+ metaKey: false,
17
+ shiftKey: false,
18
+ key,
19
+ ...overrides,
20
+ };
21
+ }
22
+
23
+ describe("resolveCanvasNudgeDelta", () => {
24
+ it("maps plain arrows to 1px composition deltas", () => {
25
+ expect(resolveCanvasNudgeDelta(mockKeyboardEvent("ArrowLeft"))).toEqual({
26
+ dx: -CANVAS_NUDGE_STEP_PX,
27
+ dy: 0,
28
+ });
29
+ expect(resolveCanvasNudgeDelta(mockKeyboardEvent("ArrowRight"))).toEqual({
30
+ dx: CANVAS_NUDGE_STEP_PX,
31
+ dy: 0,
32
+ });
33
+ expect(resolveCanvasNudgeDelta(mockKeyboardEvent("ArrowUp"))).toEqual({
34
+ dx: 0,
35
+ dy: -CANVAS_NUDGE_STEP_PX,
36
+ });
37
+ expect(resolveCanvasNudgeDelta(mockKeyboardEvent("ArrowDown"))).toEqual({
38
+ dx: 0,
39
+ dy: CANVAS_NUDGE_STEP_PX,
40
+ });
41
+ });
42
+
43
+ it("maps Shift+arrow to 10px deltas", () => {
44
+ expect(resolveCanvasNudgeDelta(mockKeyboardEvent("ArrowRight", { shiftKey: true }))).toEqual({
45
+ dx: CANVAS_NUDGE_SHIFT_STEP_PX,
46
+ dy: 0,
47
+ });
48
+ expect(resolveCanvasNudgeDelta(mockKeyboardEvent("ArrowUp", { shiftKey: true }))).toEqual({
49
+ dx: 0,
50
+ dy: -CANVAS_NUDGE_SHIFT_STEP_PX,
51
+ });
52
+ });
53
+
54
+ it("ignores browser and app shortcut chords", () => {
55
+ expect(resolveCanvasNudgeDelta(mockKeyboardEvent("ArrowLeft", { altKey: true }))).toBeNull();
56
+ expect(resolveCanvasNudgeDelta(mockKeyboardEvent("ArrowLeft", { ctrlKey: true }))).toBeNull();
57
+ expect(resolveCanvasNudgeDelta(mockKeyboardEvent("ArrowLeft", { metaKey: true }))).toBeNull();
58
+ });
59
+
60
+ it("ignores non-arrow keys", () => {
61
+ expect(resolveCanvasNudgeDelta(mockKeyboardEvent("Escape"))).toBeNull();
62
+ expect(resolveCanvasNudgeDelta(mockKeyboardEvent("a"))).toBeNull();
63
+ });
64
+ });
65
+
66
+ describe("canCanvasNudgeTargets", () => {
67
+ const movable = { capabilities: { canApplyManualOffset: true } };
68
+ const locked = { capabilities: { canApplyManualOffset: false } };
69
+
70
+ it("requires at least one target", () => {
71
+ expect(canCanvasNudgeTargets([])).toBe(false);
72
+ });
73
+
74
+ it("allows only when every target accepts a manual offset", () => {
75
+ expect(canCanvasNudgeTargets([movable])).toBe(true);
76
+ expect(canCanvasNudgeTargets([movable, movable])).toBe(true);
77
+ expect(canCanvasNudgeTargets([locked])).toBe(false);
78
+ expect(canCanvasNudgeTargets([movable, locked])).toBe(false);
79
+ });
80
+ });
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Pure helpers for the canvas arrow-key nudge (DomEditOverlay).
3
+ *
4
+ * Mirrors captions/keyboard.ts conventions; the two nudge surfaces can't
5
+ * double-fire because PreviewOverlays mounts CaptionOverlay and DomEditOverlay
6
+ * mutually exclusively (caption edit mode returns before the DOM overlay).
7
+ */
8
+
9
+ const CANVAS_NUDGE_KEYS = new Set(["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"]);
10
+
11
+ export const CANVAS_NUDGE_STEP_PX = 1;
12
+ export const CANVAS_NUDGE_SHIFT_STEP_PX = 10;
13
+ /** One undo entry per key burst: the commit fires after this idle gap. */
14
+ export const CANVAS_NUDGE_COMMIT_DEBOUNCE_MS = 400;
15
+
16
+ type CanvasNudgeKeyEvent = Pick<
17
+ KeyboardEvent,
18
+ "altKey" | "ctrlKey" | "metaKey" | "shiftKey" | "key"
19
+ >;
20
+
21
+ /**
22
+ * Arrow key → composition-px delta (Shift = 10). Null when the key is not a
23
+ * plain/Shift arrow, so browser and app shortcut chords pass through.
24
+ */
25
+ export function resolveCanvasNudgeDelta(
26
+ event: CanvasNudgeKeyEvent,
27
+ ): { dx: number; dy: number } | null {
28
+ if (event.metaKey || event.ctrlKey || event.altKey) return null;
29
+ if (!CANVAS_NUDGE_KEYS.has(event.key)) return null;
30
+ const step = event.shiftKey ? CANVAS_NUDGE_SHIFT_STEP_PX : CANVAS_NUDGE_STEP_PX;
31
+ return {
32
+ dx: event.key === "ArrowLeft" ? -step : event.key === "ArrowRight" ? step : 0,
33
+ dy: event.key === "ArrowUp" ? -step : event.key === "ArrowDown" ? step : 0,
34
+ };
35
+ }
36
+
37
+ export interface CanvasNudgeTarget {
38
+ capabilities: { canApplyManualOffset: boolean };
39
+ }
40
+
41
+ /** A nudge needs at least one target and every target must accept manual offsets. */
42
+ export function canCanvasNudgeTargets(targets: ReadonlyArray<CanvasNudgeTarget>): boolean {
43
+ return targets.length > 0 && targets.every((t) => t.capabilities.canApplyManualOffset);
44
+ }
@@ -1,6 +1,37 @@
1
1
  // @vitest-environment jsdom
2
2
  import { describe, expect, it } from "vitest";
3
- import { selectionCacheKey } from "./domEditOverlayGeometry";
3
+ import {
4
+ orientedOverlayRect,
5
+ orientedGroupAwareOverlayRect,
6
+ overlayCornersCentroid,
7
+ selectionCacheKey,
8
+ } from "./domEditOverlayGeometry";
9
+
10
+ describe("overlayCornersCentroid", () => {
11
+ it("averages the four corners (the rendered rotation center)", () => {
12
+ expect(
13
+ overlayCornersCentroid({
14
+ nw: { x: 10, y: 20 },
15
+ ne: { x: 110, y: 20 },
16
+ se: { x: 110, y: 80 },
17
+ sw: { x: 10, y: 80 },
18
+ }),
19
+ ).toEqual({ x: 60, y: 50 });
20
+ });
21
+
22
+ it("is unchanged by rotation — a rotated square's corners average to its center", () => {
23
+ // Unit square centered at (5,5), rotated 45deg about its center: corners land
24
+ // on the axis midpoints, whose average is still the center.
25
+ const c = overlayCornersCentroid({
26
+ nw: { x: 5, y: 5 - Math.SQRT2 / 2 },
27
+ ne: { x: 5 + Math.SQRT2 / 2, y: 5 },
28
+ se: { x: 5, y: 5 + Math.SQRT2 / 2 },
29
+ sw: { x: 5 - Math.SQRT2 / 2, y: 5 },
30
+ });
31
+ expect(c.x).toBeCloseTo(5, 9);
32
+ expect(c.y).toBeCloseTo(5, 9);
33
+ });
34
+ });
4
35
 
5
36
  describe("selectionCacheKey — hfId collision (R7)", () => {
6
37
  it("produces distinct keys for two elements that differ only by hfId", () => {
@@ -11,3 +42,138 @@ describe("selectionCacheKey — hfId collision (R7)", () => {
11
42
  expect(a).not.toBe(b);
12
43
  });
13
44
  });
45
+
46
+ describe("orientedOverlayRect — rotation gate (perf fix, V15 18a/18b)", () => {
47
+ // jsdom has no DOMMatrix/DOMPoint; a minimal stand-in is enough to exercise the
48
+ // real (unmocked) orientedOverlayRect, unlike DomEditOverlay.test.ts and
49
+ // anchoredResizeCommitFeedsOffset.test.ts, which mock this module entirely.
50
+ class FakeDOMMatrix {
51
+ a = 1;
52
+ b = 0;
53
+ c = 0;
54
+ d = 1;
55
+ e = 0;
56
+ f = 0;
57
+ constructor(init?: string) {
58
+ const m = init ? /matrix\(([^)]+)\)/.exec(init) : null;
59
+ if (!m) return;
60
+ const parts = m[1]!.split(",").map((s) => Number.parseFloat(s.trim()));
61
+ [this.a, this.b, this.c, this.d, this.e, this.f] = parts as [
62
+ number,
63
+ number,
64
+ number,
65
+ number,
66
+ number,
67
+ number,
68
+ ];
69
+ }
70
+ transformPoint(pt: { x: number; y: number }) {
71
+ return {
72
+ x: this.a * pt.x + this.c * pt.y + this.e,
73
+ y: this.b * pt.x + this.d * pt.y + this.f,
74
+ };
75
+ }
76
+ }
77
+ class FakeDOMPoint {
78
+ constructor(
79
+ public x: number,
80
+ public y: number,
81
+ ) {}
82
+ }
83
+ // matrix() form of rotate(30deg), so the module's `new DOMMatrix(cs.transform)`
84
+ // parse (which expects "matrix(...)", not "rotate(...)") resolves correctly.
85
+ const ROTATE_30DEG_MATRIX =
86
+ "matrix(0.8660254037844387, 0.49999999999999994, -0.49999999999999994, 0.8660254037844387, 0, 0)";
87
+
88
+ function stubRect(
89
+ el: Element,
90
+ rect: { left: number; top: number; width: number; height: number },
91
+ ) {
92
+ (el as unknown as { getBoundingClientRect: () => DOMRect }).getBoundingClientRect = () =>
93
+ ({
94
+ left: rect.left,
95
+ top: rect.top,
96
+ width: rect.width,
97
+ height: rect.height,
98
+ right: rect.left + rect.width,
99
+ bottom: rect.top + rect.height,
100
+ x: rect.left,
101
+ y: rect.top,
102
+ toJSON() {
103
+ return this;
104
+ },
105
+ }) as DOMRect;
106
+ }
107
+
108
+ function buildHarness() {
109
+ const overlayEl = document.createElement("div");
110
+ document.body.appendChild(overlayEl);
111
+ stubRect(overlayEl, { left: 0, top: 0, width: 1000, height: 1000 });
112
+
113
+ const iframe = document.createElement("iframe");
114
+ document.body.appendChild(iframe);
115
+ stubRect(iframe, { left: 0, top: 0, width: 1000, height: 1000 });
116
+
117
+ const doc = iframe.contentDocument!;
118
+ const root = doc.createElement("div");
119
+ root.setAttribute("data-composition-id", "root");
120
+ root.setAttribute("data-width", "1000");
121
+ root.setAttribute("data-height", "1000");
122
+ doc.body.appendChild(root);
123
+
124
+ const el = doc.createElement("div");
125
+ root.appendChild(el);
126
+ stubRect(el, { left: 400, top: 450, width: 200, height: 100 });
127
+ Object.defineProperty(el, "offsetWidth", { value: 200, configurable: true });
128
+ Object.defineProperty(el, "offsetHeight", { value: 100, configurable: true });
129
+
130
+ const win = iframe.contentWindow as unknown as Window & {
131
+ DOMMatrix: unknown;
132
+ DOMPoint: unknown;
133
+ };
134
+ win.DOMMatrix = FakeDOMMatrix;
135
+ win.DOMPoint = FakeDOMPoint;
136
+
137
+ return { overlayEl, iframe, el };
138
+ }
139
+
140
+ it("unrotated element takes the cheap AABB path — matches the raw bounding rect, angle 0", () => {
141
+ const { overlayEl, iframe, el } = buildHarness();
142
+ const rect = orientedOverlayRect(overlayEl, iframe, el);
143
+ expect(rect).not.toBeNull();
144
+ expect(rect!.left).toBeCloseTo(400, 5);
145
+ expect(rect!.top).toBeCloseTo(450, 5);
146
+ expect(rect!.width).toBeCloseTo(200, 5);
147
+ expect(rect!.height).toBeCloseTo(100, 5);
148
+ expect(rect!.angle ?? 0).toBe(0);
149
+ });
150
+
151
+ it("rotated element takes the corner-geometry path — reports the live angle", () => {
152
+ const { overlayEl, iframe, el } = buildHarness();
153
+ el.style.transform = ROTATE_30DEG_MATRIX;
154
+ const rect = orientedOverlayRect(overlayEl, iframe, el);
155
+ expect(rect).not.toBeNull();
156
+ expect(rect!.angle).toBeCloseTo(30, 3);
157
+ });
158
+
159
+ it("preserves an ordinary element's rotation through the group-aware entry point", () => {
160
+ const { overlayEl, iframe, el } = buildHarness();
161
+ el.style.transform = ROTATE_30DEG_MATRIX;
162
+ const rect = orientedGroupAwareOverlayRect(overlayEl, iframe, el);
163
+ expect(rect!.angle).toBeCloseTo(30, 3);
164
+ });
165
+
166
+ it("gate re-evaluates every call — editing an element to rotated mid-session flips the path immediately", () => {
167
+ const { overlayEl, iframe, el } = buildHarness();
168
+ const before = orientedOverlayRect(overlayEl, iframe, el);
169
+ expect(before?.angle ?? 0).toBe(0);
170
+
171
+ el.style.transform = ROTATE_30DEG_MATRIX;
172
+ const after = orientedOverlayRect(overlayEl, iframe, el);
173
+ expect(after!.angle).toBeCloseTo(30, 3);
174
+
175
+ el.style.transform = "";
176
+ const restored = orientedOverlayRect(overlayEl, iframe, el);
177
+ expect(restored?.angle ?? 0).toBe(0);
178
+ });
179
+ });