@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,131 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ resolveCenterResizeScale,
4
+ resolveCenterResizeSize,
5
+ resolveRotatedResizeCursor,
6
+ } from "./domEditResizeLocal";
7
+
8
+ const DEG = Math.PI / 180;
9
+
10
+ describe("resolveCenterResizeScale — radial distance from the center", () => {
11
+ it("scale is the ratio of pointer-to-center distances", () => {
12
+ // start 100px from center, now 150px from center → 1.5x.
13
+ expect(
14
+ resolveCenterResizeScale({
15
+ centerStart: { x: 100, y: 100 },
16
+ pointerStart: { x: 200, y: 100 },
17
+ pointer: { x: 250, y: 100 },
18
+ }),
19
+ ).toBeCloseTo(1.5, 9);
20
+ });
21
+
22
+ it("shrinks as the pointer moves toward the center", () => {
23
+ expect(
24
+ resolveCenterResizeScale({
25
+ centerStart: { x: 0, y: 0 },
26
+ pointerStart: { x: 0, y: 200 },
27
+ pointer: { x: 0, y: 50 },
28
+ }),
29
+ ).toBeCloseTo(0.25, 9);
30
+ });
31
+
32
+ it("bails to 1 when the gesture starts at (or ~at) the center (degenerate)", () => {
33
+ expect(
34
+ resolveCenterResizeScale({
35
+ centerStart: { x: 100, y: 100 },
36
+ pointerStart: { x: 101, y: 100 },
37
+ pointer: { x: 400, y: 400 },
38
+ }),
39
+ ).toBe(1);
40
+ });
41
+ });
42
+
43
+ describe("resolveCenterResizeSize — rotation is a non-event (radial distance)", () => {
44
+ // The pure function takes no rotation argument: rotating the WHOLE gesture
45
+ // (center, pointer-down, pointer-now) about the center by any angle preserves
46
+ // every radial distance, so the returned size is identical at 0/37/90deg.
47
+ const base = { baseWidth: 240, baseHeight: 120 };
48
+ const centerStart = { x: 320, y: 180 };
49
+ // A pointer-down 100px right of center, dragged to 160px right of center (1.6x).
50
+ const p0 = { x: 100, y: 0 };
51
+ const p1 = { x: 160, y: 0 };
52
+ const rot = (v: { x: number; y: number }, t: number) => ({
53
+ x: centerStart.x + (Math.cos(t) * v.x - Math.sin(t) * v.y),
54
+ y: centerStart.y + (Math.sin(t) * v.x + Math.cos(t) * v.y),
55
+ });
56
+
57
+ for (const deg of [0, 37, 90]) {
58
+ it(`@${deg}deg: proportional 1.6x scale, same result as unrotated`, () => {
59
+ const t = deg * DEG;
60
+ const out = resolveCenterResizeSize({
61
+ ...base,
62
+ centerStart,
63
+ pointerStart: rot(p0, t),
64
+ pointer: rot(p1, t),
65
+ });
66
+ expect(out.width).toBeCloseTo(240 * 1.6, 6);
67
+ expect(out.height).toBeCloseTo(120 * 1.6, 6);
68
+ expect(out.width / out.height).toBeCloseTo(2, 9);
69
+ });
70
+ }
71
+
72
+ it("shrink toward center keeps the aspect ratio", () => {
73
+ const out = resolveCenterResizeSize({
74
+ baseWidth: 300,
75
+ baseHeight: 180,
76
+ centerStart: { x: 0, y: 0 },
77
+ pointerStart: { x: 0, y: 200 },
78
+ pointer: { x: 0, y: 120 },
79
+ });
80
+ expect(out.width).toBeCloseTo(300 * 0.6, 6);
81
+ expect(out.height).toBeCloseTo(180 * 0.6, 6);
82
+ expect(out.width / out.height).toBeCloseTo(300 / 180, 9);
83
+ });
84
+
85
+ it("clamps at the local minimum, never mirroring through zero (drag past center)", () => {
86
+ // Pointer dragged to the exact center → raw scale 0; the smaller edge is
87
+ // clamped to MIN_RESIZE_LOCAL_PX and the aspect ratio holds at the clamp.
88
+ const out = resolveCenterResizeSize({
89
+ baseWidth: 200,
90
+ baseHeight: 100,
91
+ centerStart: { x: 100, y: 100 },
92
+ pointerStart: { x: 200, y: 100 },
93
+ pointer: { x: 100, y: 100 },
94
+ });
95
+ expect(out.width).toBeGreaterThan(0);
96
+ expect(out.height).toBeGreaterThan(0);
97
+ expect(Math.min(out.width, out.height)).toBeCloseTo(1, 9);
98
+ expect(out.width / out.height).toBeCloseTo(2, 9);
99
+ });
100
+
101
+ it("degenerate start-at-center returns the base size unchanged (scale 1)", () => {
102
+ const out = resolveCenterResizeSize({
103
+ baseWidth: 200,
104
+ baseHeight: 100,
105
+ centerStart: { x: 100, y: 100 },
106
+ pointerStart: { x: 100, y: 100 },
107
+ pointer: { x: 400, y: 400 },
108
+ });
109
+ expect(out).toEqual({ width: 200, height: 100 });
110
+ });
111
+ });
112
+
113
+ describe("resolveRotatedResizeCursor", () => {
114
+ it("returns the static diagonal cursors at rotation 0", () => {
115
+ expect(resolveRotatedResizeCursor("nw", 0)).toBe("nwse-resize");
116
+ expect(resolveRotatedResizeCursor("se", 0)).toBe("nwse-resize");
117
+ expect(resolveRotatedResizeCursor("ne", 0)).toBe("nesw-resize");
118
+ expect(resolveRotatedResizeCursor("sw", 0)).toBe("nesw-resize");
119
+ });
120
+
121
+ it("rotates the cursor with the element (90deg swaps the diagonals)", () => {
122
+ // NW base 315° + 90° = 45° → nesw-resize
123
+ expect(resolveRotatedResizeCursor("nw", 90)).toBe("nesw-resize");
124
+ // NW base 315° + 45° = 360°→0° → ns-resize
125
+ expect(resolveRotatedResizeCursor("nw", 45)).toBe("ns-resize");
126
+ });
127
+
128
+ it("wraps negative rotations", () => {
129
+ expect(resolveRotatedResizeCursor("se", -90)).toBe("nesw-resize");
130
+ });
131
+ });
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Center-anchored corner-resize math (the CapCut model): the element scales
3
+ * proportionally about its CENTER, the center stays planted, and all four corners
4
+ * behave identically.
5
+ *
6
+ * The scale factor is the RADIAL distance from the element's center: how far the
7
+ * pointer is from the center now, divided by how far it was at gesture start. This
8
+ * is inherently proportional, continuous everywhere, and rotation-invariant — a
9
+ * distance is a distance regardless of the element's angle, so there is no per-axis
10
+ * projection and no dominant-axis branch to jump across. Which corner was grabbed
11
+ * is irrelevant to the size (it only picks the resize cursor).
12
+ *
13
+ * All math here is pure and unit-tested; the live wiring (measuring the element's
14
+ * rendered center, feeding the center-pin translate through the manual-offset
15
+ * channel) lives in useDomEditOverlayGestures.ts.
16
+ */
17
+ import type { ResizeHandle } from "./domEditOverlayGestures";
18
+
19
+ /** Minimum element edge in LOCAL px — mirrors the old MIN_RESIZE_EDGE_PX clamp
20
+ * (no flip-through-zero: clamp, never mirror). */
21
+ const MIN_RESIZE_LOCAL_PX = 1;
22
+
23
+ /**
24
+ * Below this pointer-to-center distance (overlay px) the gesture started at (or
25
+ * effectively at) the center, so the ratio is degenerate (division by ~0). Bail to
26
+ * scale 1 rather than blow up.
27
+ */
28
+ const DEGENERATE_START_DIST_PX = 3;
29
+
30
+ /**
31
+ * The proportional scale factor for a center-anchored resize: the ratio of the
32
+ * pointer's radial distance from the element center now to its distance at gesture
33
+ * start. Rotation-invariant (a radial distance ignores the element's angle) and
34
+ * continuous. Never negative — dragging through the center just shrinks toward the
35
+ * clamp; the caller clamps the resulting size, this returns the raw ratio (guarded
36
+ * against a degenerate start-at-center gesture, which returns 1).
37
+ */
38
+ export function resolveCenterResizeScale(input: {
39
+ pointer: { x: number; y: number };
40
+ pointerStart: { x: number; y: number };
41
+ centerStart: { x: number; y: number };
42
+ }): number {
43
+ const startDist = Math.hypot(
44
+ input.pointerStart.x - input.centerStart.x,
45
+ input.pointerStart.y - input.centerStart.y,
46
+ );
47
+ if (!Number.isFinite(startDist) || startDist < DEGENERATE_START_DIST_PX) return 1;
48
+ const nowDist = Math.hypot(
49
+ input.pointer.x - input.centerStart.x,
50
+ input.pointer.y - input.centerStart.y,
51
+ );
52
+ return nowDist / startDist;
53
+ }
54
+
55
+ /**
56
+ * The element's new LOCAL size for a center-anchored corner resize: base size
57
+ * scaled by `resolveCenterResizeScale`, clamped so the smaller edge never drops
58
+ * below MIN_RESIZE_LOCAL_PX (clamp small, never mirror through zero). The scale is
59
+ * a dimensionless ratio, so the base local size and the screen-space pointer
60
+ * distances live in different frames without any display-scale conversion — the
61
+ * ratio cancels the scale.
62
+ */
63
+ export function resolveCenterResizeSize(input: {
64
+ baseWidth: number;
65
+ baseHeight: number;
66
+ pointer: { x: number; y: number };
67
+ pointerStart: { x: number; y: number };
68
+ centerStart: { x: number; y: number };
69
+ }): { width: number; height: number } {
70
+ const baseWidth = Math.max(input.baseWidth, MIN_RESIZE_LOCAL_PX);
71
+ const baseHeight = Math.max(input.baseHeight, MIN_RESIZE_LOCAL_PX);
72
+ const rawScale = resolveCenterResizeScale({
73
+ pointer: input.pointer,
74
+ pointerStart: input.pointerStart,
75
+ centerStart: input.centerStart,
76
+ });
77
+ const minScale = MIN_RESIZE_LOCAL_PX / Math.min(baseWidth, baseHeight);
78
+ const scale = Math.max(minScale, rawScale);
79
+ return { width: baseWidth * scale, height: baseHeight * scale };
80
+ }
81
+
82
+ /**
83
+ * The eight CSS resize cursors, rotated with the object. A corner's base pointing
84
+ * direction (the diagonal it lives on) plus the element rotation, bucketed into
85
+ * 45° slots. So a 90°-rotated NW corner reads as a NE-diagonal cursor, etc.
86
+ */
87
+ const CURSORS_8 = [
88
+ "ns-resize", // 0° (up)
89
+ "nesw-resize", // 45°
90
+ "ew-resize", // 90° (right)
91
+ "nwse-resize", // 135°
92
+ "ns-resize", // 180° (down)
93
+ "nesw-resize", // 225°
94
+ "ew-resize", // 270° (left)
95
+ "nwse-resize", // 315°
96
+ ] as const;
97
+
98
+ /** Base outward diagonal angle of each corner, in degrees, screen convention
99
+ * (0° = up, clockwise). NW points up-left = 315°, NE up-right = 45°, etc. */
100
+ const CORNER_BASE_ANGLE_DEG: Record<ResizeHandle, number> = {
101
+ nw: 315,
102
+ ne: 45,
103
+ se: 135,
104
+ sw: 225,
105
+ };
106
+
107
+ /** Resize cursor for a corner handle on an element rotated by `rotationDeg`. */
108
+ export function resolveRotatedResizeCursor(handle: ResizeHandle, rotationDeg: number): string {
109
+ const angle = CORNER_BASE_ANGLE_DEG[handle] + rotationDeg;
110
+ const normalized = ((angle % 360) + 360) % 360;
111
+ const bucket = Math.round(normalized / 45) % 8;
112
+ return CURSORS_8[bucket]!;
113
+ }
114
+
115
+ /** Per-frame anchored-resize center accumulator: ADD the residual center correction
116
+ * (fixedStart − fixedNow) onto the previous anchor so the pin CONVERGES instead of
117
+ * oscillating (fa4f39168). Pure; exported for the release-shift characterization tests. */
118
+ export function computeNextResizeAnchor(
119
+ prev: { dx: number; dy: number } | undefined,
120
+ fixedStart: { x: number; y: number },
121
+ fixedNow: { x: number; y: number },
122
+ ): { dx: number; dy: number } {
123
+ const base = prev ?? { dx: 0, dy: 0 };
124
+ return { dx: base.dx + (fixedStart.x - fixedNow.x), dy: base.dy + (fixedStart.y - fixedNow.y) };
125
+ }
@@ -104,7 +104,7 @@ export function findClosestByAttribute(
104
104
  // mounted root (it keeps `data-composition-id` but drops `data-composition-src`/
105
105
  // `-file`), so a subcomp element's DOM ancestors no longer say which file it came
106
106
  // from. This project-global map (composition-id → source file, built once from
107
- // index.html's clips — see NLELayout) recovers it. The studio loads one project at a
107
+ // index.html's clips — see NLEContext/EditorShell) recovers it. The studio loads one project at a
108
108
  // time, so module scope is the right lifetime; it's empty until set, in which case
109
109
  // resolution falls back to the historical attribute-only behavior.
110
110
  let compositionSourceMap: Map<string, string> = new Map();
@@ -49,6 +49,7 @@ import {
49
49
  buildMotionPatches,
50
50
  buildClearMotionPatches,
51
51
  } from "./manualEditsDomPatches";
52
+ import { applyStudioBoxSize, applyStudioPathOffset } from "./manualEditsDom";
52
53
 
53
54
  /* ── helpers ── */
54
55
 
@@ -267,6 +268,52 @@ describe("buildBoxSizePatches / buildClearBoxSizePatches", () => {
267
268
  });
268
269
  });
