@hyperframes/studio 0.7.55 → 0.7.57

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 (274) hide show
  1. package/dist/assets/hyperframes-player--Z69cEkE.js +459 -0
  2. package/dist/assets/{index-CPetwHFV.js → index-Bf1x1y8H.js} +1 -1
  3. package/dist/assets/index-DfmYkU44.js +423 -0
  4. package/dist/assets/{index-BXaqaVKt.js → index-KsfE1bUu.js} +1 -1
  5. package/dist/assets/index-_pqzyxB1.css +1 -0
  6. package/dist/{chunk-SOTCF4DF.js → chunk-5QSIMBEJ.js} +2 -1
  7. package/dist/chunk-5QSIMBEJ.js.map +1 -0
  8. package/dist/{domEditingLayers-2ECJK24D.js → domEditingLayers-6LQGKPOI.js} +2 -2
  9. package/dist/index.d.ts +172 -144
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +43613 -40346
  12. package/dist/index.js.map +1 -1
  13. package/package.json +7 -7
  14. package/src/App.tsx +106 -96
  15. package/src/components/EditorShell.tsx +253 -0
  16. package/src/components/StudioGlobalDragOverlay.tsx +1 -3
  17. package/src/components/StudioLeftSidebar.tsx +13 -3
  18. package/src/components/StudioRightPanel.tsx +8 -3
  19. package/src/components/TimelineToolbar.test.tsx +2 -2
  20. package/src/components/TimelineToolbar.tsx +179 -121
  21. package/src/components/editor/CanvasContextMenu.test.tsx +152 -1
  22. package/src/components/editor/CanvasContextMenu.tsx +33 -18
  23. package/src/components/editor/DomEditCropHandles.test.tsx +74 -3
  24. package/src/components/editor/DomEditCropHandles.tsx +64 -29
  25. package/src/components/editor/DomEditOverlay.test.ts +245 -279
  26. package/src/components/editor/DomEditOverlay.tsx +126 -125
  27. package/src/components/editor/DomEditOverlayHover.test.tsx +90 -0
  28. package/src/components/editor/DomEditRotateHandle.tsx +38 -18
  29. package/src/components/editor/DomEditSelectionChrome.test.tsx +80 -0
  30. package/src/components/editor/DomEditSelectionChrome.tsx +257 -0
  31. package/src/components/editor/LayersPanel.test.ts +66 -2
  32. package/src/components/editor/LayersPanel.tsx +53 -2
  33. package/src/components/editor/OffCanvasIndicators.test.tsx +34 -0
  34. package/src/components/editor/OffCanvasIndicators.tsx +49 -10
  35. package/src/components/editor/SnapGuideOverlay.tsx +28 -23
  36. package/src/components/editor/SnapToolbar.test.tsx +0 -1
  37. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +214 -0
  38. package/src/components/editor/anchoredResizeReleaseShift.test.ts +106 -10
  39. package/src/components/editor/canvasContextMenuZOrder.test.ts +186 -2
  40. package/src/components/editor/canvasContextMenuZOrder.ts +144 -22
  41. package/src/components/editor/domEditNudge.test.ts +80 -0
  42. package/src/components/editor/domEditNudge.ts +44 -0
  43. package/src/components/editor/domEditOverlayGeometry.test.ts +167 -1
  44. package/src/components/editor/domEditOverlayGeometry.ts +280 -20
  45. package/src/components/editor/domEditOverlayGestures.ts +44 -41
  46. package/src/components/editor/domEditOverlayStartGesture.ts +62 -2
  47. package/src/components/editor/domEditResizeLocal.test.ts +131 -0
  48. package/src/components/editor/domEditResizeLocal.ts +125 -0
  49. package/src/components/editor/domEditingDom.ts +1 -1
  50. package/src/components/editor/manualEditsDomPatches.test.ts +47 -0
  51. package/src/components/editor/manualOffsetDrag.ts +55 -12
  52. package/src/components/editor/offCanvasIndicatorGeometry.ts +31 -10
  53. package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +25 -1
  54. package/src/components/editor/propertyPanelColorGradingSection.tsx +15 -21
  55. package/src/components/editor/resizeDraft.ts +108 -0
  56. package/src/components/editor/snapEngine.test.ts +12 -78
  57. package/src/components/editor/snapEngine.ts +13 -53
  58. package/src/components/editor/useCanvasContextMenuState.ts +92 -0
  59. package/src/components/editor/useDomEditNudge.test.tsx +228 -0
  60. package/src/components/editor/useDomEditNudge.ts +254 -0
  61. package/src/components/editor/useDomEditOverlayGestures.ts +115 -116
  62. package/src/components/editor/useDomEditOverlayRects.ts +9 -2
  63. package/src/components/editor/useZOrderCrossedFlash.tsx +68 -0
  64. package/src/components/nle/AssetPreviewOverlay.test.tsx +106 -0
  65. package/src/components/nle/AssetPreviewOverlay.tsx +171 -0
  66. package/src/components/nle/NLEContext.test.ts +144 -0
  67. package/src/components/nle/NLEContext.tsx +328 -0
  68. package/src/components/nle/NLEPreview.tsx +1 -1
  69. package/src/components/nle/PreviewOverlays.tsx +260 -0
  70. package/src/components/nle/PreviewPane.tsx +163 -0
  71. package/src/components/nle/TimelinePane.test.ts +60 -0
  72. package/src/components/nle/TimelinePane.tsx +293 -0
  73. package/src/components/nle/TimelineResizeDivider.tsx +8 -2
  74. package/src/components/nle/useCompositionStack.test.tsx +44 -0
  75. package/src/components/nle/useCompositionStack.ts +17 -9
  76. package/src/components/nle/useTimelineEditCallbacks.ts +217 -0
  77. package/src/components/sidebar/AssetCard.test.tsx +106 -0
  78. package/src/components/sidebar/AssetCard.tsx +343 -0
  79. package/src/components/sidebar/AssetContextMenu.tsx +15 -30
  80. package/src/components/sidebar/AssetsTab.test.ts +96 -0
  81. package/src/components/sidebar/AssetsTab.tsx +71 -206
  82. package/src/components/sidebar/AudioRow.tsx +56 -2
  83. package/src/components/sidebar/BlocksTab.tsx +8 -1
  84. package/src/components/sidebar/LeftSidebar.tsx +4 -1
  85. package/src/components/sidebar/assetHelpers.ts +29 -0
  86. package/src/contexts/DomEditContext.tsx +9 -0
  87. package/src/contexts/StudioContext.tsx +9 -8
  88. package/src/contexts/TimelineEditContext.tsx +3 -7
  89. package/src/hooks/deleteSelectedKeyframes.ts +35 -0
  90. package/src/hooks/domEditCommitTypes.ts +23 -1
  91. package/src/hooks/domSelectionTestHarness.ts +14 -0
  92. package/src/hooks/gestureTransaction.test.ts +311 -0
  93. package/src/hooks/gestureTransaction.ts +199 -0
  94. package/src/hooks/gsapDragCommit.test.ts +141 -41
  95. package/src/hooks/gsapDragCommit.ts +64 -74
  96. package/src/hooks/gsapDragStaticSetHelpers.ts +9 -21
  97. package/src/hooks/gsapResizeIntercept.test.ts +69 -0
  98. package/src/hooks/gsapResizeIntercept.ts +38 -10
  99. package/src/hooks/gsapRuntimeBridge.test.ts +49 -13
  100. package/src/hooks/gsapRuntimeBridge.ts +4 -4
  101. package/src/hooks/gsapScriptCommitTypes.ts +16 -5
  102. package/src/hooks/gsapShared.test.ts +19 -1
  103. package/src/hooks/gsapShared.ts +14 -0
  104. package/src/hooks/patchDocumentRootDuration.test.ts +71 -0
  105. package/src/hooks/timelineEditingGsap.ts +14 -0
  106. package/src/hooks/timelineEditingHelpers.test.ts +125 -1
  107. package/src/hooks/timelineEditingHelpers.ts +53 -292
  108. package/src/hooks/timelineMoveAdapter.test.ts +81 -0
  109. package/src/hooks/timelineMoveAdapter.ts +39 -0
  110. package/src/hooks/timelineTimingSync.test.ts +150 -0
  111. package/src/hooks/timelineTimingSync.ts +412 -0
  112. package/src/hooks/useAnimatedPropertyCommit.test.tsx +36 -8
  113. package/src/hooks/useAnimatedPropertyCommit.ts +31 -26
  114. package/src/hooks/useAppHotkeys.ts +16 -25
  115. package/src/hooks/useBlockHandlers.ts +55 -27
  116. package/src/hooks/useCaptionDetection.ts +2 -0
  117. package/src/hooks/useCompositionDimensions.ts +32 -5
  118. package/src/hooks/useContextMenuDismiss.ts +31 -7
  119. package/src/hooks/useDomEditCommits.test.tsx +245 -1
  120. package/src/hooks/useDomEditCommits.ts +162 -3
  121. package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
  122. package/src/hooks/useDomEditSession.ts +2 -2
  123. package/src/hooks/useDomEditTextCommits.test.tsx +135 -0
  124. package/src/hooks/useDomEditTextCommits.ts +8 -3
  125. package/src/hooks/useDomEditWiring.ts +7 -9
  126. package/src/hooks/useDomGeometryCommits.ts +17 -2
  127. package/src/hooks/useDomSelection.test.ts +2 -36
  128. package/src/hooks/useDomSelection.ts +81 -51
  129. package/src/hooks/useDomSelectionSelectionGuards.test.ts +276 -0
  130. package/src/hooks/useElementLifecycleOps.test.tsx +423 -0
  131. package/src/hooks/useElementLifecycleOps.ts +53 -71
  132. package/src/hooks/useElementPicker.ts +7 -8
  133. package/src/hooks/useEnableKeyframes.test.ts +142 -1
  134. package/src/hooks/useEnableKeyframes.ts +73 -29
  135. package/src/hooks/useGestureCommit.test.tsx +130 -0
  136. package/src/hooks/useGestureCommit.ts +32 -8
  137. package/src/hooks/useGroupCommits.ts +7 -28
  138. package/src/hooks/useGsapAwareEditing.test.tsx +239 -0
  139. package/src/hooks/useGsapAwareEditing.ts +115 -20
  140. package/src/hooks/useGsapKeyframeOps.test.tsx +59 -0
  141. package/src/hooks/useGsapKeyframeOps.ts +38 -10
  142. package/src/hooks/useGsapScriptCommits.test.tsx +40 -1
  143. package/src/hooks/useGsapScriptCommits.ts +134 -50
  144. package/src/hooks/useGsapSelectionHandlers.ts +49 -16
  145. package/src/hooks/useMusicBeatAnalysis.ts +72 -36
  146. package/src/hooks/usePersistentEditHistory.test.ts +33 -0
  147. package/src/hooks/usePersistentEditHistory.ts +92 -58
  148. package/src/hooks/usePreviewPersistence.ts +25 -7
  149. package/src/hooks/useRazorSplit.history.test.tsx +148 -18
  150. package/src/hooks/useRazorSplit.test.ts +269 -0
  151. package/src/hooks/useRazorSplit.testHelpers.ts +64 -0
  152. package/src/hooks/useRazorSplit.ts +107 -36
  153. package/src/hooks/useRenderClipContent.ts +24 -6
  154. package/src/hooks/useStudioContextValue.ts +15 -5
  155. package/src/hooks/useStudioUrlState.ts +2 -4
  156. package/src/hooks/useTimelineAssetDropOps.ts +175 -0
  157. package/src/hooks/useTimelineEditing.test.tsx +398 -241
  158. package/src/hooks/useTimelineEditing.ts +181 -265
  159. package/src/hooks/useTimelineEditingTypes.ts +6 -0
  160. package/src/hooks/useTimelineGroupEditing.ts +167 -132
  161. package/src/index.ts +1 -1
  162. package/src/player/components/ImageThumbnail.test.tsx +173 -0
  163. package/src/player/components/ImageThumbnail.tsx +160 -0
  164. package/src/player/components/PlayerControls.tsx +5 -5
  165. package/src/player/components/PlayheadIndicator.tsx +55 -5
  166. package/src/player/components/Timeline.test.ts +183 -23
  167. package/src/player/components/Timeline.tsx +194 -194
  168. package/src/player/components/TimelineCanvas.tsx +218 -523
  169. package/src/player/components/TimelineClip.tsx +4 -1
  170. package/src/player/components/TimelineClipDiamonds.tsx +9 -40
  171. package/src/player/components/TimelineEmptyState.tsx +3 -1
  172. package/src/player/components/TimelineLanes.tsx +487 -0
  173. package/src/player/components/TimelineOverlays.tsx +122 -0
  174. package/src/player/components/TimelineRuler.tsx +58 -48
  175. package/src/player/components/VideoThumbnail.test.tsx +152 -0
  176. package/src/player/components/VideoThumbnail.tsx +28 -7
  177. package/src/player/components/thumbnailUtils.test.ts +127 -0
  178. package/src/player/components/thumbnailUtils.ts +54 -0
  179. package/src/player/components/timelineCallbacks.ts +18 -18
  180. package/src/player/components/timelineClipChildren.tsx +39 -0
  181. package/src/player/components/timelineClipDragCommit.test.ts +1026 -0
  182. package/src/player/components/timelineClipDragCommit.ts +498 -0
  183. package/src/player/components/timelineClipDragPreview.test.ts +144 -0
  184. package/src/player/components/timelineClipDragPreview.ts +323 -38
  185. package/src/player/components/timelineClipDragTypes.ts +65 -0
  186. package/src/player/components/timelineCollision.test.ts +42 -2
  187. package/src/player/components/timelineDragDrop.ts +69 -60
  188. package/src/player/components/timelineEditCapabilities.ts +52 -0
  189. package/src/player/components/timelineEditing.test.ts +1 -1
  190. package/src/player/components/timelineEditing.ts +76 -59
  191. package/src/player/components/timelineGroupEditing.test.ts +126 -0
  192. package/src/player/components/timelineGroupEditing.ts +133 -0
  193. package/src/player/components/timelineGroupResizeCommit.ts +54 -0
  194. package/src/player/components/timelineLayout.test.ts +95 -23
  195. package/src/player/components/timelineLayout.ts +207 -75
  196. package/src/player/components/timelineMarquee.test.ts +197 -0
  197. package/src/player/components/timelineMarquee.ts +112 -0
  198. package/src/player/components/timelineOptimisticRevision.ts +42 -0
  199. package/src/player/components/timelineRevealScroll.test.ts +133 -0
  200. package/src/player/components/timelineRevealScroll.ts +91 -0
  201. package/src/player/components/timelineStackingSync.test.ts +60 -0
  202. package/src/player/components/timelineStackingSync.ts +27 -2
  203. package/src/player/components/timelineTheme.ts +6 -1
  204. package/src/player/components/timelineTrackPersistPipeline.test.ts +168 -0
  205. package/src/player/components/timelineZones.test.ts +122 -310
  206. package/src/player/components/timelineZones.ts +82 -135
  207. package/src/player/components/useResolvedTimelineEditCallbacks.ts +25 -3
  208. package/src/player/components/useTimelineClipDrag.resize.test.tsx +241 -0
  209. package/src/player/components/useTimelineClipDrag.ts +380 -386
  210. package/src/player/components/useTimelineEditPinning.ts +121 -0
  211. package/src/player/components/useTimelineGeometry.ts +129 -0
  212. package/src/player/components/useTimelinePlayhead.ts +4 -16
  213. package/src/player/components/useTimelineRangeSelection.ts +341 -50
  214. package/src/player/components/useTimelineRevealClip.ts +56 -0
  215. package/src/player/components/useTimelineStackingSync.test.tsx +77 -0
  216. package/src/player/components/useTimelineStackingSync.ts +94 -0
  217. package/src/player/hooks/useExpandedTimelineElements.ts +7 -3
  218. package/src/player/hooks/usePlaybackKeyboard.ts +5 -0
  219. package/src/player/hooks/useTimelinePlayer.seek.test.ts +55 -0
  220. package/src/player/hooks/useTimelinePlayer.test.ts +36 -3
  221. package/src/player/hooks/useTimelinePlayer.ts +52 -53
  222. package/src/player/hooks/useTimelinePlayerLoop.ts +85 -0
  223. package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -1
  224. package/src/player/index.ts +5 -2
  225. package/src/player/lib/runtimeProtocol.test.ts +48 -0
  226. package/src/player/lib/runtimeProtocol.ts +65 -0
  227. package/src/player/lib/time.test.ts +19 -19
  228. package/src/player/lib/time.ts +6 -2
  229. package/src/player/lib/timelineDOM.test.ts +25 -98
  230. package/src/player/lib/timelineDOM.ts +21 -81
  231. package/src/player/lib/timelineElementHelpers.ts +73 -5
  232. package/src/player/lib/timelineIframeHelpers.ts +4 -4
  233. package/src/player/store/playerStore.test.ts +38 -13
  234. package/src/player/store/playerStore.ts +99 -3
  235. package/src/styles/studio.css +27 -0
  236. package/src/utils/assetPreviewDismiss.test.ts +27 -0
  237. package/src/utils/assetPreviewDismiss.ts +29 -0
  238. package/src/utils/assetPreviewStore.ts +33 -0
  239. package/src/utils/blockInstaller.ts +10 -2
  240. package/src/utils/editHistory.test.ts +35 -0
  241. package/src/utils/gsapSoftReload.test.ts +119 -1
  242. package/src/utils/gsapSoftReload.ts +153 -0
  243. package/src/utils/mediaTypes.ts +3 -2
  244. package/src/utils/resizeDebug.ts +55 -0
  245. package/src/utils/rootDuration.test.ts +90 -1
  246. package/src/utils/rootDuration.ts +76 -13
  247. package/src/utils/sdkCutover.ts +4 -0
  248. package/src/utils/studioFileHistory.ts +37 -7
  249. package/src/utils/studioHelpers.ts +62 -0
  250. package/src/utils/studioPreviewHelpers.test.ts +65 -8
  251. package/src/utils/studioPreviewHelpers.ts +10 -0
  252. package/src/utils/studioTelemetry.ts +4 -1
  253. package/src/utils/studioUrlState.test.ts +0 -1
  254. package/src/utils/timelineAssetDrop.test.ts +144 -52
  255. package/src/utils/timelineAssetDrop.ts +67 -29
  256. package/src/utils/timelineDiscovery.ts +0 -17
  257. package/dist/assets/hyperframes-player-BBrKzTGd.js +0 -459
  258. package/dist/assets/index-BRwkMj0w.js +0 -423
  259. package/dist/assets/index-Dq7FEg0K.css +0 -1
  260. package/dist/chunk-SOTCF4DF.js.map +0 -1
  261. package/src/components/StudioPreviewArea.tsx +0 -500
  262. package/src/components/nle/NLELayout.test.ts +0 -12
  263. package/src/components/nle/NLELayout.tsx +0 -591
  264. package/src/player/components/TimelineLayerGutter.tsx +0 -61
  265. package/src/player/components/TimelineSelectionOverlays.tsx +0 -55
  266. package/src/player/components/timelineMarqueeSelection.test.ts +0 -87
  267. package/src/player/components/timelineSnapTargets.test.ts +0 -144
  268. package/src/player/components/timelineSnapTargets.ts +0 -164
  269. package/src/player/components/useTimelineClipDrag.test.tsx +0 -506
  270. package/src/player/components/useTimelineClipGroupDrag.ts +0 -417
  271. package/src/player/components/useTimelineMarqueeSelection.test.tsx +0 -231
  272. package/src/player/components/useTimelineMarqueeSelection.ts +0 -276
  273. package/src/utils/timelineDiscovery.test.ts +0 -90
  274. /package/dist/{domEditingLayers-2ECJK24D.js.map → domEditingLayers-6LQGKPOI.js.map} +0 -0
