@hyperframes/studio 0.7.55 → 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 (237) hide show
  1. package/dist/assets/{index-BXaqaVKt.js → index-BWnOxAiH.js} +1 -1
  2. package/dist/assets/{index-CPetwHFV.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 +40414 -37680
  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/DomEditCropHandles.test.tsx +74 -3
  21. package/src/components/editor/DomEditCropHandles.tsx +64 -29
  22. package/src/components/editor/DomEditOverlay.test.ts +245 -279
  23. package/src/components/editor/DomEditOverlay.tsx +158 -126
  24. package/src/components/editor/DomEditOverlayHover.test.tsx +90 -0
  25. package/src/components/editor/DomEditRotateHandle.tsx +38 -18
  26. package/src/components/editor/DomEditSelectionChrome.test.tsx +80 -0
  27. package/src/components/editor/DomEditSelectionChrome.tsx +257 -0
  28. package/src/components/editor/LayersPanel.test.ts +66 -2
  29. package/src/components/editor/LayersPanel.tsx +43 -1
  30. package/src/components/editor/OffCanvasIndicators.test.tsx +34 -0
  31. package/src/components/editor/OffCanvasIndicators.tsx +49 -10
  32. package/src/components/editor/SnapGuideOverlay.tsx +28 -23
  33. package/src/components/editor/SnapToolbar.test.tsx +0 -1
  34. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +214 -0
  35. package/src/components/editor/anchoredResizeReleaseShift.test.ts +106 -10
  36. package/src/components/editor/domEditNudge.test.ts +80 -0
  37. package/src/components/editor/domEditNudge.ts +44 -0
  38. package/src/components/editor/domEditOverlayGeometry.test.ts +167 -1
  39. package/src/components/editor/domEditOverlayGeometry.ts +280 -20
  40. package/src/components/editor/domEditOverlayGestures.ts +44 -41
  41. package/src/components/editor/domEditOverlayStartGesture.ts +62 -2
  42. package/src/components/editor/domEditResizeLocal.test.ts +131 -0
  43. package/src/components/editor/domEditResizeLocal.ts +125 -0
  44. package/src/components/editor/domEditingDom.ts +1 -1
  45. package/src/components/editor/manualEditsDomPatches.test.ts +47 -0
  46. package/src/components/editor/manualOffsetDrag.ts +55 -12
  47. package/src/components/editor/offCanvasIndicatorGeometry.ts +31 -10
  48. package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +25 -1
  49. package/src/components/editor/resizeDraft.ts +108 -0
  50. package/src/components/editor/snapEngine.test.ts +12 -78
  51. package/src/components/editor/snapEngine.ts +13 -53
  52. package/src/components/editor/useDomEditNudge.test.tsx +228 -0
  53. package/src/components/editor/useDomEditNudge.ts +254 -0
  54. package/src/components/editor/useDomEditOverlayGestures.ts +115 -116
  55. package/src/components/editor/useDomEditOverlayRects.ts +9 -2
  56. package/src/components/nle/AssetPreviewOverlay.tsx +147 -0
  57. package/src/components/nle/NLEContext.test.ts +144 -0
  58. package/src/components/nle/NLEContext.tsx +328 -0
  59. package/src/components/nle/NLEPreview.tsx +1 -1
  60. package/src/components/nle/PreviewOverlays.tsx +240 -0
  61. package/src/components/nle/PreviewPane.tsx +163 -0
  62. package/src/components/nle/TimelinePane.test.ts +60 -0
  63. package/src/components/nle/TimelinePane.tsx +293 -0
  64. package/src/components/nle/TimelineResizeDivider.tsx +8 -2
  65. package/src/components/nle/useCompositionStack.test.tsx +44 -0
  66. package/src/components/nle/useCompositionStack.ts +17 -9
  67. package/src/components/nle/useTimelineEditCallbacks.ts +217 -0
  68. package/src/components/sidebar/AssetCard.tsx +326 -0
  69. package/src/components/sidebar/AssetContextMenu.tsx +15 -30
  70. package/src/components/sidebar/AssetsTab.test.ts +96 -0
  71. package/src/components/sidebar/AssetsTab.tsx +71 -206
  72. package/src/components/sidebar/AudioRow.tsx +39 -2
  73. package/src/components/sidebar/BlocksTab.tsx +8 -1
  74. package/src/components/sidebar/LeftSidebar.tsx +4 -1
  75. package/src/components/sidebar/assetHelpers.ts +29 -0
  76. package/src/contexts/DomEditContext.tsx +9 -0
  77. package/src/contexts/StudioContext.tsx +9 -8
  78. package/src/contexts/TimelineEditContext.tsx +3 -7
  79. package/src/hooks/deleteSelectedKeyframes.ts +35 -0
  80. package/src/hooks/domEditCommitTypes.ts +12 -1
  81. package/src/hooks/domSelectionTestHarness.ts +14 -0
  82. package/src/hooks/gestureTransaction.test.ts +311 -0
  83. package/src/hooks/gestureTransaction.ts +199 -0
  84. package/src/hooks/gsapDragCommit.test.ts +141 -41
  85. package/src/hooks/gsapDragCommit.ts +64 -74
  86. package/src/hooks/gsapDragStaticSetHelpers.ts +9 -21
  87. package/src/hooks/gsapResizeIntercept.test.ts +69 -0
  88. package/src/hooks/gsapResizeIntercept.ts +38 -10
  89. package/src/hooks/gsapRuntimeBridge.test.ts +49 -13
  90. package/src/hooks/gsapRuntimeBridge.ts +4 -4
  91. package/src/hooks/gsapScriptCommitTypes.ts +16 -5
  92. package/src/hooks/gsapShared.test.ts +19 -1
  93. package/src/hooks/gsapShared.ts +14 -0
  94. package/src/hooks/patchDocumentRootDuration.test.ts +71 -0
  95. package/src/hooks/timelineEditingGsap.ts +14 -0
  96. package/src/hooks/timelineEditingHelpers.test.ts +34 -1
  97. package/src/hooks/timelineEditingHelpers.ts +22 -48
  98. package/src/hooks/timelineMoveAdapter.test.ts +77 -0
  99. package/src/hooks/timelineMoveAdapter.ts +37 -0
  100. package/src/hooks/useAnimatedPropertyCommit.test.tsx +36 -8
  101. package/src/hooks/useAnimatedPropertyCommit.ts +31 -26
  102. package/src/hooks/useAppHotkeys.ts +16 -25
  103. package/src/hooks/useBlockHandlers.ts +55 -27
  104. package/src/hooks/useContextMenuDismiss.ts +31 -7
  105. package/src/hooks/useDomEditCommits.test.tsx +154 -1
  106. package/src/hooks/useDomEditCommits.ts +100 -3
  107. package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
  108. package/src/hooks/useDomEditSession.ts +2 -2
  109. package/src/hooks/useDomEditTextCommits.test.tsx +135 -0
  110. package/src/hooks/useDomEditTextCommits.ts +8 -3
  111. package/src/hooks/useDomEditWiring.ts +7 -9
  112. package/src/hooks/useDomGeometryCommits.ts +17 -2
  113. package/src/hooks/useDomSelection.test.ts +2 -36
  114. package/src/hooks/useDomSelection.ts +81 -51
  115. package/src/hooks/useDomSelectionSelectionGuards.test.ts +276 -0
  116. package/src/hooks/useElementLifecycleOps.test.tsx +258 -0
  117. package/src/hooks/useElementLifecycleOps.ts +40 -71
  118. package/src/hooks/useEnableKeyframes.test.ts +142 -1
  119. package/src/hooks/useEnableKeyframes.ts +73 -29
  120. package/src/hooks/useGestureCommit.test.tsx +130 -0
  121. package/src/hooks/useGestureCommit.ts +32 -8
  122. package/src/hooks/useGroupCommits.ts +7 -28
  123. package/src/hooks/useGsapAwareEditing.test.tsx +239 -0
  124. package/src/hooks/useGsapAwareEditing.ts +115 -20
  125. package/src/hooks/useGsapKeyframeOps.test.tsx +59 -0
  126. package/src/hooks/useGsapKeyframeOps.ts +38 -10
  127. package/src/hooks/useGsapScriptCommits.test.tsx +40 -1
  128. package/src/hooks/useGsapScriptCommits.ts +134 -50
  129. package/src/hooks/useGsapSelectionHandlers.ts +49 -16
  130. package/src/hooks/useMusicBeatAnalysis.ts +72 -36
  131. package/src/hooks/usePersistentEditHistory.test.ts +33 -0
  132. package/src/hooks/usePersistentEditHistory.ts +92 -58
  133. package/src/hooks/usePreviewPersistence.ts +25 -7
  134. package/src/hooks/useRazorSplit.history.test.tsx +148 -18
  135. package/src/hooks/useRazorSplit.test.ts +269 -0
  136. package/src/hooks/useRazorSplit.testHelpers.ts +64 -0
  137. package/src/hooks/useRazorSplit.ts +107 -36
  138. package/src/hooks/useRenderClipContent.ts +24 -6
  139. package/src/hooks/useStudioContextValue.ts +15 -5
  140. package/src/hooks/useStudioUrlState.ts +2 -4
  141. package/src/hooks/useTimelineEditing.test.tsx +20 -29
  142. package/src/hooks/useTimelineEditingTypes.ts +6 -0
  143. package/src/hooks/useTimelineGroupEditing.ts +18 -4
  144. package/src/index.ts +1 -1
  145. package/src/player/components/ImageThumbnail.test.tsx +173 -0
  146. package/src/player/components/ImageThumbnail.tsx +160 -0
  147. package/src/player/components/PlayheadIndicator.tsx +50 -5
  148. package/src/player/components/Timeline.test.ts +102 -19
  149. package/src/player/components/Timeline.tsx +194 -194
  150. package/src/player/components/TimelineCanvas.tsx +207 -521
  151. package/src/player/components/TimelineClip.tsx +4 -1
  152. package/src/player/components/TimelineClipDiamonds.tsx +9 -40
  153. package/src/player/components/TimelineEmptyState.tsx +3 -1
  154. package/src/player/components/TimelineLanes.tsx +487 -0
  155. package/src/player/components/TimelineOverlays.tsx +122 -0
  156. package/src/player/components/VideoThumbnail.test.tsx +152 -0
  157. package/src/player/components/VideoThumbnail.tsx +28 -7
  158. package/src/player/components/thumbnailUtils.test.ts +127 -0
  159. package/src/player/components/thumbnailUtils.ts +54 -0
  160. package/src/player/components/timelineCallbacks.ts +18 -18
  161. package/src/player/components/timelineClipChildren.tsx +39 -0
  162. package/src/player/components/timelineClipDragCommit.test.ts +943 -0
  163. package/src/player/components/timelineClipDragCommit.ts +401 -0
  164. package/src/player/components/timelineClipDragPreview.test.ts +144 -0
  165. package/src/player/components/timelineClipDragPreview.ts +323 -38
  166. package/src/player/components/timelineClipDragTypes.ts +65 -0
  167. package/src/player/components/timelineCollision.test.ts +42 -2
  168. package/src/player/components/timelineDragDrop.ts +69 -60
  169. package/src/player/components/timelineEditCapabilities.ts +52 -0
  170. package/src/player/components/timelineEditing.test.ts +1 -1
  171. package/src/player/components/timelineEditing.ts +76 -59
  172. package/src/player/components/timelineGroupEditing.test.ts +126 -0
  173. package/src/player/components/timelineGroupEditing.ts +133 -0
  174. package/src/player/components/timelineGroupResizeCommit.ts +54 -0
  175. package/src/player/components/timelineLayout.test.ts +95 -23
  176. package/src/player/components/timelineLayout.ts +120 -61
  177. package/src/player/components/timelineMarquee.test.ts +197 -0
  178. package/src/player/components/timelineMarquee.ts +112 -0
  179. package/src/player/components/timelineOptimisticRevision.ts +42 -0
  180. package/src/player/components/timelineTheme.ts +6 -1
  181. package/src/player/components/useResolvedTimelineEditCallbacks.ts +25 -3
  182. package/src/player/components/useTimelineClipDrag.resize.test.tsx +241 -0
  183. package/src/player/components/useTimelineClipDrag.ts +380 -386
  184. package/src/player/components/useTimelineEditPinning.ts +121 -0
  185. package/src/player/components/useTimelineGeometry.ts +129 -0
  186. package/src/player/components/useTimelinePlayhead.ts +4 -16
  187. package/src/player/components/useTimelineRangeSelection.ts +341 -50
  188. package/src/player/components/useTimelineStackingSync.test.tsx +67 -0
  189. package/src/player/components/useTimelineStackingSync.ts +83 -0
  190. package/src/player/hooks/useExpandedTimelineElements.ts +0 -3
  191. package/src/player/hooks/usePlaybackKeyboard.ts +5 -0
  192. package/src/player/hooks/useTimelinePlayer.ts +48 -53
  193. package/src/player/hooks/useTimelinePlayerLoop.ts +85 -0
  194. package/src/player/index.ts +3 -2
  195. package/src/player/lib/time.test.ts +19 -19
  196. package/src/player/lib/time.ts +6 -2
  197. package/src/player/lib/timelineDOM.test.ts +25 -98
  198. package/src/player/lib/timelineDOM.ts +12 -81
  199. package/src/player/lib/timelineElementHelpers.ts +54 -5
  200. package/src/player/lib/timelineIframeHelpers.ts +2 -0
  201. package/src/player/store/playerStore.ts +62 -3
  202. package/src/styles/studio.css +27 -0
  203. package/src/utils/assetPreviewStore.ts +33 -0
  204. package/src/utils/blockInstaller.ts +10 -2
  205. package/src/utils/editHistory.test.ts +35 -0
  206. package/src/utils/gsapSoftReload.test.ts +119 -1
  207. package/src/utils/gsapSoftReload.ts +153 -0
  208. package/src/utils/mediaTypes.ts +3 -2
  209. package/src/utils/resizeDebug.ts +55 -0
  210. package/src/utils/rootDuration.test.ts +90 -1
  211. package/src/utils/rootDuration.ts +76 -13
  212. package/src/utils/sdkCutover.ts +4 -0
  213. package/src/utils/studioFileHistory.ts +37 -7
  214. package/src/utils/studioHelpers.ts +62 -0
  215. package/src/utils/studioPreviewHelpers.test.ts +65 -8
  216. package/src/utils/studioPreviewHelpers.ts +10 -0
  217. package/src/utils/studioTelemetry.ts +4 -1
  218. package/src/utils/studioUrlState.test.ts +0 -1
  219. package/src/utils/timelineAssetDrop.ts +62 -0
  220. package/src/utils/timelineDiscovery.ts +0 -17
  221. package/dist/assets/index-BRwkMj0w.js +0 -423
  222. package/dist/assets/index-Dq7FEg0K.css +0 -1
  223. package/dist/chunk-SOTCF4DF.js.map +0 -1
  224. package/src/components/StudioPreviewArea.tsx +0 -500
  225. package/src/components/nle/NLELayout.test.ts +0 -12
  226. package/src/components/nle/NLELayout.tsx +0 -591
  227. package/src/player/components/TimelineLayerGutter.tsx +0 -61
  228. package/src/player/components/TimelineSelectionOverlays.tsx +0 -55
  229. package/src/player/components/timelineMarqueeSelection.test.ts +0 -87
  230. package/src/player/components/timelineSnapTargets.test.ts +0 -144
  231. package/src/player/components/timelineSnapTargets.ts +0 -164
  232. package/src/player/components/useTimelineClipDrag.test.tsx +0 -506
  233. package/src/player/components/useTimelineClipGroupDrag.ts +0 -417
  234. package/src/player/components/useTimelineMarqueeSelection.test.tsx +0 -231
  235. package/src/player/components/useTimelineMarqueeSelection.ts +0 -276
  236. package/src/utils/timelineDiscovery.test.ts +0 -90
  237. /package/dist/{domEditingLayers-2ECJK24D.js.map → domEditingLayers-6LQGKPOI.js.map} +0 -0
@@ -0,0 +1,214 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+ import type { DomEditSelection } from "./domEditing";
5
+ import type { GestureState, UseDomEditOverlayGesturesOptions } from "./domEditOverlayGestures";
6
+
7
+ // Origin box (overlay px). The gesture-start center is its centroid; the
8
+ // center-anchored resize must keep THIS point planted, which means the release
9
+ // must commit a nonzero offset equal to minus half the size growth.
10
+ const ORIGIN = { left: 0, top: 0, width: 200, height: 100 };
11
+ const ORIGIN_CENTER = {
12
+ x: ORIGIN.left + ORIGIN.width / 2,
13
+ y: ORIGIN.top + ORIGIN.height / 2,
14
+ };
15
+
16
+ // Consistent geometry stub: model the physical truth the real DOM would report.
17
+ // A CSS width/height change grows the box from its top-left, so the rendered
18
+ // center drifts by half the size delta; the manual offset the gesture applies
19
+ // (read back from the element's studio vars) pulls it back. `elementCornerOverlayPoints`
20
+ // returns the four corners of that drifted box; `overlayCornersCentroid` (kept
21
+ // real) averages them so the anchor loop can measure the true center each frame.
22
+ vi.mock("./domEditOverlayGeometry", async () => {
23
+ const actual = await vi.importActual<typeof import("./domEditOverlayGeometry")>(
24
+ "./domEditOverlayGeometry",
25
+ );
26
+ const { readStudioBoxSize, readStudioPathOffset } = await import("./manualEditsDom");
27
+ const physicalCenter = (element: HTMLElement) => {
28
+ const size = readStudioBoxSize(element);
29
+ const width = size.width > 0 ? size.width : ORIGIN.width;
30
+ const height = size.height > 0 ? size.height : ORIGIN.height;
31
+ const offset = readStudioPathOffset(element);
32
+ return {
33
+ x: ORIGIN_CENTER.x + (width - ORIGIN.width) / 2 + offset.x,
34
+ y: ORIGIN_CENTER.y + (height - ORIGIN.height) / 2 + offset.y,
35
+ width,
36
+ height,
37
+ };
38
+ };
39
+ return {
40
+ ...actual,
41
+ elementCornerOverlayPoints: (_o: unknown, _i: unknown, element: HTMLElement) => {
42
+ const c = physicalCenter(element);
43
+ const hw = c.width / 2;
44
+ const hh = c.height / 2;
45
+ return {
46
+ nw: { x: c.x - hw, y: c.y - hh },
47
+ ne: { x: c.x + hw, y: c.y - hh },
48
+ sw: { x: c.x - hw, y: c.y + hh },
49
+ se: { x: c.x + hw, y: c.y + hh },
50
+ };
51
+ },
52
+ orientedOverlayRect: (_o: unknown, _i: unknown, element: HTMLElement) => {
53
+ const c = physicalCenter(element);
54
+ return {
55
+ left: c.x - c.width / 2,
56
+ top: c.y - c.height / 2,
57
+ width: c.width,
58
+ height: c.height,
59
+ editScaleX: 1,
60
+ editScaleY: 1,
61
+ angle: 0,
62
+ };
63
+ },
64
+ };
65
+ });
66
+
67
+ const { createDomEditOverlayGestureHandlers } = await import("./useDomEditOverlayGestures");
68
+
69
+ function ref<T>(current: T) {
70
+ return { current };
71
+ }
72
+
73
+ interface CommitCall {
74
+ size: { width: number; height: number };
75
+ offset: { x: number; y: number } | undefined;
76
+ }
77
+
78
+ function buildHarness() {
79
+ const element = document.createElement("div");
80
+ document.body.append(element);
81
+
82
+ const selection = {
83
+ element,
84
+ id: "box",
85
+ selector: "#box",
86
+ selectorIndex: 0,
87
+ sourceFile: "index.html",
88
+ tagName: "div",
89
+ label: "Box",
90
+ textContent: "",
91
+ textFields: [],
92
+ capabilities: {
93
+ canEditText: false,
94
+ canEditLayout: true,
95
+ canMove: true,
96
+ canApplyManualOffset: true,
97
+ canApplyManualSize: true,
98
+ canApplyManualRotation: false,
99
+ canAdjustOpacity: true,
100
+ canAdjustFill: true,
101
+ canAdjustBorderRadius: true,
102
+ canAdjustStroke: true,
103
+ canAdjustShadow: true,
104
+ canAdjustZIndex: true,
105
+ },
106
+ computedStyle: { display: "block", position: "absolute" },
107
+ } as unknown as DomEditSelection;
108
+
109
+ const commits: CommitCall[] = [];
110
+ const overlayEl = document.createElement("div");
111
+ const iframe = document.createElement("iframe");
112
+
113
+ const opts: UseDomEditOverlayGesturesOptions = {
114
+ overlayRef: ref<HTMLDivElement | null>(overlayEl),
115
+ iframeRef: ref<HTMLIFrameElement | null>(iframe),
116
+ boxRef: ref<HTMLDivElement | null>(document.createElement("div")),
117
+ selectionRef: ref<DomEditSelection | null>(selection),
118
+ hoverSelectionRef: ref<DomEditSelection | null>(null),
119
+ overlayRectRef: ref<OverlayRectLike | null>({
120
+ left: ORIGIN.left,
121
+ top: ORIGIN.top,
122
+ width: ORIGIN.width,
123
+ height: ORIGIN.height,
124
+ editScaleX: 1,
125
+ editScaleY: 1,
126
+ }) as never,
127
+ groupOverlayItemsRef: ref([]),
128
+ gestureRef: ref<GestureState | null>(null),
129
+ groupGestureRef: ref(null),
130
+ blockedMoveRef: ref(null),
131
+ rafPausedRef: ref(false),
132
+ suppressNextBoxClickRef: ref(false),
133
+ setOverlayRect: () => {},
134
+ setGroupOverlayItems: () => {},
135
+ onBlockedMoveRef: ref(() => {}),
136
+ onManualDragStartRef: ref(() => {}),
137
+ onPathOffsetCommitRef: ref(() => {}),
138
+ onGroupPathOffsetCommitRef: ref(() => {}),
139
+ onBoxSizeCommitRef: ref((_s, size, offset) => {
140
+ commits.push({ size, offset });
141
+ }),
142
+ onRotationCommitRef: ref(() => {}),
143
+ onCanvasPointerMoveRef: ref(() => Promise.resolve(null)),
144
+ onCanvasMouseDown: () => {},
145
+ snapGuidesRef: ref(null),
146
+ };
147
+
148
+ const handlers = createDomEditOverlayGestureHandlers(opts);
149
+ return { handlers, commits, selection };
150
+ }
151
+
152
+ type OverlayRectLike = {
153
+ left: number;
154
+ top: number;
155
+ width: number;
156
+ height: number;
157
+ editScaleX: number;
158
+ editScaleY: number;
159
+ };
160
+
161
+ function evt(clientX: number, clientY: number) {
162
+ return {
163
+ clientX,
164
+ clientY,
165
+ pointerId: 1,
166
+ button: 0,
167
+ altKey: false,
168
+ shiftKey: false,
169
+ preventDefault() {},
170
+ stopPropagation() {},
171
+ currentTarget: { setPointerCapture() {} },
172
+ } as unknown as React.PointerEvent<HTMLDivElement>;
173
+ }
174
+
175
+ afterEach(() => {
176
+ document.body.innerHTML = "";
177
+ });
178
+
179
+ describe("anchored corner resize — the release commit feeds the center-pin offset", () => {
180
+ it("onPointerUp passes a nonzero offset that keeps the center planted", () => {
181
+ const { handlers, commits } = buildHarness();
182
+
183
+ // Start an SE corner resize. Pointer starts 100px right of the center.
184
+ handlers.startGesture("resize", evt(ORIGIN_CENTER.x + 100, ORIGIN_CENTER.y), {
185
+ resizeHandle: "se",
186
+ });
187
+
188
+ // Drag outward to radial scale 1.5 (dist 150 / 100). Several frames so the
189
+ // per-frame center-pin anchor accumulates and converges into g.lastResizeAnchor.
190
+ for (let i = 0; i < 5; i++) {
191
+ handlers.onPointerMove(evt(ORIGIN_CENTER.x + 150, ORIGIN_CENTER.y));
192
+ }
193
+
194
+ handlers.onPointerUp(evt(ORIGIN_CENTER.x + 150, ORIGIN_CENTER.y));
195
+
196
+ expect(commits).toHaveLength(1);
197
+ const { size, offset } = commits[0]!;
198
+
199
+ // Proportional 1.5x growth of the 200x100 base.
200
+ expect(size.width).toBeCloseTo(300, 0);
201
+ expect(size.height).toBeCloseTo(150, 0);
202
+
203
+ // The committed offset must be present and nonzero — the open question.
204
+ expect(offset).toBeDefined();
205
+ if (!offset) return;
206
+ expect(offset.x).not.toBe(0);
207
+ expect(offset.y).not.toBe(0);
208
+
209
+ // And it must equal minus half the size growth, i.e. it re-pins the center to
210
+ // exactly the gesture-start center (offset = -(finalSize - origin)/2).
211
+ expect(offset.x).toBeCloseTo(-(size.width - ORIGIN.width) / 2, 0);
212
+ expect(offset.y).toBeCloseTo(-(size.height - ORIGIN.height) / 2, 0);
213
+ });
214
+ });
@@ -8,6 +8,7 @@ import {
8
8
  } from "./manualEditsDom";
9
9
  import { buildBoxSizePatches, buildPathOffsetPatches } from "./manualEditsDomPatches";
10
10
  import { createManualOffsetDragMember, applyManualOffsetDragCommit } from "./manualOffsetDrag";
11
+ import { computeNextResizeAnchor } from "./domEditResizeLocal";
11
12
  import type { PatchOperation } from "../../utils/sourcePatcher";
12
13
  import { splitTopLevelWhitespace } from "./manualEditsStyleHelpers";
13
14
 
@@ -16,15 +17,25 @@ import { splitTopLevelWhitespace } from "./manualEditsStyleHelpers";
16
17
  * CENTER, which must stay planted across the whole gesture — including after
17
18
  * release, on every corner and at any rotation.
18
19
  *
19
- * This file lands here with ONLY the persist round-trip test, which exercises the
20
- * real apply persist reload symbols that already exist at this point in the
21
- * stack. The two center-anchor CONVERGENCE tests (the release-shift root cause)
22
- * drive the exported `computeNextResizeAnchor` accumulator, which is extracted from
23
- * the resize pointermove branch of `useDomEditOverlayGestures.ts`. That gesture code
24
- * lands later in the stack (with the canvas glue swap), so those two tests are added
25
- * to this file at that point — importing the real production helper rather than a
26
- * test-local copy, so they can never pass against a stand-in that drifts from the
27
- * shipped math.
20
+ * Root cause of the original release "shift" (proved with a real-layout Chromium
21
+ * replay, see the anchor-loop test below): during a resize drag the per-frame
22
+ * anchor is derived from the element's LIVE measured center — which already carries
23
+ * the offset applied on the PREVIOUS frame — while `applyManualOffsetDragDraft`
24
+ * treats that anchor as the ABSOLUTE offset. So `fixedStart - centerNow` is really
25
+ * only the RESIDUAL correction, and using it as the absolute value makes the anchor
26
+ * OSCILLATE between the correct value and zero every frame:
27
+ * frame 0: offset 0 center shifted by the resize anchor = full amount → apply
28
+ * frame 1: offset applied → center back at fixedStart → anchor = 0 → apply 0 (un-pin!)
29
+ * frame 2: offset 0 again → anchor = full amount → ...
30
+ * Release commits `g.lastResizeAnchor` from whichever parity the last pointermove
31
+ * landed on, so the element lands EITHER pinned OR un-pinned — an unpredictable
32
+ * post-release "shift".
33
+ *
34
+ * Fix (useDomEditOverlayGestures pointermove, resize branch, fa4f39168): accumulate
35
+ * the residual onto the previously-applied anchor instead of using it as the
36
+ * absolute offset, so the loop converges to a stable value on every frame. The
37
+ * per-frame accumulation is the exported `computeNextResizeAnchor` helper (the one
38
+ * call site in the pointermove resize branch); tests 1 & 2 drive it directly.
28
39
  */
29
40
 
30
41
  afterEach(() => {
@@ -66,10 +77,95 @@ function resolvedTranslatePx(el: HTMLElement): { x: number; y: number } {
66
77
  }
67
78
 
68
79
  describe("center-anchored corner resize — no shift after release", () => {
80
+ it("the per-frame center anchor converges (does NOT oscillate) — the release-shift root cause", () => {
81
+ // Model the pointermove anchor loop that pins the element's CENTER. The physical
82
+ // truth (confirmed in a real browser): the measured center sits at
83
+ // `fixedStart - appliedOffset` shifted by the resize — i.e. applying the offset
84
+ // moves the center back toward its gesture-start position. Here scale=1 so
85
+ // screen px == offset px. `trueAnchor` is the compensating offset that pins it.
86
+ const trueAnchor = { dx: -60, dy: -27 };
87
+ const fixedStart = { x: 500, y: 270 };
88
+
89
+ // `appliedOffset` mirrors what applyManualOffsetDragDraft set last frame.
90
+ let appliedOffset = { x: 0, y: 0 };
91
+ // `lastResizeAnchor` accumulator, exactly as g.lastResizeAnchor in the fix.
92
+ let lastResizeAnchor: { dx: number; dy: number } | undefined;
93
+
94
+ const anchorsSeen: Array<{ dx: number; dy: number }> = [];
95
+ for (let frame = 0; frame < 8; frame++) {
96
+ // Live measured center: the resize would put it at fixedStart + trueAnchor
97
+ // (un-anchored), and the currently-applied offset pulls it back by that
98
+ // offset. So centerNow = fixedStart - trueAnchor + appliedOffset.
99
+ const centerNow = {
100
+ x: fixedStart.x - trueAnchor.dx + appliedOffset.x,
101
+ y: fixedStart.y - trueAnchor.dy + appliedOffset.y,
102
+ };
103
+ // ── The fixed logic (accumulate residual onto the previous anchor) ──
104
+ const anchor = computeNextResizeAnchor(lastResizeAnchor, fixedStart, centerNow);
105
+ lastResizeAnchor = anchor;
106
+ anchorsSeen.push(anchor);
107
+ // applyManualOffsetDragDraft sets the absolute offset (scale 1) = anchor.
108
+ appliedOffset = { x: anchor.dx, y: anchor.dy };
109
+ }
110
+
111
+ // Every frame must report the same, correct anchor — no oscillation, so the
112
+ // committed value is parity-independent.
113
+ for (const a of anchorsSeen) {
114
+ expect(a).toEqual(trueAnchor);
115
+ }
116
+ // Guard against the OLD absolute formula regressing: with `anchor =
117
+ // fixedStart - centerNow` (no accumulation) the sequence would be
118
+ // [trueAnchor, 0, trueAnchor, 0, ...]; assert the last two frames agree.
119
+ expect(anchorsSeen.at(-1)).toEqual(anchorsSeen.at(-2));
120
+ });
121
+
122
+ it("the center stays fixed for every corner at any rotation (loop converges)", () => {
123
+ // The pin loop is handle- and rotation-independent: it always measures the
124
+ // element CENTER and drives it back to fixedStart. Simulate the loop for all
125
+ // four corners across unrotated + rotated gestures; the resize's raw center
126
+ // shift varies with corner/rotation (modelled as `rawShift`), but the loop must
127
+ // converge the measured center onto fixedStart every time.
128
+ const fixedStart = { x: 640, y: 360 };
129
+ const HANDLES = ["nw", "ne", "sw", "se"] as const;
130
+ const DEGS = [0, 30, 90, 137];
131
+ for (const handle of HANDLES) {
132
+ for (const deg of DEGS) {
133
+ const t = (deg * Math.PI) / 180;
134
+ // Raw (un-pinned) center shift the size write would cause this frame —
135
+ // a corner/rotation-dependent vector. Its exact value is irrelevant; the
136
+ // loop only needs to cancel it.
137
+ const seed = (HANDLES.indexOf(handle) + 1) * 11;
138
+ const rawShift = {
139
+ dx: Math.cos(t) * seed - Math.sin(t) * (seed / 2),
140
+ dy: Math.sin(t) * seed + Math.cos(t) * (seed / 2),
141
+ };
142
+ let appliedOffset = { x: 0, y: 0 };
143
+ let lastResizeAnchor: { dx: number; dy: number } | undefined;
144
+ let centerNow = { x: fixedStart.x, y: fixedStart.y };
145
+ for (let frame = 0; frame < 6; frame++) {
146
+ centerNow = {
147
+ x: fixedStart.x + rawShift.dx + appliedOffset.x,
148
+ y: fixedStart.y + rawShift.dy + appliedOffset.y,
149
+ };
150
+ const anchor = computeNextResizeAnchor(lastResizeAnchor, fixedStart, centerNow);
151
+ lastResizeAnchor = anchor;
152
+ appliedOffset = { x: anchor.dx, y: anchor.dy };
153
+ }
154
+ // After convergence the pinned center equals the gesture-start center.
155
+ const pinnedCenter = {
156
+ x: fixedStart.x + rawShift.dx + appliedOffset.x,
157
+ y: fixedStart.y + rawShift.dy + appliedOffset.y,
158
+ };
159
+ expect(pinnedCenter.x).toBeCloseTo(fixedStart.x, 9);
160
+ expect(pinnedCenter.y).toBeCloseTo(fixedStart.y, 9);
161
+ }
162
+ }
163
+ });
164
+
69
165
  it("net translate after persist+reload equals the committed anchor offset (non-GSAP)", () => {
70
166
  // The committed offset flows through the real apply → persist → reload chain
71
167
  // unchanged (this hop was proved clean; the shift is upstream in the anchor
72
- // loop, tested with the gesture code, not in persistence).
168
+ // loop above, not in persistence).
73
169
  const el = document.createElement("div");
74
170
  el.style.setProperty("width", "200px");
75
171
  el.style.setProperty("height", "100px");
@@ -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
+ });