269
270
 
271
+ /* ── Combined box-size + path-offset (anchored-corner resize) ──────────────── */
272
+
273
+ describe("anchored-corner combined patch: [...buildBoxSizePatches, ...buildPathOffsetPatches]", () => {
274
+ // NW/NE/SW resize commits size AND anchor offset in ONE persist. The two
275
+ // builders read the same already-mutated element and are concatenated; this
276
+ // is only safe if their {type,property} keys are disjoint (no builder
277
+ // overwrites the other's op when the source patcher applies them in order).
278
+ it("concatenation of both builders emits disjoint {type,property} keys (no collision)", () => {
279
+ const e = div();
280
+ applyStudioBoxSize(e, { width: 300, height: 200 });
281
+ applyStudioPathOffset(e, { x: 10, y: 20 });
282
+
283
+ const combined = [...buildBoxSizePatches(e), ...buildPathOffsetPatches(e)];
284
+ const keys = combined.map(opKey);
285
+ expect(new Set(keys).size, `duplicate {type,property} key in combined patch: ${keys}`).toBe(
286
+ keys.length,
287
+ );
288
+ });
289
+
290
+ it("combined patch carries BOTH markers so a soft-reload re-hydrates size and offset together", () => {
291
+ const e = div();
292
+ applyStudioBoxSize(e, { width: 300, height: 200 });
293
+ applyStudioPathOffset(e, { x: 10, y: 20 });
294
+
295
+ const combined = [...buildBoxSizePatches(e), ...buildPathOffsetPatches(e)];
296
+ const has = (property: string) =>
297
+ combined.some((op) => op.type === "attribute" && op.property === property);
298
+ expect(has(STUDIO_BOX_SIZE_ATTR)).toBe(true);
299
+ expect(has(STUDIO_PATH_OFFSET_ATTR)).toBe(true);
300
+ });
301
+
302
+ it("order is size-first: every box-size op precedes every path-offset op", () => {
303
+ const e = div();
304
+ applyStudioBoxSize(e, { width: 300, height: 200 });
305
+ applyStudioPathOffset(e, { x: 10, y: 20 });
306
+
307
+ const boxKeys = new Set(buildBoxSizePatches(e).map(opKey));
308
+ const combined = [...buildBoxSizePatches(e), ...buildPathOffsetPatches(e)];
309
+ const lastBoxIdx = combined.reduce((acc, op, i) => (boxKeys.has(opKey(op)) ? i : acc), -1);
310
+ const firstOffsetIdx = combined.findIndex(
311
+ (op) => op.type === "attribute" && op.property === STUDIO_PATH_OFFSET_ATTR,
312
+ );
313
+ expect(firstOffsetIdx).toBeGreaterThan(lastBoxIdx);
314
+ });
315
+ });
316
+
270
317
  /* ── Rotation ────────────────────────────────────────────────────────────── */