@@ -0,0 +1,254 @@
1
+ /**
2
+ * Canvas arrow-key nudge for DomEditOverlay: arrows move the selected
3
+ * element(s) 1 composition px, Shift = 10. Each keypress previews through the
4
+ * same GSAP/CSS channel as a drag draft, and the burst commits ONCE through
5
+ * the same onPathOffsetCommit / onGroupPathOffsetCommit path a drag drop uses
6
+ * — so a nudge burst is exactly a tiny drag: one source patch, one undo entry.
7
+ */
8
+ import { useCallback, useEffect, useRef, type RefObject } from "react";
9
+ import { useMountEffect } from "../../hooks/useMountEffect";
10
+ import { isEditableTarget } from "../../utils/timelineDiscovery";
11
+ import { acquireCanvasNudgeKeys } from "../../utils/canvasNudgeGate";
12
+ import type { DomEditSelection } from "./domEditing";
13
+ import {
14
+ type GroupOverlayItem,
15
+ type OverlayRect,
16
+ filterNestedDomEditGroupItems,
17
+ } from "./domEditOverlayGeometry";
18
+ import type {
19
+ BlockedMoveState,
20
+ DomEditGroupPathOffsetCommit,
21
+ GestureState,
22
+ GroupGestureState,
23
+ } from "./domEditOverlayGestures";
24
+ import {
25
+ applyManualOffsetNudgeCommit,
26
+ applyManualOffsetNudgeDraft,
27
+ createManualOffsetDragMember,
28
+ endManualOffsetDragMembers,
29
+ restoreManualOffsetDragMembers,
30
+ type ManualOffsetDragMember,
31
+ } from "./manualOffsetDrag";
32
+ import { isStudioManualEditGestureCurrent, restoreStudioPathOffset } from "./manualEdits";
33
+ import {
34
+ CANVAS_NUDGE_COMMIT_DEBOUNCE_MS,
35
+ canCanvasNudgeTargets,
36
+ resolveCanvasNudgeDelta,
37
+ } from "./domEditNudge";
38
+
39
+ interface NudgeSession {
40
+ members: ManualOffsetDragMember[];
41
+ isGroup: boolean;
42
+ /** Accumulated delta of the burst, in composition px. */
43
+ accum: { x: number; y: number };
44
+ timer: ReturnType<typeof setTimeout> | null;
45
+ }
46
+
47
+ export interface UseDomEditNudgeParams {
48
+ selection: DomEditSelection | null;
49
+ groupSelections: DomEditSelection[];
50
+ allowCanvasMovement: boolean;
51
+ selectionRef: RefObject<DomEditSelection | null>;
52
+ overlayRectRef: RefObject<OverlayRect | null>;
53
+ groupOverlayItemsRef: RefObject<GroupOverlayItem[]>;
54
+ gestureRef: RefObject<GestureState | null>;
55
+ groupGestureRef: RefObject<GroupGestureState | null>;
56
+ blockedMoveRef: RefObject<BlockedMoveState | null>;
57
+ onManualDragStartRef: RefObject<(() => void) | undefined>;
58
+ onPathOffsetCommitRef: RefObject<
59
+ (
60
+ s: DomEditSelection,
61
+ n: { x: number; y: number },
62
+ m?: { altKey?: boolean },
63
+ ) => Promise<void> | void
64
+ >;
65
+ onGroupPathOffsetCommitRef: RefObject<
66
+ (updates: DomEditGroupPathOffsetCommit[]) => Promise<void> | void
67
+ >;
68
+ }
69
+
70
+ type NudgeTarget = {
71
+ key: string;
72
+ selection: DomEditSelection;
73
+ element: HTMLElement;
74
+ rect: OverlayRect;
75
+ };
76
+
77
+ /**
78
+ * A selection's stable identity, not its object reference. A parent that
79
+ * doesn't memoize the selection it passes down hands us a new object every
80
+ * render for the SAME element — keying an effect on the object itself would
81
+ * then re-fire on every render instead of on an actual selection change.
82
+ *
83
+ * All discriminators are folded in (id / hfId / selector / selectorIndex /
84
+ * label), not just the first present one: two id-less siblings can share a
85
+ * selector, so `id ?? selector ?? label` collapsed them to the same key —
86
+ * selecting sibling B mid-burst then didn't flush A's pending nudge, so the
87
+ * next arrow kept moving A. selectorIndex is the discriminator that separates
88
+ * them. The parts are only ever compared for equality (never parsed back),
89
+ * so they're joined on a NUL delimiter — a byte no discriminator can contain,
90
+ * so distinct selections stay distinct even when a value (e.g. a label) holds
91
+ * a space. It's written as the escape "\u0000", not a literal 0x00 byte, so
92
+ * this source file stays text (a raw NUL makes git treat it as binary).
93
+ */
94
+ function selectionIdentityKey(selection: DomEditSelection | null): string {
95
+ if (!selection) return "";
96
+ return [
97
+ selection.id ?? "",
98
+ selection.hfId ?? "",
99
+ selection.selector ?? "",
100
+ selection.selectorIndex ?? "",
101
+ selection.label,
102
+ ].join("\u0000");
103
+ }
104
+
105
+ function groupSelectionsIdentityKey(selections: DomEditSelection[]): string {
106
+ return selections.map(selectionIdentityKey).join(" ");
107
+ }
108
+
109
+ /** Drag members for a multi-selection nudge (same snapshot a group drag uses). */
110
+ function resolveGroupNudgeTargets(groupItems: GroupOverlayItem[]): NudgeTarget[] | null {
111
+ if (!canCanvasNudgeTargets(groupItems.map((item) => item.selection))) return null;
112
+ return filterNestedDomEditGroupItems(groupItems);
113
+ }
114
+
115
+ /** Drag member for a single-selection nudge, or null when it can't be moved. */
116
+ function resolveSingleNudgeTarget(
117
+ sel: DomEditSelection | null,
118
+ rect: OverlayRect | null,
119
+ ): NudgeTarget[] | null {
120
+ if (!sel || !rect || !sel.capabilities.canApplyManualOffset || !sel.element.isConnected) {
121
+ return null;
122
+ }
123
+ return [{ key: sel.id ?? sel.selector ?? sel.label, selection: sel, element: sel.element, rect }];
124
+ }
125
+
126
+ /**
127
+ * True when a keydown must not start/extend a nudge: canvas movement disabled,
128
+ * a pointer gesture already owns the element, or the user is typing in a field.
129
+ */
130
+ function shouldIgnoreNudgeKey(p: UseDomEditNudgeParams, event: KeyboardEvent): boolean {
131
+ if (!p.allowCanvasMovement || event.defaultPrevented) return true;
132
+ if (p.gestureRef.current || p.groupGestureRef.current || p.blockedMoveRef.current) return true;
133
+ return isEditableTarget(event.target);
134
+ }
135
+
136
+ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: () => void } {
137
+ const sessionRef = useRef<NudgeSession | null>(null);
138
+ const paramsRef = useRef(params);
139
+ paramsRef.current = params;
140
+
141
+ // Commit the pending burst: one source write per burst = one undo entry.
142
+ // Mirrors the drag's onPointerUp — same commit callbacks, same failure
143
+ // restore, same member teardown.
144
+ const commitSession = () => {
145
+ const session = sessionRef.current;
146
+ if (!session) return;
147
+ sessionRef.current = null;
148
+ if (session.timer) clearTimeout(session.timer);
149
+ const updates: DomEditGroupPathOffsetCommit[] = session.members.map((member) => ({
150
+ selection: member.selection,
151
+ next: applyManualOffsetNudgeCommit(member, session.accum),
152
+ }));
153
+ const p = paramsRef.current;
154
+ const commit = session.isGroup
155
+ ? p.onGroupPathOffsetCommitRef.current(updates)
156
+ : p.onPathOffsetCommitRef.current(updates[0].selection, updates[0].next);
157
+ void Promise.resolve(commit)
158
+ .catch(() => {
159
+ for (const member of session.members) {
160
+ if (isStudioManualEditGestureCurrent(member.element, member.gestureToken)) {
161
+ restoreStudioPathOffset(member.element, member.initialPathOffset);
162
+ }
163
+ }
164
+ })
165
+ .finally(() => endManualOffsetDragMembers(session.members));
166
+ };
167
+ const commitSessionRef = useRef(commitSession);
168
+ commitSessionRef.current = commitSession;
169
+
170
+ // Build drag members for the current target set — the same member snapshot a
171
+ // pointer drag starts from (startGesture / startGroupDrag), so the nudge
172
+ // commit converts offsets → GSAP x/y with identical math.
173
+ const beginSession = (): NudgeSession | null => {
174
+ const p = paramsRef.current;
175
+ const groupItems = p.groupOverlayItemsRef.current;
176
+ const isGroup = groupItems.length > 1;
177
+ const targets = isGroup
178
+ ? resolveGroupNudgeTargets(groupItems)
179
+ : resolveSingleNudgeTarget(p.selectionRef.current, p.overlayRectRef.current);
180
+ if (!targets) return null;
181
+ const members: ManualOffsetDragMember[] = [];
182
+ for (const target of targets) {
183
+ const result = createManualOffsetDragMember(target);
184
+ if (!result.ok) {
185
+ restoreManualOffsetDragMembers(members);
186
+ return null;
187
+ }
188
+ members.push(result.member);
189
+ }
190
+ if (members.length === 0) return null;
191
+ // Same side effect a drag start has (pauses preview playback).
192
+ p.onManualDragStartRef.current?.();
193
+ return { members, isGroup, accum: { x: 0, y: 0 }, timer: null };
194
+ };
195
+
196
+ const handleKeyDown = (event: KeyboardEvent) => {
197
+ const p = paramsRef.current;
198
+ if (shouldIgnoreNudgeKey(p, event)) return;
199
+ const delta = resolveCanvasNudgeDelta(event);
200
+ if (!delta) return;
201
+ const session = sessionRef.current ?? beginSession();
202
+ if (!session) return;
203
+ sessionRef.current = session;
204
+ event.preventDefault();
205
+ session.accum = { x: session.accum.x + delta.dx, y: session.accum.y + delta.dy };
206
+ for (const member of session.members) applyManualOffsetNudgeDraft(member, session.accum);
207
+ if (session.timer) clearTimeout(session.timer);
208
+ session.timer = setTimeout(() => commitSessionRef.current(), CANVAS_NUDGE_COMMIT_DEBOUNCE_MS);
209
+ };
210
+ const handleKeyDownRef = useRef(handleKeyDown);
211
+ handleKeyDownRef.current = handleKeyDown;
212
+
213
+ useMountEffect(() => {
214
+ const listener = (event: KeyboardEvent) => handleKeyDownRef.current(event);
215
+ // Capture, like the other app-level key handlers, so a focused panel
216
+ // can't swallow the nudge before it reaches us.
217
+ window.addEventListener("keydown", listener, true);
218
+ return () => {
219
+ window.removeEventListener("keydown", listener, true);
220
+ commitSessionRef.current();
221
+ };
222
+ });
223
+
224
+ // Selection change ends the burst: commit to the OLD target before the
225
+ // arrows start moving the new one. Keyed on the selection's stable identity
226
+ // (id/selector/label), NOT the object reference — a parent that re-creates
227
+ // the selection object on every render (without memoizing it) must not
228
+ // flush a burst that's still in progress for the same element.
229
+ const selectionKey = selectionIdentityKey(params.selection);
230
+ const groupSelectionsKey = groupSelectionsIdentityKey(params.groupSelections);
231
+ // eslint-disable-next-line no-restricted-syntax
232
+ useEffect(() => () => commitSessionRef.current(), [selectionKey, groupSelectionsKey]);
233
+
234
+ // Claim the arrow keys from the playback frame-step while the selection is
235
+ // nudgeable (see canvasNudgeGate — listener order is mount-dependent, so
236
+ // preventDefault alone can't arbitrate).
237
+ // eslint-disable-next-line no-restricted-syntax
238
+ useEffect(() => {
239
+ const targets =
240
+ params.groupSelections.length > 1
241
+ ? params.groupSelections
242
+ : params.selection
243
+ ? [params.selection]
244
+ : [];
245
+ if (!params.allowCanvasMovement || !canCanvasNudgeTargets(targets)) return;
246
+ return acquireCanvasNudgeKeys();
247
+ }, [params.selection, params.groupSelections, params.allowCanvasMovement]);
248
+
249
+ // A pointer gesture supersedes a pending burst — DomEditOverlay flushes on
250
+ // pointerdown-capture so the drag's member snapshot starts from the nudged,
251
+ // committed position.
252
+ const flushNudge = useCallback(() => commitSessionRef.current(), []);
253
+ return { flushNudge };
254
+ }
@@ -4,7 +4,6 @@
4
4
  * Owns: onPointerMove, onPointerUp, clearPointerState.
