@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,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
+ }
@@ -5,8 +5,8 @@ import {
5
5
  buildCompositionSnapTarget,
6
6
  buildGridSnapEdges,
7
7
  resolveSnapAdjustment,
8
- resolveResizeSnapAdjustment,
9
8
  resolveEquidistanceGuides,
9
+ resolveGuideLineRect,
10
10
  SNAP_THRESHOLD_PX,
11
11
  type SnapTarget,
12
12
  } from "./snapEngine";
@@ -385,88 +385,22 @@ describe("resolveSnapAdjustment", () => {
385
385
  });
386
386
 
387
387
  // ---------------------------------------------------------------------------
388
- // resolveResizeSnapAdjustment
388
+ // resolveGuideLineRect
389
389
  // ---------------------------------------------------------------------------
390
390
 
391
- describe("resolveResizeSnapAdjustment", () => {
392
- test("only right edge snaps on X", () => {
393
- // Moving rect at (100, 100) size 50x50, right=150.
394
- // Target left at 200. Propose dx=47 => proposed right=197. Dist to 200=3.
395
- const t = target("a", 200, 100, 100, 100);
396
- const result = resolveResizeSnapAdjustment({
397
- movingRect: rect(100, 100, 50, 50),
398
- proposedDx: 47,
399
- proposedDy: 0,
400
- targets: [t],
401
- threshold: SNAP_THRESHOLD_PX,
402
- disabled: false,
403
- });
404
- expect(result.dx).toBe(50); // right edge snaps to 200
405
- });
391
+ describe("resolveGuideLineRect", () => {
392
+ const composition = rect(120, 80, 640, 360); // letterboxed inside the overlay
406
393
 
407
- test("only bottom edge snaps on Y", () => {
408
- // Moving rect at (100, 100) size 50x50, bottom=150.
409
- // Target top at 200. Propose dy=47 => proposed bottom=197. Dist to 200=3.
410
- const t = target("a", 100, 200, 100, 100);
411
- const result = resolveResizeSnapAdjustment({
412
- movingRect: rect(100, 100, 50, 50),
413
- proposedDx: 0,
414
- proposedDy: 47,
415
- targets: [t],
416
- threshold: SNAP_THRESHOLD_PX,
417
- disabled: false,
418
- });
419
- expect(result.dy).toBe(50); // bottom edge snaps to 200
420
- });
421
-
422
- test("left edge does NOT snap during resize", () => {
423
- // Target right at 150. Moving rect left=100. If drag were active,
424
- // left would snap. But during resize, only right edge snaps.
425
- // Moving rect at (100, 100) size 200x200, right=300.
426
- // Target right=150. Proposed dx=-153 => proposed right=147. Dist to 150=3.
427
- // This SHOULD snap right to 150 (dx = -150). But left stays at 100.
428
- const t = target("a", 50, 100, 100, 100); // right=150
429
- const result = resolveResizeSnapAdjustment({
430
- movingRect: rect(100, 100, 200, 200),
431
- proposedDx: -153,
432
- proposedDy: 0,
433
- targets: [t],
434
- threshold: SNAP_THRESHOLD_PX,
435
- disabled: false,
436
- });
437
- // Right edge: 300 + (-153) = 147 => snaps to 150, adjustment = +3, dx = -150
438
- expect(result.dx).toBe(-150);
439
- });
440
-
441
- test("disabled=true returns passthrough for resize", () => {
442
- const t = target("a", 200, 200, 100, 100);
443
- const result = resolveResizeSnapAdjustment({
444
- movingRect: rect(100, 100, 50, 50),
445
- proposedDx: 47,
446
- proposedDy: 47,
447
- targets: [t],
448
- threshold: SNAP_THRESHOLD_PX,
449
- disabled: true,
450
- });
451
- expect(result.dx).toBe(47);
452
- expect(result.dy).toBe(47);
453
- expect(result.guides).toHaveLength(0);
394
+ test("vertical guide (axis x) spans the composition's height at the snap x", () => {
395
+ expect(resolveGuideLineRect({ axis: "x", position: 440, from: 0, to: 0 }, composition)).toEqual(
396
+ { left: 440, top: 80, width: 1, height: 360 },
397
+ );
454
398
  });
455
399
 
456
- test("resize produces guide lines", () => {
457
- const t = target("a", 200, 100, 100, 100);
458
- const result = resolveResizeSnapAdjustment({
459
- movingRect: rect(100, 100, 50, 50),
460
- proposedDx: 47,
461
- proposedDy: 0,
462
- targets: [t],
463
- threshold: SNAP_THRESHOLD_PX,
464
- disabled: false,
465
- });
466
- expect(result.guides.length).toBeGreaterThanOrEqual(1);
467
- const xGuide = result.guides.find((g) => g.axis === "x");
468
- expect(xGuide).toBeDefined();
469
- expect(xGuide!.position).toBe(200);
400
+ test("horizontal guide (axis y) spans the composition's width at the snap y", () => {
401
+ expect(resolveGuideLineRect({ axis: "y", position: 260, from: 0, to: 0 }, composition)).toEqual(
402
+ { left: 120, top: 260, width: 640, height: 1 },
403
+ );
470
404
  });
471
405
  });
472
406
 
@@ -410,62 +410,22 @@ export function resolveSnapAdjustment(input: {
410
410
  }
411
411
 
412
412
  // ---------------------------------------------------------------------------
413
- // resolveResizeSnapAdjustmentresize variant (only right/bottom snap)
413
+ // resolveGuideLineRectscreen rect for rendering a snap guide line
414
414
  // ---------------------------------------------------------------------------
415
415
 
416
- // fallow-ignore-next-line complexity
417
- export function resolveResizeSnapAdjustment(input: {
418
- movingRect: Rect;
419
- proposedDx: number;
420
- proposedDy: number;
421
- targets: SnapTarget[];
422
- gridEdges?: { x: SnapEdge[]; y: SnapEdge[] };
423
- threshold: number;
424
- disabled: boolean;
425
- }): SnapResult {
426
- if (input.disabled || input.threshold <= 0) {
427
- return DISABLED_RESULT(input.proposedDx, input.proposedDy);
416
+ /**
417
+ * Full-length guide line spanning the composition: a vertical line (axis "x")
418
+ * runs the composition's height at the snapped x position; a horizontal line
419
+ * (axis "y") runs the composition's width. `composition` is the composition
420
+ * rect in overlay space — guide positions are already overlay-space, so the
421
+ * line must be offset by the composition's left/top (the canvas is usually
422
+ * letterboxed inside the overlay).
423
+ */
424
+ export function resolveGuideLineRect(guide: SnapGuide, composition: Rect): Rect {
425
+ if (guide.axis === "x") {
426
+ return { left: guide.position, top: composition.top, width: 1, height: composition.height };
428
427
  }
429
-
430
- const mr = input.movingRect;
431
- const proposedRight = rectRight(mr) + input.proposedDx;
432
- const proposedBottom = rectBottom(mr) + input.proposedDy;
433
-
434
- const xCandidates = collectCandidates(
435
- [proposedRight],
436
- input.targets,
437
- (t) => [t.left, t.centerX, t.right],
438
- input.gridEdges?.x,
439
- input.threshold,
440
- );
441
- const yCandidates = collectCandidates(
442
- [proposedBottom],
443
- input.targets,
444
- (t) => [t.top, t.centerY, t.bottom],
445
- input.gridEdges?.y,
446
- input.threshold,
447
- );
448
-
449
- const bestX = pickBest(xCandidates);
450
- const bestY = pickBest(yCandidates);
451
- const adjustedDx = input.proposedDx + (bestX?.adjustment ?? 0);
452
- const adjustedDy = input.proposedDy + (bestY?.adjustment ?? 0);
453
-
454
- const adjustedRect: Rect = {
455
- left: mr.left,
456
- top: mr.top,
457
- width: mr.width + adjustedDx,
458
- height: mr.height + adjustedDy,
459
- };
460
-
461
- const targetMap = new Map(input.targets.map((t) => [t.id, t]));
462
-
463
- return {
464
- dx: adjustedDx,
465
- dy: adjustedDy,
466
- guides: buildGuidesFromMatches(bestX, bestY, adjustedRect, targetMap),
467
- spacingGuides: [], // computed separately via resolveEquidistanceGuides
468
- };
428
+ return { left: composition.left, top: guide.position, width: composition.width, height: 1 };
469
429
  }
470
430
 
471
431
  // ---------------------------------------------------------------------------
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Canvas right-click context-menu state for DomEditOverlay: where the menu is
3
+ * open (viewport x/y) and which selection it targets, plus the right-click
4
+ * handler that resolves/selects the element under the pointer before opening.
5
+ */
6
+ import { useCallback, useEffect, useState, type RefObject } from "react";
7
+ import type { DomEditSelection } from "./domEditing";
8
+
9
+ export interface CanvasContextMenuState {
10
+ x: number;
11
+ y: number;
12
+ sel: DomEditSelection;
13
+ }
14
+
15
+ interface UseCanvasContextMenuStateParams {
16
+ selection: DomEditSelection | null;
17
+ selectionRef: RefObject<DomEditSelection | null>;
18
+ hoverSelectionRef: RefObject<DomEditSelection | null>;
19
+ onCanvasPointerMoveRef: RefObject<
20
+ (
21
+ event: React.PointerEvent<HTMLDivElement>,
22
+ options?: { preferClipAncestor?: boolean },
23
+ ) => Promise<DomEditSelection | null>
24
+ >;
25
+ onSelectionChangeRef: RefObject<
26
+ (selection: DomEditSelection, options?: { revealPanel?: boolean; additive?: boolean }) => void
27
+ >;
28
+ }
29
+
30
+ export function useCanvasContextMenuState({
31
+ selection,
32
+ selectionRef,
33
+ hoverSelectionRef,
34
+ onCanvasPointerMoveRef,
35
+ onSelectionChangeRef,
36
+ }: UseCanvasContextMenuStateParams): {
37
+ contextMenu: CanvasContextMenuState | null;
38
+ closeContextMenu: () => void;
39
+ handleContextMenu: (event: React.MouseEvent<HTMLDivElement>) => Promise<void>;
40
+ } {
41
+ // Context menu state: position of the right-click that opened it.
42
+ // contextMenu.sel is the element the menu targets — captured at right-click
43
+ // time so the menu can open even before the React selection state settles.
44
+ const [contextMenu, setContextMenu] = useState<CanvasContextMenuState | null>(null);
45
+ const closeContextMenu = useCallback(() => setContextMenu(null), []);
46
+
47
+ // Close the context menu whenever the selection moves off the element the menu
48
+ // targets (a click that reselects elsewhere, a deselect, or a preview reload
49
+ // that rebuilds the selection). Without this the menu can linger — orphaned —
50
+ // over a stale target after the underlying element is gone. A right-click that
51
+ // OPENS the menu also selects its target, so the common open path keeps the
52
+ // menu (same element) rather than immediately dismissing it.
53
+ useEffect(() => {
54
+ if (!contextMenu) return;
55
+ if (!selection || selection.element !== contextMenu.sel.element) {
56
+ setContextMenu(null);
57
+ }
58
+ }, [selection, contextMenu]);
59
+
60
+ // Right-click: select element first (if not already selected), then open menu.
61
+ const handleContextMenu = useCallback(
62
+ async (event: React.MouseEvent<HTMLDivElement>) => {
63
+ event.preventDefault();
64
+
65
+ // If no element is selected yet, resolve it from the pointer position first.
66
+ const currentSel = selectionRef.current;
67
+ let activeSel: DomEditSelection | null = currentSel;
68
+ if (!currentSel) {
69
+ const pointerEvent = event as unknown as React.PointerEvent<HTMLDivElement>;
70
+ const resolved = await onCanvasPointerMoveRef.current(pointerEvent);
71
+ if (!resolved) return; // Nothing under the cursor — skip menu.
72
+ onSelectionChangeRef.current(resolved, { revealPanel: true });
73
+ // Use `resolved` directly: React state (and therefore selectionRef) won't
74
+ // update synchronously after onSelectionChange — we'd be reading stale null.
75
+ activeSel = resolved;
76
+ } else {
77
+ // Check if the user right-clicked on an unselected element (hover target).
78
+ const hover = hoverSelectionRef.current;
79
+ if (hover && hover.element !== currentSel.element) {
80
+ onSelectionChangeRef.current(hover, { revealPanel: true });
81
+ activeSel = hover;
82
+ }
83
+ }
84
+
85
+ if (!activeSel) return;
86
+ setContextMenu({ x: event.clientX, y: event.clientY, sel: activeSel });
87
+ },
88
+ [selectionRef, hoverSelectionRef, onCanvasPointerMoveRef, onSelectionChangeRef],
89
+ );
90
+
91
+ return { contextMenu, closeContextMenu, handleContextMenu };
92
+ }
@@ -0,0 +1,228 @@
1
+ // @vitest-environment happy-dom
2
+ import React, { act } from "react";
3
+ import { createRoot } from "react-dom/client";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import { installReactActEnvironment, makeSelection } from "../../hooks/domSelectionTestHarness";
6
+ import { useDomEditNudge, type UseDomEditNudgeParams } from "./useDomEditNudge";
7
+ import { CANVAS_NUDGE_COMMIT_DEBOUNCE_MS, CANVAS_NUDGE_STEP_PX } from "./domEditNudge";
8
+ import { __resetForTests } from "../../utils/canvasNudgeGate";
9
+ import type { DomEditSelection } from "./domEditing";
10
+ import type { OverlayRect } from "./domEditOverlayGeometry";
11
+
12
+ installReactActEnvironment();
13
+
14
+ function makeRef<T>(current: T): { current: T } {
15
+ return { current };
16
+ }
17
+
18
+ const REST_RECT: OverlayRect = {
19
+ left: 0,
20
+ top: 0,
21
+ width: 100,
22
+ height: 50,
23
+ editScaleX: 1,
24
+ editScaleY: 1,
25
+ };
26
+
27
+ // Stable across renders on purpose: the test targets the `selection` identity
28
+ // key specifically, so `groupSelections` must not itself be a source of churn.
29
+ const EMPTY_GROUP_SELECTIONS: DomEditSelection[] = [];
30
+
31
+ function Harness({
32
+ selection,
33
+ onPathOffsetCommit,
34
+ }: {
35
+ selection: DomEditSelection | null;
36
+ onPathOffsetCommit: UseDomEditNudgeParams["onPathOffsetCommitRef"]["current"];
37
+ }) {
38
+ useDomEditNudge({
39
+ selection,
40
+ groupSelections: EMPTY_GROUP_SELECTIONS,
41
+ allowCanvasMovement: true,
42
+ selectionRef: makeRef(selection),
43
+ overlayRectRef: makeRef(REST_RECT),
44
+ groupOverlayItemsRef: makeRef([]),
45
+ gestureRef: makeRef(null),
46
+ groupGestureRef: makeRef(null),
47
+ blockedMoveRef: makeRef(null),
48
+ onManualDragStartRef: makeRef(() => {}),
49
+ onPathOffsetCommitRef: makeRef(onPathOffsetCommit),
50
+ onGroupPathOffsetCommitRef: makeRef(async () => {}),
51
+ });
52
+ return null;
53
+ }
54
+
55
+ function dispatchArrowRight(): void {
56
+ window.dispatchEvent(
57
+ new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true, cancelable: true }),
58
+ );
59
+ }
60
+
61
+ describe("useDomEditNudge — selection cleanup keyed on stable identity", () => {
62
+ beforeEach(() => {
63
+ vi.useFakeTimers();
64
+ __resetForTests();
65
+ });
66
+
67
+ afterEach(() => {
68
+ vi.useRealTimers();
69
+ });
70
+
71
+ it("keeps a nudge burst alive when the parent hands down a new selection object for the same element", () => {
72
+ const host = document.createElement("div");
73
+ document.body.append(host);
74
+ const root = createRoot(host);
75
+
76
+ const element = document.createElement("div");
77
+ element.id = "dot-a";
78
+ document.body.append(element);
79
+
80
+ const commit = vi.fn();
81
+ const firstSelection = makeSelection("Dot", element);
82
+
83
+ act(() => {
84
+ root.render(
85
+ React.createElement(Harness, { selection: firstSelection, onPathOffsetCommit: commit }),
86
+ );
87
+ });
88
+
89
+ act(() => {
90
+ dispatchArrowRight();
91
+ });
92
+ expect(commit).not.toHaveBeenCalled();
93
+
94
+ // Re-render with a BRAND NEW selection object describing the SAME element
95
+ // (same id) — exactly what an un-memoized parent does on every render.
96
+ // Before the fix, the cleanup effect was keyed on this object's identity
97
+ // and would flush the burst right here, one arrow-press early.
98
+ const secondSelection = makeSelection("Dot", element);
99
+ act(() => {
100
+ root.render(
101
+ React.createElement(Harness, { selection: secondSelection, onPathOffsetCommit: commit }),
102
+ );
103
+ });
104
+ expect(commit).not.toHaveBeenCalled();
105
+
106
+ act(() => {
107
+ dispatchArrowRight();
108
+ });
109
+ expect(commit).not.toHaveBeenCalled();
110
+
111
+ act(() => {
112
+ vi.advanceTimersByTime(CANVAS_NUDGE_COMMIT_DEBOUNCE_MS + 10);
113
+ });
114
+
115
+ // One combined commit for both presses, not two separate (premature) ones.
116
+ expect(commit).toHaveBeenCalledTimes(1);
117
+ const [, next] = commit.mock.calls[0] as [DomEditSelection, { x: number; y: number }];
118
+ expect(next.x).toBeCloseTo(2 * CANVAS_NUDGE_STEP_PX);
119
+ expect(next.y).toBeCloseTo(0);
120
+
121
+ act(() => root.unmount());
122
+ host.remove();
123
+ element.remove();
124
+ });
125
+
126
+ it("still flushes the burst when the selection actually changes to a different element", () => {
127
+ const host = document.createElement("div");
128
+ document.body.append(host);
129
+ const root = createRoot(host);
130
+
131
+ const elementA = document.createElement("div");
132
+ elementA.id = "dot-a";
133
+ document.body.append(elementA);
134
+ const elementB = document.createElement("div");
135
+ elementB.id = "dot-b";
136
+ document.body.append(elementB);
137
+
138
+ const commit = vi.fn();
139
+ const selectionA = makeSelection("Dot A", elementA);
140
+ const selectionB = makeSelection("Dot B", elementB);
141
+
142
+ act(() => {
143
+ root.render(
144
+ React.createElement(Harness, { selection: selectionA, onPathOffsetCommit: commit }),
145
+ );
146
+ });
147
+
148
+ act(() => {
149
+ dispatchArrowRight();
150
+ });
151
+ expect(commit).not.toHaveBeenCalled();
152
+
153
+ // A genuine selection change (different id) must still flush immediately —
154
+ // only same-identity re-renders should be ignored.
155
+ act(() => {
156
+ root.render(
157
+ React.createElement(Harness, { selection: selectionB, onPathOffsetCommit: commit }),
158
+ );
159
+ });
160
+ expect(commit).toHaveBeenCalledTimes(1);
161
+ const [committedSelection, next] = commit.mock.calls[0] as [
162
+ DomEditSelection,
163
+ { x: number; y: number },
164
+ ];
165
+ expect(committedSelection.element).toBe(elementA);
166
+ expect(next.x).toBeCloseTo(CANVAS_NUDGE_STEP_PX);
167
+
168
+ act(() => root.unmount());
169
+ host.remove();
170
+ elementA.remove();
171
+ elementB.remove();
172
+ });
173
+
174
+ it("flushes the burst when switching between two id-less siblings that share a selector", () => {
175
+ const host = document.createElement("div");
176
+ document.body.append(host);
177
+ const root = createRoot(host);
178
+
179
+ // Two id-less siblings with the SAME selector — distinguished only by
180
+ // selectorIndex. Under the old `id ?? selector ?? label` key they shared
181
+ // one identity, so selecting B mid-burst didn't flush A and the next arrow
182
+ // kept moving A. The key now folds in selectorIndex, so they're distinct.
183
+ const elementA = document.createElement("div");
184
+ document.body.append(elementA);
185
+ const elementB = document.createElement("div");
186
+ document.body.append(elementB);
187
+
188
+ const commit = vi.fn();
189
+ const base = (label: string, element: HTMLElement) => ({
190
+ ...makeSelection(label, element),
191
+ id: undefined,
192
+ selector: "div.row",
193
+ });
194
+ const selectionA: DomEditSelection = { ...base("Row", elementA), selectorIndex: 0 };
195
+ const selectionB: DomEditSelection = { ...base("Row", elementB), selectorIndex: 1 };
196
+
197
+ act(() => {
198
+ root.render(
199
+ React.createElement(Harness, { selection: selectionA, onPathOffsetCommit: commit }),
200
+ );
201
+ });
202
+
203
+ act(() => {
204
+ dispatchArrowRight();
205
+ });
206
+ expect(commit).not.toHaveBeenCalled();
207
+
208
+ act(() => {
209
+ root.render(
210
+ React.createElement(Harness, { selection: selectionB, onPathOffsetCommit: commit }),
211
+ );
212
+ });
213
+
214
+ // The sibling switch must flush A's pending burst exactly once, for A.
215
+ expect(commit).toHaveBeenCalledTimes(1);
216
+ const [committedSelection, next] = commit.mock.calls[0] as [
217
+ DomEditSelection,
218
+ { x: number; y: number },
219
+ ];
220
+ expect(committedSelection.element).toBe(elementA);
221
+ expect(next.x).toBeCloseTo(CANVAS_NUDGE_STEP_PX);
222
+
223
+ act(() => root.unmount());
224
+ host.remove();
225
+ elementA.remove();
226
+ elementB.remove();
227
+ });
228
+ });