271
318
 
272
319
  describe("buildRotationPatches / buildClearRotationPatches", () => {
@@ -441,22 +441,26 @@ export function applyManualOffsetDragDraft(
441
441
  return offset;
442
442
  }
443
443
 
444
- export function applyManualOffsetDragCommit(
445
- member: ManualOffsetDragMember,
446
- dx: number,
447
- dy: number,
448
- ): { x: number; y: number } {
449
- // Re-stamp the STABLE gesture-start base/offset before the source commit reads
450
- // them. A mid-drag re-render can wipe these attrs; the commit converts the drop
451
- // offset → gsap x/y via computeDraggedGsapPosition, which without the base falls
452
- // back to the live (already-dragged) transform and re-adds the delta — so the
453
- // element flies off-screen the instant you drop it. The member holds the true
454
- // gesture-start values in JS, immune to the re-render.
444
+ /**
445
+ * Re-stamp the STABLE gesture-start base/offset before the source commit reads
446
+ * them. A mid-gesture re-render can wipe these attrs; the commit converts the
447
+ * drop offset → gsap x/y via computeDraggedGsapPosition, which without the base
448
+ * falls back to the live (already-dragged) transform and re-adds the delta so
449
+ * the element flies off-screen the instant you drop it. The member holds the
450
+ * true gesture-start values in JS, immune to the re-render.
451
+ */
452
+ function restampManualOffsetDragGestureBase(member: ManualOffsetDragMember): void {
455
453
  member.element.setAttribute("data-hf-drag-gsap-base-x", String(member.baseGsap.x));
456
454
  member.element.setAttribute("data-hf-drag-gsap-base-y", String(member.baseGsap.y));
457
455
  member.element.setAttribute("data-hf-drag-initial-offset-x", String(member.initialOffset.x));
458
456
  member.element.setAttribute("data-hf-drag-initial-offset-y", String(member.initialOffset.y));
459
- const offset = resolveManualOffsetDragMemberOffset(member, dx, dy);
457
+ }
458
+
459
+ function applyManualOffsetCommitValue(
460
+ member: ManualOffsetDragMember,
461
+ offset: { x: number; y: number },
462
+ ): { x: number; y: number } {
463
+ restampManualOffsetDragGestureBase(member);
460
464
  // Optimistic visual through the GSAP channel (same as the live draft and the
461
465
  // committed `tl.set`), so the element holds its dropped position until the
462
466
  // source mutation soft-reloads — no transient CSS `--hf-studio-offset` write.
@@ -467,6 +471,45 @@ export function applyManualOffsetDragCommit(
467
471
  return offset;
468
472
  }
469
473
 
474
+ export function applyManualOffsetDragCommit(
475
+ member: ManualOffsetDragMember,
476
+ dx: number,
477
+ dy: number,
478
+ ): { x: number; y: number } {
479
+ return applyManualOffsetCommitValue(member, resolveManualOffsetDragMemberOffset(member, dx, dy));
480
+ }
481
+
482
+ /**
483
+ * Arrow-key nudge, in OFFSET units (composition px), not screen px — "nudge
484
+ * 1px" means one composition pixel regardless of canvas zoom, so the delta
485
+ * adds to the gesture-start offset directly instead of going through the
486
+ * screen→offset matrix. Draft/commit land in the same GSAP channel (with the
487
+ * same CSS fallback) as the drag equivalents above.
488
+ */
489
+ export function applyManualOffsetNudgeDraft(
490
+ member: ManualOffsetDragMember,
491
+ delta: { x: number; y: number },
492
+ ): { x: number; y: number } {
493
+ const offset = {
494
+ x: member.initialOffset.x + delta.x,
495
+ y: member.initialOffset.y + delta.y,
496
+ };
497
+ if (!applyOffsetDragDraftViaGsap(member.element, offset, member.baseGsap)) {
498
+ applyStudioPathOffsetDraft(member.element, offset);
499
+ }
500
+ return offset;
501
+ }
502
+
503
+ export function applyManualOffsetNudgeCommit(
504
+ member: ManualOffsetDragMember,
505
+ delta: { x: number; y: number },
506
+ ): { x: number; y: number } {
507
+ return applyManualOffsetCommitValue(member, {
508
+ x: member.initialOffset.x + delta.x,
509
+ y: member.initialOffset.y + delta.y,
510
+ });
511
+ }
512
+
470
513
  function restoreManualOffsetDragMember(member: ManualOffsetDragMember): void {
471
514
  restoreStudioPathOffset(member.element, member.initialPathOffset);
472
515
  endStudioManualEditGesture(member.element, member.gestureToken);
@@ -1,7 +1,7 @@
1
1
  import type React from "react";
2
2
  import type { OffCanvasRect } from "./OffCanvasIndicators";
3
3
  import { hugRectForElement } from "./domEditOverlayCrop";
4
- import { groupAwareOverlayRect } from "./domEditOverlayGeometry";
4
+ import { orientedGroupAwareOverlayRect } from "./domEditOverlayGeometry";
5
5
  import { isElementComputedVisible } from "./domEditingElement";
6
6
  import { collectDomEditLayerItems } from "./domEditingLayers";
7
7
 
@@ -13,11 +13,30 @@ function offCanvasSignature(rects: OffCanvasRect[]): string {
13
13
  return rects
14
14
  .map(
15
15
  (rect) =>
16
- `${rect.key}:${rounded(rect.left)},${rounded(rect.top)},${rounded(rect.width)},${rounded(rect.height)}`,
16
+ `${rect.key}:${rounded(rect.left)},${rounded(rect.top)},${rounded(rect.width)},${rounded(rect.height)},${rounded(rect.angle ?? 0)}`,
17
17
  )
18
18
  .join("|");
19
19
  }
20
20
 
21
+ function extendsOutside(
22
+ rect: Omit<OffCanvasRect, "key">,
23
+ comp: { left: number; top: number; width: number; height: number },
24
+ ): boolean {
25
+ const radians = ((rect.angle ?? 0) * Math.PI) / 180;
26
+ const halfWidth =
27
+ (Math.abs(Math.cos(radians)) * rect.width + Math.abs(Math.sin(radians)) * rect.height) / 2;
28
+ const halfHeight =
29
+ (Math.abs(Math.sin(radians)) * rect.width + Math.abs(Math.cos(radians)) * rect.height) / 2;
30
+ const centerX = rect.left + rect.width / 2;
31
+ const centerY = rect.top + rect.height / 2;
32
+ return (
33
+ centerX - halfWidth < comp.left ||
34
+ centerX + halfWidth > comp.left + comp.width ||
35
+ centerY - halfHeight < comp.top ||
36
+ centerY + halfHeight > comp.top + comp.height
37
+ );
38
+ }
39
+
21
40
  // fallow-ignore-next-line complexity
22
41
  export function recomputeOffCanvasIndicators(
23
42
  iframe: HTMLIFrameElement,
@@ -50,18 +69,20 @@ export function recomputeOffCanvasIndicators(
50
69
  // whose members sit inside the canvas isn't flagged off-canvas by a stale
51
70
  // wrapper box. Crop-hug the result so an inset crop that keeps the visible
52
71
  // part on-canvas doesn't flag the element either.
53
- const base = groupAwareOverlayRect(overlay, iframe, item.element);
72
+ const base = orientedGroupAwareOverlayRect(overlay, iframe, item.element);
54
73
  const r = base ? { ...base, ...hugRectForElement(base, item.element) } : null;
55
74
  if (!r) continue;
56
75
  // Any edge crossing the composition border → gray-zone indicator (the
57
76
  // in-canvas portion is clipped away below, so only the sliver shows).
58
- const extendsOutsideComp =
59
- r.left < comp.left ||
60
- r.left + r.width > comp.left + comp.width ||
61
- r.top < comp.top ||
62
- r.top + r.height > comp.top + comp.height;
63
- if (extendsOutsideComp) {
64
- rects.push({ key: item.key, left: r.left, top: r.top, width: r.width, height: r.height });
77
+ if (extendsOutside(r, comp)) {
78
+ rects.push({
79
+ key: item.key,
80
+ left: r.left,
81
+ top: r.top,
82
+ width: r.width,
83
+ height: r.height,
84
+ angle: r.angle,
85
+ });
65
86
  elMap.set(item.key, item.element);
66
87
  }
67
88
  }
@@ -2,11 +2,35 @@
2
2
 
3
3
  import React, { act } from "react";
4
4
  import { createRoot, type Root } from "react-dom/client";
5
- import { describe, expect, it } from "vitest";
5
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
6
6
  import { DomEditOverlay } from "./DomEditOverlay";
7
7
 
8
8
  Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
9
9
 
10
+ // happy-dom (20.x) holds each MutationObserver's delivery callback ONLY via a
11
+ // WeakRef (MutationObserverListener: `callback: new WeakRef(...)` — the arrow
12
+ // has no strong referent). If V8 runs a GC between observe() and a mutation,
13
+ // deref() returns undefined and mutation delivery silently stops — the
14
+ // indicator-refresh loop never sees its dirty flag and these tests flake under
15
+ // full-suite memory pressure (passing in isolation). Pin WeakRef to a strong
16
+ // ref for this file so the real observer path stays deterministic.
17
+ const RealWeakRef = globalThis.WeakRef;
18
+ class StrongRef<T extends WeakKey> {
19
+ #value: T;
20
+ constructor(value: T) {
21
+ this.#value = value;
22
+ }
23
+ deref(): T {
24
+ return this.#value;
25
+ }
26
+ }
27
+ beforeAll(() => {
28
+ (globalThis as { WeakRef: unknown }).WeakRef = StrongRef;
29
+ });
30
+ afterAll(() => {
31
+ globalThis.WeakRef = RealWeakRef;
32
+ });
33
+
10
34
  const INDICATOR = '[aria-label="Select off-canvas element index.html:headline:0"]';
11
35
 
12
36
  function domRect(left: number, top: number, width: number, height: number): DOMRect {
@@ -0,0 +1,108 @@
1
+ import type { GestureState } from "./domEditOverlayGestures";
2
+ import { resolveResizeCenterAnchorOffset } from "./domEditOverlayGestures";
3
+ import type { OverlayRect } from "./domEditOverlayGeometry";
4
+ import {
5
+ cornerEdgeLength,
6
+ elementCornerOverlayPoints,
7
+ overlayCornersCentroid,
8
+ } from "./domEditOverlayGeometry";
9
+ import { computeNextResizeAnchor } from "./domEditResizeLocal";
10
+ import { applyManualOffsetDragDraft } from "./manualOffsetDrag";
11
+
12
+ type Corners = ReturnType<typeof elementCornerOverlayPoints>;
13
+
14
+ /**
15
+ * The residual center-pin offset for this frame. With measurable corners and a
16
+ * fixed-center start, accumulate `fixedStart - centerNow` onto the previous
17
+ * anchor so it CONVERGES rather than oscillating: `applyManualOffsetDragDraft`
18
+ * treats its argument as the absolute offset, and `centerNow` (measured on the
19
+ * live element) already carries the previous frame's offset, so the difference
20
+ * is only the residual correction. Using it absolutely would drop the offset
21
+ * every other frame and un-pin the center (fa4f39168). Memberless/unmeasurable
22
+ * geometry falls back to the AABB half-delta.
23
+ */
24
+ function resolveResizeAnchor(
25
+ g: GestureState,
26
+ corners: Corners | null,
27
+ measureOrientedRect: () => OverlayRect | null,
28
+ ): { dx: number; dy: number } {
29
+ const fixedStart = g.resizeFixedCenterStart;
30
+ if (corners && fixedStart) {
31
+ return computeNextResizeAnchor(g.lastResizeAnchor, fixedStart, overlayCornersCentroid(corners));
32
+ }
33
+ const fallbackRect = measureOrientedRect();
34
+ return resolveResizeCenterAnchorOffset({
35
+ originWidth: g.originWidth,
36
+ originHeight: g.originHeight,
37
+ overlayWidth: fallbackRect ? fallbackRect.width : g.originWidth,
38
+ overlayHeight: fallbackRect ? fallbackRect.height : g.originHeight,
39
+ });
40
+ }
41
+
42
+ /**
43
+ * Center-pinned draft rect: translate the element through the manual-offset
44
+ * channel to keep its gesture-start center planted (rotation-safe for any
45
+ * transform-origin), then hug its true rendered bounds. Mutates
46
+ * `g.lastResizeAnchor` and applies the offset draft as a side effect.
47
+ */
48
+ function resolveAnchoredResizeDraft(
49
+ g: GestureState,
50
+ member: NonNullable<GestureState["pathOffsetMember"]>,
51
+ element: HTMLElement,
52
+ overlayEl: HTMLDivElement | null,
53
+ iframe: HTMLIFrameElement | null,
54
+ measureOrientedRect: () => OverlayRect | null,
55
+ ): OverlayRect {
56
+ // Measure real corners ONCE — reused for the anchor and the fallback size.
57
+ const corners =
58
+ overlayEl && iframe ? elementCornerOverlayPoints(overlayEl, iframe, element) : null;
59
+ const anchor = resolveResizeAnchor(g, corners, measureOrientedRect);
60
+ g.lastResizeAnchor = anchor;
61
+ applyManualOffsetDragDraft(member, anchor.dx, anchor.dy);
62
+ // Re-measure AFTER the anchor translate so it hugs the element every frame.
63
+ return (
64
+ measureOrientedRect() ?? {
65
+ left: g.originLeft + anchor.dx,
66
+ top: g.originTop + anchor.dy,
67
+ width: corners ? cornerEdgeLength(corners.nw, corners.ne) : g.originWidth,
68
+ height: corners ? cornerEdgeLength(corners.nw, corners.sw) : g.originHeight,
69
+ editScaleX: g.editScaleX,
70
+ editScaleY: g.editScaleY,
71
+ angle: g.actualRotation,
72
+ }
73
+ );
74
+ }
75
+
76
+ /** The overlay rect to paint for the current resize pointer-move frame. */
77
+ export function resolveResizeDraftRect(
78
+ g: GestureState,
79
+ element: HTMLElement,
80
+ overlayEl: HTMLDivElement | null,
81
+ iframe: HTMLIFrameElement | null,
82
+ measureOrientedRect: () => OverlayRect | null,
83
+ ): OverlayRect {
84
+ if (g.pathOffsetMember) {
85
+ return resolveAnchoredResizeDraft(
86
+ g,
87
+ g.pathOffsetMember,
88
+ element,
89
+ overlayEl,
90
+ iframe,
91
+ measureOrientedRect,
92
+ );
93
+ }
94
+ // Re-measure the element's oriented box AFTER the size write. The size draft
95
+ // rounds/clamps and (with a centered transform-origin + GSAP scale) the real
96
+ // rendered size diverges from the CSS size, so measure rather than trust math.
97
+ return (
98
+ measureOrientedRect() ?? {
99
+ left: g.originLeft,
100
+ top: g.originTop,
101
+ width: g.originWidth,
102
+ height: g.originHeight,
103
+ editScaleX: g.editScaleX,
104
+ editScaleY: g.editScaleY,
105
+ angle: g.actualRotation,
106
+ }
107
+ );
108
+ }