5
5
  * startGesture and startGroupDrag live in domEditOverlayStartGesture.ts.
6
6
  */
7
- import { setElementGsapPosition } from "../../utils/elementGsap";
8
7
  import type { RefObject } from "react";
9
8
  import { type DomEditSelection } from "./domEditing";
10
9
  import {
@@ -30,36 +29,29 @@ import {
30
29
  import {
31
30
  type GroupOverlayItem,
32
31
  type OverlayRect,
32
+ orientedOverlayRect,
33
33
  resolveDomEditGroupOverlayRect,
34
- toOverlayRect,
35
34
  } from "./domEditOverlayGeometry";
36
35
  import {
37
36
  BLOCKED_MOVE_THRESHOLD_PX,
38
37
  type GestureKind,
39
38
  type GestureState,
40
39
  type GroupGestureState,
40
+ type ResizeHandle,
41
41
  type UseDomEditOverlayGesturesOptions,
42
+ ROTATED_SNAP_BYPASS_DEGREES,
42
43
  hasDomEditRotationChanged,
43
- resolveDomEditResizeGesture,
44
44
  resolveDomEditRotationGesture,
45
45
  } from "./domEditOverlayGestures";
46
+ import { resolveCenterResizeSize } from "./domEditResizeLocal";
47
+ import { resolveResizeDraftRect } from "./resizeDraft";
46
48
  import {
47
49
  startGesture as _startGesture,
48
50
  startGroupDrag as _startGroupDrag,
49
51
  } from "./domEditOverlayStartGesture";
50
52
  import { hugRectForElement } from "./domEditOverlayCrop";
51
- import {
52
- resolveSnapAdjustment,
53
- resolveResizeSnapAdjustment,
54
- resolveEquidistanceGuides,
55
- SNAP_THRESHOLD_PX,
56
- } from "./snapEngine";
57
- /** Undo the resize draft's anchor pin: snap GSAP x/y back to the gesture base. */
58
- function restoreResizeAnchorPin(element: HTMLElement, g: GestureState): void {
59
- const anchor = g.resizeAnchor;
60
- if (!anchor || (anchor.pinX === 0 && anchor.pinY === 0)) return;
61
- setElementGsapPosition(element, anchor.baseGsapX, anchor.baseGsapY);
62
- }
53
+ import { resolveSnapAdjustment, resolveEquidistanceGuides, SNAP_THRESHOLD_PX } from "./snapEngine";
54
+ import { logResize, logResizeMove, logResizeSettle } from "../../utils/resizeDebug";
63
55
 
64
56
  export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGesturesOptions) {
65
57
  const setDraftOverlayRect = (next: OverlayRect) => {
@@ -73,6 +65,10 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
73
65
  height: g.originHeight,
74
66
  editScaleX: g.editScaleX,
75
67
  editScaleY: g.editScaleY,
68
+ // Every draft rect must carry the element's rotation: the rotation wrapper
69
+ // renders rotate(overlayRect.angle), so an omitted angle straightens the
70
+ // chrome for the duration of the draft (the "straightens while moving" bug).
71
+ angle: g.actualRotation,
76
72
  });
77
73
  };
78
74
  const setDraftGroupOverlayItems = (next: GroupOverlayItem[]) => {
@@ -88,7 +84,11 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
88
84
  const startGesture = (
89
85
  kind: GestureKind,
90
86
  e: React.PointerEvent<HTMLElement>,
91
- options?: { selection?: DomEditSelection; rect?: OverlayRect | null },
87
+ options?: {
88
+ selection?: DomEditSelection;
89
+ rect?: OverlayRect | null;
90
+ resizeHandle?: ResizeHandle;
91
+ },
92
92
  ) => _startGesture(kind, e, opts, options);
93
93
 
94
94
  // fallow-ignore-next-line complexity
@@ -183,8 +183,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
183
183
  actualAngle: g.actualRotation,
184
184
  snap: e.shiftKey,
185
185
  });
186
- const draftViaGsap = applyRotationDraftViaGsap(sel.element, rotated.angle);
187
- if (!draftViaGsap) {
186
+ if (!applyRotationDraftViaGsap(sel.element, rotated.angle)) {
188
187
  applyStudioRotationDraft(sel.element, rotated);
189
188
  }
190
189
  return;
@@ -192,7 +191,11 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
192
191
 
193
192
  if (g.kind === "drag") {
194
193
  const sc = g.snapContext;
195
- if (sc?.snapEnabled && sc.targets.length > 0) {
194
+ // Bypass edge-snapping for rotated elements — the snap targets and the
195
+ // snapped rect are axis-aligned, so snapping a rotated box's AABB shifts it
196
+ // unpredictably. Rotation ~0 keeps snapping exactly as before.
197
+ const dragRotated = Math.abs(g.actualRotation) >= ROTATED_SNAP_BYPASS_DEGREES;
198
+ if (!dragRotated && sc?.snapEnabled && sc.targets.length > 0) {
196
199
  // Snap the element's VISIBLE (crop-hugged) edges, not the full bounds.
197
200
  const movingRect = hugRectForElement(
198
201
  {
@@ -246,6 +249,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
246
249
  height: g.originHeight,
247
250
  editScaleX: g.editScaleX,
248
251
  editScaleY: g.editScaleY,
252
+ angle: g.actualRotation,
249
253
  });
250
254
  if (box) {
251
255
  box.style.left = `${nextBoxLeft}px`;
@@ -255,90 +259,49 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
255
259
  } else {
256
260
  if (!box) return;
257
261
 
258
- const sc = g.snapContext;
259
- if (sc?.snapEnabled && sc.targets.length > 0) {
260
- const movingRect = {
261
- left: g.originLeft,
262
- top: g.originTop,
263
- width: g.originWidth,
264
- height: g.originHeight,
265
- };
266
- const allTargets = sc.compositionTarget
267
- ? [...sc.targets, sc.compositionTarget]
268
- : sc.targets;
269
- const snap = resolveResizeSnapAdjustment({
270
- movingRect,
271
- proposedDx: dx,
272
- proposedDy: dy,
273
- targets: allTargets,
274
- gridEdges: sc.gridEdges ?? undefined,
275
- threshold: SNAP_THRESHOLD_PX,
276
- disabled: e.altKey,
277
- });
278
- dx = snap.dx;
279
- dy = snap.dy;
280
- opts.snapGuidesRef.current = { guides: snap.guides, spacingGuides: [] };
281
- }
282
-
283
- const nextSize = resolveDomEditResizeGesture({
284
- originWidth: g.originWidth,
285
- originHeight: g.originHeight,
286
- actualWidth: g.actualWidth,
287
- actualHeight: g.actualHeight,
288
- scaleX: g.editScaleX,
289
- scaleY: g.editScaleY,
290
- contentScaleX: g.contentScaleX,
291
- contentScaleY: g.contentScaleY,
292
- dx,
293
- dy,
294
- uniform: e.shiftKey,
262
+ // CENTER-ANCHORED size (CapCut model): the element scales proportionally
263
+ // about its CENTER the scale is the pointer's RADIAL distance from the
264
+ // element center now over its distance at gesture start. Rotation-invariant
265
+ // (a distance ignores the angle) and continuous, so all four corners behave
266
+ // identically and there is no per-axis projection or edge-snapping. Base size
267
+ // is the element-local px size at gesture start (actualWidth/Height,
268
+ // GSAP-scale-aware). Corner drag is ALWAYS proportional; there is no
269
+ // free-form stretch gesture. Edge-snapping is intentionally NOT applied:
270
+ // with center anchoring both edges move symmetrically, so the corner-anchored
271
+ // snap math no longer holds — CapCut does not edge-snap during scale either.
272
+ const nextSize = resolveCenterResizeSize({
273
+ baseWidth: g.actualWidth,
274
+ baseHeight: g.actualHeight,
275
+ pointer: { x: e.clientX, y: e.clientY },
276
+ pointerStart: { x: g.startX, y: g.startY },
277
+ centerStart: { x: g.centerX, y: g.centerY },
295
278
  });
296
279
  applyStudioBoxSizeDraft(sel.element, nextSize);
297
- // Pin the gesture anchor (top-left): with a live scale transform, the CSS
298
- // size change shifts the rendered box around the element center. Measure
299
- // the drift of the gesture-start corner and counter it via GSAP x/y —
300
- // accumulated onto the previous pin so the correction converges instead
301
- // of oscillating. The release-time position compensation re-measures the
302
- // drop, so the pin composes with the commit.
303
- const anchor = g.resizeAnchor;
304
- if (anchor) {
305
- const pinned = sel.element.getBoundingClientRect();
306
- const nextPinX = anchor.pinX + (anchor.anchorX - pinned.x);
307
- const nextPinY = anchor.pinY + (anchor.anchorY - pinned.y);
308
- if (
309
- setElementGsapPosition(
310
- sel.element,
311
- anchor.baseGsapX + nextPinX,
312
- anchor.baseGsapY + nextPinY,
313
- )
314
- ) {
315
- anchor.pinX = nextPinX;
316
- anchor.pinY = nextPinY;
317
- }
318
- }
319
- // Re-read BCR after applying dimensions. For elements with a GSAP
320
- // scale transform and centered transform-origin the visual top-left
321
- // drifts and the visual size diverges from the raw CSS size, so BCR
322
- // is the only accurate source for both.
280
+
323
281
  const overlayEl = opts.overlayRef.current;
324
282
  const iframe = opts.iframeRef.current;
325
- const refreshed = overlayEl && iframe ? toOverlayRect(overlayEl, iframe, sel.element) : null;
326
- const overlayLeft = refreshed ? refreshed.left : g.originLeft;
327
- const overlayTop = refreshed ? refreshed.top : g.originTop;
328
- const overlayWidth = refreshed ? refreshed.width : nextSize.overlayWidth;
329
- const overlayHeight = refreshed ? refreshed.height : nextSize.overlayHeight;
330
- box.style.left = `${overlayLeft}px`;
331
- box.style.top = `${overlayTop}px`;
332
- box.style.width = `${overlayWidth}px`;
333
- box.style.height = `${overlayHeight}px`;
334
- setDraftOverlayRect({
335
- left: overlayLeft,
336
- top: overlayTop,
337
- width: overlayWidth,
338
- height: overlayHeight,
339
- editScaleX: g.editScaleX,
340
- editScaleY: g.editScaleY,
283
+ const measureOrientedRect = () =>
284
+ overlayEl && iframe ? orientedOverlayRect(overlayEl, iframe, sel.element) : null;
285
+
286
+ const draftRect = resolveResizeDraftRect(
287
+ g,
288
+ sel.element,
289
+ overlayEl,
290
+ iframe,
291
+ measureOrientedRect,
292
+ );
293
+ logResizeMove({
294
+ pointer: { x: e.clientX, y: e.clientY },
295
+ nextSize,
296
+ anchor: g.lastResizeAnchor ?? null,
297
+ draftRect,
298
+ liveInlineStyle: sel.element.getAttribute("style"),
341
299
  });
300
+ box.style.left = `${draftRect.left}px`;
301
+ box.style.top = `${draftRect.top}px`;
302
+ box.style.width = `${draftRect.width}px`;
303
+ box.style.height = `${draftRect.height}px`;
304
+ setDraftOverlayRect(draftRect);
342
305
  }
343
306
  };
344
307
 
@@ -414,9 +377,12 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
414
377
  }
415
378
 
416
379
  if (g.kind === "resize" && movedDistance < BLOCKED_MOVE_THRESHOLD_PX) {
417
- restoreResizeAnchorPin(sel.element, g);
418
380
  restoreStudioBoxSize(sel.element, g.initialBoxSize);
419
- endStudioManualEditGesture(sel.element, g.manualEditDragToken);
381
+ if (g.pathOffsetMember) {
382
+ restoreManualOffsetDragMembers([g.pathOffsetMember]);
383
+ } else {
384
+ endStudioManualEditGesture(sel.element, g.manualEditDragToken);
385
+ }
420
386
  if (box) {
421
387
  box.style.width = `${g.originWidth}px`;
422
388
  box.style.height = `${g.originHeight}px`;
@@ -444,8 +410,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
444
410
  restoreStudioRotation(sel.element, g.initialRotation);
445
411
  }
446
412
  };
447
- const rotationChanged = hasDomEditRotationChanged(g.actualRotation, finalRotation.angle);
448
- if (!rotationChanged) {
413
+ if (!hasDomEditRotationChanged(g.actualRotation, finalRotation.angle)) {
449
414
  restoreRotation();
450
415
  endStudioManualEditGesture(sel.element, g.manualEditDragToken);
451
416
  return;
@@ -464,10 +429,13 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
464
429
  )
465
430
  restoreRotation();
466
431
  })
467
- .finally(() => {
468
- endStudioManualEditGesture(sel.element, g.manualEditDragToken);
469
- });
432
+ .finally(() => endStudioManualEditGesture(sel.element, g.manualEditDragToken));
470
433
  } else if (g.kind === "drag") {
434
+ // A moved drag (taps returned earlier) must not let the release click
435
+ // re-select whatever now sits under the pointer — dropping over a
436
+ // higher-z element should keep the dragged element selected, not select
437
+ // the drop target. Mirrors the resize branch below.
438
+ opts.suppressNextBoxClickRef.current = true;
471
439
  const dx = g.lastSnappedDx ?? e.clientX - g.startX;
472
440
  const dy = g.lastSnappedDy ?? e.clientY - g.startY;
473
441
  if (!g.pathOffsetMember) {
@@ -483,6 +451,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
483
451
  height: g.originHeight,
484
452
  editScaleX: g.editScaleX,
485
453
  editScaleY: g.editScaleY,
454
+ angle: g.actualRotation,
486
455
  });
487
456
  if (box) {
488
457
  box.style.left = `${nextBoxLeft}px`;
@@ -505,18 +474,45 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
505
474
  opts.suppressNextBoxClickRef.current = true;
506
475
  const finalSize = readStudioBoxSize(sel.element);
507
476
  applyStudioBoxSize(sel.element, finalSize);
508
- void Promise.resolve(opts.onBoxSizeCommitRef.current(sel, finalSize))
477
+ // Anchored corner resize (NW/NE/SW) also moved the element to keep the
478
+ // center planted. Land the size AND the anchor offset in a SINGLE
479
+ // box-size commit (one persist, one undo entry). The prior two-commit
480
+ // sequence re-stamped the element from source after the size-only persist
481
+ // but before the offset persist landed — that one frame (new size, old
482
+ // offset) was the release "jump". SE has no anchor member → size only.
483
+ const member = g.pathOffsetMember;
484
+ const anchor = g.lastResizeAnchor;
485
+ const finalOffset =
486
+ member && anchor && (anchor.dx !== 0 || anchor.dy !== 0)
487
+ ? applyManualOffsetDragCommit(member, anchor.dx, anchor.dy)
488
+ : null;
489
+ logResize("release", {
490
+ finalSize,
491
+ anchor: anchor ?? null,
492
+ finalOffset: finalOffset ?? null,
493
+ hasMember: !!member,
494
+ inlineStyle: sel.element.getAttribute("style"),
495
+ });
496
+ const restore = () => {
497
+ if (
498
+ !g.manualEditDragToken ||
499
+ !isStudioManualEditGestureCurrent(sel.element, g.manualEditDragToken)
500
+ )
501
+ return;
502
+ restoreStudioBoxSize(sel.element, g.initialBoxSize);
503
+ if (finalOffset) restoreStudioPathOffset(sel.element, g.initialPathOffset);
504
+ };
505
+ void Promise.resolve(
506
+ opts.onBoxSizeCommitRef.current(sel, finalSize, finalOffset ?? undefined, restore),
507
+ )
509
508
  .catch((error) => {
510
509
  console.error("resize commit failed", error);
511
- if (
512
- g.manualEditDragToken &&
513
- isStudioManualEditGestureCurrent(sel.element, g.manualEditDragToken)
514
- ) {
515
- restoreResizeAnchorPin(sel.element, g);
516
- restoreStudioBoxSize(sel.element, g.initialBoxSize);
517
- }
518
510
  })
519
- .finally(() => endStudioManualEditGesture(sel.element, g.manualEditDragToken));
511
+ .finally(() => {
512
+ if (member) endManualOffsetDragMembers([member]);
513
+ else endStudioManualEditGesture(sel.element, g.manualEditDragToken);
514
+ });
515
+ logResizeSettle(sel.element, "post-release");
520
516
  }
521
517
  };
522
518
 
@@ -534,9 +530,12 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
534
530
  restoreGestureOverlayRect(g);
535
531
  }
536
532
  if (g?.mode === "box-size" && sel) {
537
- restoreResizeAnchorPin(sel.element, g);
538
533
  restoreStudioBoxSize(sel.element, g.initialBoxSize);
539
- endStudioManualEditGesture(sel.element, g.manualEditDragToken);
534
+ if (g.pathOffsetMember) {
535
+ restoreManualOffsetDragMembers([g.pathOffsetMember]);
536
+ } else {
537
+ endStudioManualEditGesture(sel.element, g.manualEditDragToken);
538
+ }
540
539
  restoreGestureOverlayRect(g);
541
540
  }
542
541
  if (g?.mode === "rotation" && sel) {
@@ -13,6 +13,7 @@ import {
13
13
  groupOverlayItemsEqual,
14
14
  isElementVisibleForOverlay,
15
15
  groupAwareOverlayRect,
16
+ orientedGroupAwareOverlayRect,
16
17
  rectsEqual,
17
18
  resolveElementForOverlay,
18
19
  selectionCacheKey,
@@ -157,7 +158,13 @@ export function useDomEditOverlayRects({
157
158
  // backgroundless full-bleed scene above a subcomposition), which would wrongly
158
159
  // hide the selection box. Occlusion stays for hover, where a false hide is cheap.
159
160
  if (el && isElementVisibleForOverlay(el)) {
160
- const nextRect = groupAwareOverlayRect(overlayEl, iframe, el);
161
+ // Groups render as an AABB union of their members (a group OBB is out of
162
+ // scope); a single element renders as an oriented box that co-rotates
163
+ // with its transform. orientedOverlayRect gates on rotation internally
164
+ // (a cheap per-call check) and only pays for the full corner-transform
165
+ // measurement when the element is actually rotated — this RAF loop runs
166
+ // every frame for any single selection, so that gate matters here most.
167
+ const nextRect = orientedGroupAwareOverlayRect(overlayEl, iframe, el);
161
168
  setOverlayRect(nextRect);
162
169
  const descendants = el.querySelectorAll("*");
163
170
  if (descendants.length > 0 && descendants.length <= 60) {
@@ -242,7 +249,7 @@ export function useDomEditOverlayRects({
242
249
  return;
243
250
  }
244
251
 
245
- setHoverRect(groupAwareOverlayRect(overlayEl, iframe, hoverEl));
252
+ setHoverRect(orientedGroupAwareOverlayRect(overlayEl, iframe, hoverEl));
246
253
  };
247
254
 
248
255
  frame = requestAnimationFrame(update);