@hyperframes/studio 0.7.55 → 0.7.56

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (237) hide show
  1. package/dist/assets/{index-BXaqaVKt.js → index-BWnOxAiH.js} +1 -1
  2. package/dist/assets/{index-CPetwHFV.js → index-C4csZims.js} +1 -1
  3. package/dist/assets/index-CmVCjZjp.js +423 -0
  4. package/dist/assets/index-D-GyYi2d.css +1 -0
  5. package/dist/{chunk-SOTCF4DF.js → chunk-5QSIMBEJ.js} +2 -1
  6. package/dist/chunk-5QSIMBEJ.js.map +1 -0
  7. package/dist/{domEditingLayers-2ECJK24D.js → domEditingLayers-6LQGKPOI.js} +2 -2
  8. package/dist/index.d.ts +146 -144
  9. package/dist/index.html +2 -2
  10. package/dist/index.js +40414 -37680
  11. package/dist/index.js.map +1 -1
  12. package/package.json +7 -7
  13. package/src/App.tsx +106 -96
  14. package/src/components/EditorShell.tsx +253 -0
  15. package/src/components/StudioGlobalDragOverlay.tsx +1 -3
  16. package/src/components/StudioLeftSidebar.tsx +13 -3
  17. package/src/components/StudioRightPanel.tsx +8 -3
  18. package/src/components/TimelineToolbar.test.tsx +2 -2
  19. package/src/components/TimelineToolbar.tsx +179 -121
  20. package/src/components/editor/DomEditCropHandles.test.tsx +74 -3
  21. package/src/components/editor/DomEditCropHandles.tsx +64 -29
  22. package/src/components/editor/DomEditOverlay.test.ts +245 -279
  23. package/src/components/editor/DomEditOverlay.tsx +158 -126
  24. package/src/components/editor/DomEditOverlayHover.test.tsx +90 -0
  25. package/src/components/editor/DomEditRotateHandle.tsx +38 -18
  26. package/src/components/editor/DomEditSelectionChrome.test.tsx +80 -0
  27. package/src/components/editor/DomEditSelectionChrome.tsx +257 -0
  28. package/src/components/editor/LayersPanel.test.ts +66 -2
  29. package/src/components/editor/LayersPanel.tsx +43 -1
  30. package/src/components/editor/OffCanvasIndicators.test.tsx +34 -0
  31. package/src/components/editor/OffCanvasIndicators.tsx +49 -10
  32. package/src/components/editor/SnapGuideOverlay.tsx +28 -23
  33. package/src/components/editor/SnapToolbar.test.tsx +0 -1
  34. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +214 -0
  35. package/src/components/editor/anchoredResizeReleaseShift.test.ts +106 -10
  36. package/src/components/editor/domEditNudge.test.ts +80 -0
  37. package/src/components/editor/domEditNudge.ts +44 -0
  38. package/src/components/editor/domEditOverlayGeometry.test.ts +167 -1
  39. package/src/components/editor/domEditOverlayGeometry.ts +280 -20
  40. package/src/components/editor/domEditOverlayGestures.ts +44 -41
  41. package/src/components/editor/domEditOverlayStartGesture.ts +62 -2
  42. package/src/components/editor/domEditResizeLocal.test.ts +131 -0
  43. package/src/components/editor/domEditResizeLocal.ts +125 -0
  44. package/src/components/editor/domEditingDom.ts +1 -1
  45. package/src/components/editor/manualEditsDomPatches.test.ts +47 -0
  46. package/src/components/editor/manualOffsetDrag.ts +55 -12
  47. package/src/components/editor/offCanvasIndicatorGeometry.ts +31 -10
  48. package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +25 -1
  49. package/src/components/editor/resizeDraft.ts +108 -0
  50. package/src/components/editor/snapEngine.test.ts +12 -78
  51. package/src/components/editor/snapEngine.ts +13 -53
  52. package/src/components/editor/useDomEditNudge.test.tsx +228 -0
  53. package/src/components/editor/useDomEditNudge.ts +254 -0
  54. package/src/components/editor/useDomEditOverlayGestures.ts +115 -116
  55. package/src/components/editor/useDomEditOverlayRects.ts +9 -2
  56. package/src/components/nle/AssetPreviewOverlay.tsx +147 -0
  57. package/src/components/nle/NLEContext.test.ts +144 -0
  58. package/src/components/nle/NLEContext.tsx +328 -0
  59. package/src/components/nle/NLEPreview.tsx +1 -1
  60. package/src/components/nle/PreviewOverlays.tsx +240 -0
  61. package/src/components/nle/PreviewPane.tsx +163 -0
  62. package/src/components/nle/TimelinePane.test.ts +60 -0
  63. package/src/components/nle/TimelinePane.tsx +293 -0
  64. package/src/components/nle/TimelineResizeDivider.tsx +8 -2
  65. package/src/components/nle/useCompositionStack.test.tsx +44 -0
  66. package/src/components/nle/useCompositionStack.ts +17 -9
  67. package/src/components/nle/useTimelineEditCallbacks.ts +217 -0
  68. package/src/components/sidebar/AssetCard.tsx +326 -0
  69. package/src/components/sidebar/AssetContextMenu.tsx +15 -30
  70. package/src/components/sidebar/AssetsTab.test.ts +96 -0
  71. package/src/components/sidebar/AssetsTab.tsx +71 -206
  72. package/src/components/sidebar/AudioRow.tsx +39 -2
  73. package/src/components/sidebar/BlocksTab.tsx +8 -1
  74. package/src/components/sidebar/LeftSidebar.tsx +4 -1
  75. package/src/components/sidebar/assetHelpers.ts +29 -0
  76. package/src/contexts/DomEditContext.tsx +9 -0
  77. package/src/contexts/StudioContext.tsx +9 -8
  78. package/src/contexts/TimelineEditContext.tsx +3 -7
  79. package/src/hooks/deleteSelectedKeyframes.ts +35 -0
  80. package/src/hooks/domEditCommitTypes.ts +12 -1
  81. package/src/hooks/domSelectionTestHarness.ts +14 -0
  82. package/src/hooks/gestureTransaction.test.ts +311 -0
  83. package/src/hooks/gestureTransaction.ts +199 -0
  84. package/src/hooks/gsapDragCommit.test.ts +141 -41
  85. package/src/hooks/gsapDragCommit.ts +64 -74
  86. package/src/hooks/gsapDragStaticSetHelpers.ts +9 -21
  87. package/src/hooks/gsapResizeIntercept.test.ts +69 -0
  88. package/src/hooks/gsapResizeIntercept.ts +38 -10
  89. package/src/hooks/gsapRuntimeBridge.test.ts +49 -13
  90. package/src/hooks/gsapRuntimeBridge.ts +4 -4
  91. package/src/hooks/gsapScriptCommitTypes.ts +16 -5
  92. package/src/hooks/gsapShared.test.ts +19 -1
  93. package/src/hooks/gsapShared.ts +14 -0
  94. package/src/hooks/patchDocumentRootDuration.test.ts +71 -0
  95. package/src/hooks/timelineEditingGsap.ts +14 -0
  96. package/src/hooks/timelineEditingHelpers.test.ts +34 -1
  97. package/src/hooks/timelineEditingHelpers.ts +22 -48
  98. package/src/hooks/timelineMoveAdapter.test.ts +77 -0
  99. package/src/hooks/timelineMoveAdapter.ts +37 -0
  100. package/src/hooks/useAnimatedPropertyCommit.test.tsx +36 -8
  101. package/src/hooks/useAnimatedPropertyCommit.ts +31 -26
  102. package/src/hooks/useAppHotkeys.ts +16 -25
  103. package/src/hooks/useBlockHandlers.ts +55 -27
  104. package/src/hooks/useContextMenuDismiss.ts +31 -7
  105. package/src/hooks/useDomEditCommits.test.tsx +154 -1
  106. package/src/hooks/useDomEditCommits.ts +100 -3
  107. package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
  108. package/src/hooks/useDomEditSession.ts +2 -2
  109. package/src/hooks/useDomEditTextCommits.test.tsx +135 -0
  110. package/src/hooks/useDomEditTextCommits.ts +8 -3
  111. package/src/hooks/useDomEditWiring.ts +7 -9
  112. package/src/hooks/useDomGeometryCommits.ts +17 -2
  113. package/src/hooks/useDomSelection.test.ts +2 -36
  114. package/src/hooks/useDomSelection.ts +81 -51
  115. package/src/hooks/useDomSelectionSelectionGuards.test.ts +276 -0
  116. package/src/hooks/useElementLifecycleOps.test.tsx +258 -0
  117. package/src/hooks/useElementLifecycleOps.ts +40 -71
  118. package/src/hooks/useEnableKeyframes.test.ts +142 -1
  119. package/src/hooks/useEnableKeyframes.ts +73 -29
  120. package/src/hooks/useGestureCommit.test.tsx +130 -0
  121. package/src/hooks/useGestureCommit.ts +32 -8
  122. package/src/hooks/useGroupCommits.ts +7 -28
  123. package/src/hooks/useGsapAwareEditing.test.tsx +239 -0
  124. package/src/hooks/useGsapAwareEditing.ts +115 -20
  125. package/src/hooks/useGsapKeyframeOps.test.tsx +59 -0
  126. package/src/hooks/useGsapKeyframeOps.ts +38 -10
  127. package/src/hooks/useGsapScriptCommits.test.tsx +40 -1
  128. package/src/hooks/useGsapScriptCommits.ts +134 -50
  129. package/src/hooks/useGsapSelectionHandlers.ts +49 -16
  130. package/src/hooks/useMusicBeatAnalysis.ts +72 -36
  131. package/src/hooks/usePersistentEditHistory.test.ts +33 -0
  132. package/src/hooks/usePersistentEditHistory.ts +92 -58
  133. package/src/hooks/usePreviewPersistence.ts +25 -7
  134. package/src/hooks/useRazorSplit.history.test.tsx +148 -18
  135. package/src/hooks/useRazorSplit.test.ts +269 -0
  136. package/src/hooks/useRazorSplit.testHelpers.ts +64 -0
  137. package/src/hooks/useRazorSplit.ts +107 -36
  138. package/src/hooks/useRenderClipContent.ts +24 -6
  139. package/src/hooks/useStudioContextValue.ts +15 -5
  140. package/src/hooks/useStudioUrlState.ts +2 -4
  141. package/src/hooks/useTimelineEditing.test.tsx +20 -29
  142. package/src/hooks/useTimelineEditingTypes.ts +6 -0
  143. package/src/hooks/useTimelineGroupEditing.ts +18 -4
  144. package/src/index.ts +1 -1
  145. package/src/player/components/ImageThumbnail.test.tsx +173 -0
  146. package/src/player/components/ImageThumbnail.tsx +160 -0
  147. package/src/player/components/PlayheadIndicator.tsx +50 -5
  148. package/src/player/components/Timeline.test.ts +102 -19
  149. package/src/player/components/Timeline.tsx +194 -194
  150. package/src/player/components/TimelineCanvas.tsx +207 -521
  151. package/src/player/components/TimelineClip.tsx +4 -1
  152. package/src/player/components/TimelineClipDiamonds.tsx +9 -40
  153. package/src/player/components/TimelineEmptyState.tsx +3 -1
  154. package/src/player/components/TimelineLanes.tsx +487 -0
  155. package/src/player/components/TimelineOverlays.tsx +122 -0
  156. package/src/player/components/VideoThumbnail.test.tsx +152 -0
  157. package/src/player/components/VideoThumbnail.tsx +28 -7
  158. package/src/player/components/thumbnailUtils.test.ts +127 -0
  159. package/src/player/components/thumbnailUtils.ts +54 -0
  160. package/src/player/components/timelineCallbacks.ts +18 -18
  161. package/src/player/components/timelineClipChildren.tsx +39 -0
  162. package/src/player/components/timelineClipDragCommit.test.ts +943 -0
  163. package/src/player/components/timelineClipDragCommit.ts +401 -0
  164. package/src/player/components/timelineClipDragPreview.test.ts +144 -0
  165. package/src/player/components/timelineClipDragPreview.ts +323 -38
  166. package/src/player/components/timelineClipDragTypes.ts +65 -0
  167. package/src/player/components/timelineCollision.test.ts +42 -2
  168. package/src/player/components/timelineDragDrop.ts +69 -60
  169. package/src/player/components/timelineEditCapabilities.ts +52 -0
  170. package/src/player/components/timelineEditing.test.ts +1 -1
  171. package/src/player/components/timelineEditing.ts +76 -59
  172. package/src/player/components/timelineGroupEditing.test.ts +126 -0
  173. package/src/player/components/timelineGroupEditing.ts +133 -0
  174. package/src/player/components/timelineGroupResizeCommit.ts +54 -0
  175. package/src/player/components/timelineLayout.test.ts +95 -23
  176. package/src/player/components/timelineLayout.ts +120 -61
  177. package/src/player/components/timelineMarquee.test.ts +197 -0
  178. package/src/player/components/timelineMarquee.ts +112 -0
  179. package/src/player/components/timelineOptimisticRevision.ts +42 -0
  180. package/src/player/components/timelineTheme.ts +6 -1
  181. package/src/player/components/useResolvedTimelineEditCallbacks.ts +25 -3
  182. package/src/player/components/useTimelineClipDrag.resize.test.tsx +241 -0
  183. package/src/player/components/useTimelineClipDrag.ts +380 -386
  184. package/src/player/components/useTimelineEditPinning.ts +121 -0
  185. package/src/player/components/useTimelineGeometry.ts +129 -0
  186. package/src/player/components/useTimelinePlayhead.ts +4 -16
  187. package/src/player/components/useTimelineRangeSelection.ts +341 -50
  188. package/src/player/components/useTimelineStackingSync.test.tsx +67 -0
  189. package/src/player/components/useTimelineStackingSync.ts +83 -0
  190. package/src/player/hooks/useExpandedTimelineElements.ts +0 -3
  191. package/src/player/hooks/usePlaybackKeyboard.ts +5 -0
  192. package/src/player/hooks/useTimelinePlayer.ts +48 -53
  193. package/src/player/hooks/useTimelinePlayerLoop.ts +85 -0
  194. package/src/player/index.ts +3 -2
  195. package/src/player/lib/time.test.ts +19 -19
  196. package/src/player/lib/time.ts +6 -2
  197. package/src/player/lib/timelineDOM.test.ts +25 -98
  198. package/src/player/lib/timelineDOM.ts +12 -81
  199. package/src/player/lib/timelineElementHelpers.ts +54 -5
  200. package/src/player/lib/timelineIframeHelpers.ts +2 -0
  201. package/src/player/store/playerStore.ts +62 -3
  202. package/src/styles/studio.css +27 -0
  203. package/src/utils/assetPreviewStore.ts +33 -0
  204. package/src/utils/blockInstaller.ts +10 -2
  205. package/src/utils/editHistory.test.ts +35 -0
  206. package/src/utils/gsapSoftReload.test.ts +119 -1
  207. package/src/utils/gsapSoftReload.ts +153 -0
  208. package/src/utils/mediaTypes.ts +3 -2
  209. package/src/utils/resizeDebug.ts +55 -0
  210. package/src/utils/rootDuration.test.ts +90 -1
  211. package/src/utils/rootDuration.ts +76 -13
  212. package/src/utils/sdkCutover.ts +4 -0
  213. package/src/utils/studioFileHistory.ts +37 -7
  214. package/src/utils/studioHelpers.ts +62 -0
  215. package/src/utils/studioPreviewHelpers.test.ts +65 -8
  216. package/src/utils/studioPreviewHelpers.ts +10 -0
  217. package/src/utils/studioTelemetry.ts +4 -1
  218. package/src/utils/studioUrlState.test.ts +0 -1
  219. package/src/utils/timelineAssetDrop.ts +62 -0
  220. package/src/utils/timelineDiscovery.ts +0 -17
  221. package/dist/assets/index-BRwkMj0w.js +0 -423
  222. package/dist/assets/index-Dq7FEg0K.css +0 -1
  223. package/dist/chunk-SOTCF4DF.js.map +0 -1
  224. package/src/components/StudioPreviewArea.tsx +0 -500
  225. package/src/components/nle/NLELayout.test.ts +0 -12
  226. package/src/components/nle/NLELayout.tsx +0 -591
  227. package/src/player/components/TimelineLayerGutter.tsx +0 -61
  228. package/src/player/components/TimelineSelectionOverlays.tsx +0 -55
  229. package/src/player/components/timelineMarqueeSelection.test.ts +0 -87
  230. package/src/player/components/timelineSnapTargets.test.ts +0 -144
  231. package/src/player/components/timelineSnapTargets.ts +0 -164
  232. package/src/player/components/useTimelineClipDrag.test.tsx +0 -506
  233. package/src/player/components/useTimelineClipGroupDrag.ts +0 -417
  234. package/src/player/components/useTimelineMarqueeSelection.test.tsx +0 -231
  235. package/src/player/components/useTimelineMarqueeSelection.ts +0 -276
  236. package/src/utils/timelineDiscovery.test.ts +0 -90
  237. /package/dist/{domEditingLayers-2ECJK24D.js.map → domEditingLayers-6LQGKPOI.js.map} +0 -0
@@ -1,5 +1,4 @@
1
- import { memo, useEffect, useMemo, useRef, useState, type RefObject } from "react";
2
- import { getPreviewTargetFromPointer } from "../../utils/studioPreviewHelpers";
1
+ import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
3
2
  import { type DomEditSelection } from "./domEditing";
4
3
  import type { PreviewMouseDownOptions } from "../../hooks/usePreviewInteraction";
5
4
  import { useMarqueeGestures } from "./marqueeCommit";
@@ -16,17 +15,20 @@ import {
16
15
  import { useDomEditOverlayRects } from "./useDomEditOverlayRects";
17
16
  import { OffCanvasIndicators, type OffCanvasRect } from "./OffCanvasIndicators";
18
17
  import { createDomEditOverlayGestureHandlers } from "./useDomEditOverlayGestures";
18
+ import { useDomEditNudge } from "./useDomEditNudge";
19
19
  import { SnapGuideOverlay, type SnapGuidesState } from "./SnapGuideOverlay";
20
20
  import { GridOverlay } from "./GridOverlay";
21
21
  import type { GestureRecordingState } from "./GestureRecordControl";
22
- import { DomEditCropHandles } from "./DomEditCropHandles";
23
- import { DomEditRotateHandle } from "./DomEditRotateHandle";
22
+ import { DomEditGroupChrome, DomEditSelectionChrome } from "./DomEditSelectionChrome";
24
23
  import { hugRectForElement } from "./domEditOverlayCrop";
25
24
  import { useCropOverlay } from "../../hooks/useCropOverlay";
26
25
  import { readDomEditSelectionShapeStyles, resolveBoxChromeClass } from "./domEditOverlayShape";
27
26
  import { useDomEditCompositionRect } from "./useDomEditCompositionRect";
28
27
  import { useMountEffect } from "../../hooks/useMountEffect";
29
28
  import { startOffCanvasIndicatorRefresh } from "./offCanvasIndicatorRefresh";
29
+ import { CanvasContextMenu } from "./CanvasContextMenu";
30
+ import type { ZOrderPatch } from "./canvasContextMenuZOrder";
31
+ import { getPreviewTargetFromPointer } from "../../utils/studioPreviewHelpers";
30
32
 
31
33
  // Re-exports for external consumers — preserving existing import paths.
32
34
  export {
@@ -37,7 +39,6 @@ export {
37
39
  export {
38
40
  focusDomEditOverlayElement,
39
41
  hasDomEditRotationChanged,
40
- resolveDomEditResizeGesture,
41
42
  resolveDomEditRotationGesture,
42
43
  } from "./domEditOverlayGestures";
43
44
  export type { DomEditGroupPathOffsetCommit } from "./domEditOverlayGestures";
@@ -73,6 +74,8 @@ interface DomEditOverlayProps {
73
74
  onBoxSizeCommit: (
74
75
  selection: DomEditSelection,
75
76
  next: { width: number; height: number },
77
+ offset?: { x: number; y: number },
78
+ restore?: () => void,
76
79
  ) => Promise<void> | void;
77
80
  onRotationCommit: (selection: DomEditSelection, next: { angle: number }) => Promise<void> | void;
78
81
  onStyleCommit?: (property: string, value: string) => Promise<void> | void;
@@ -81,6 +84,19 @@ interface DomEditOverlayProps {
81
84
  recordingState?: GestureRecordingState;
82
85
  onToggleRecording?: () => void;
83
86
  onMarqueeSelect?: (selections: DomEditSelection[], additive: boolean) => void;
87
+ /**
88
+ * Delete the selected canvas element.
89
+ * Wire to handleDomEditElementDelete from useDomEditActionsContext —
90
+ * same handler the Delete/Backspace hotkey uses.
91
+ */
92
+ onDeleteSelection?: (selection: DomEditSelection) => void;
93
+ /**
94
+ * Called with the resolved z-order patch list after an optimistic DOM update.
95
+ * The patch list is tie-aware and may include sibling elements (see
96
+ * canvasContextMenuZOrder). Wire to handleDomZIndexReorderCommit from
97
+ * useDomEditActionsContext. See CanvasContextMenu.tsx module comment.
98
+ */
99
+ onApplyZIndex?: (selection: DomEditSelection, patches: ZOrderPatch[]) => void;
84
100
  }
85
101
 
86
102
  // fallow-ignore-next-line complexity
@@ -105,6 +121,8 @@ export const DomEditOverlay = memo(function DomEditOverlay({
105
121
  onRotationCommit,
106
122
  onStyleCommit,
107
123
  onMarqueeSelect,
124
+ onDeleteSelection,
125
+ onApplyZIndex,
108
126
  }: DomEditOverlayProps) {
109
127
  const overlayRef = useRef<HTMLDivElement | null>(null);
110
128
  const boxRef = useRef<HTMLDivElement | null>(null);
@@ -121,8 +139,31 @@ export const DomEditOverlay = memo(function DomEditOverlay({
121
139
  const snapGuidesRef = useRef<SnapGuidesState | null>(null);
122
140
  const rafPausedRef = useRef(false);
123
141
 
142
+ // Context menu state: position of the right-click that opened it.
143
+ // contextMenuSelection is the element the menu targets — captured at right-click
144
+ // time so the menu can open even before the React selection state settles.
145
+ const [contextMenu, setContextMenu] = useState<{
146
+ x: number;
147
+ y: number;
148
+ sel: DomEditSelection;
149
+ } | null>(null);
150
+
124
151
  const selectionRef = useRef(selection);
125
152
  selectionRef.current = selection;
153
+
154
+ // Close the context menu whenever the selection moves off the element the menu
155
+ // targets (a click that reselects elsewhere, a deselect, or a preview reload
156
+ // that rebuilds the selection). Without this the menu can linger — orphaned —
157
+ // over a stale target after the underlying element is gone. A right-click that
158
+ // OPENS the menu also selects its target, so the common open path keeps the
159
+ // menu (same element) rather than immediately dismissing it.
160
+ useEffect(() => {
161
+ if (!contextMenu) return;
162
+ if (!selection || selection.element !== contextMenu.sel.element) {
163
+ setContextMenu(null);
164
+ }
165
+ }, [selection, contextMenu]);
166
+
126
167
  const activeCompositionPathRef = useRef(activeCompositionPath);
127
168
  activeCompositionPathRef.current = activeCompositionPath;
128
169
  const groupSelectionsRef = useRef(groupSelections);
@@ -241,6 +282,23 @@ export const DomEditOverlay = memo(function DomEditOverlay({
241
282
  snapGuidesRef,
242
283
  });
243
284
 
285
+ // Arrow-key nudge (1px, Shift = 10px) — commits through the same
286
+ // path-offset callbacks as a drag, one undo entry per key burst.
287
+ const { flushNudge } = useDomEditNudge({
288
+ selection,
289
+ groupSelections,
290
+ allowCanvasMovement,
291
+ selectionRef,
292
+ overlayRectRef,
293
+ groupOverlayItemsRef,
294
+ gestureRef,
295
+ groupGestureRef,
296
+ blockedMoveRef,
297
+ onManualDragStartRef,
298
+ onPathOffsetCommitRef,
299
+ onGroupPathOffsetCommitRef,
300
+ });
301
+
244
302
  const marquee = useMarqueeGestures({
245
303
  iframeRef,
246
304
  overlayRef,
@@ -370,6 +428,38 @@ export const DomEditOverlay = memo(function DomEditOverlay({
370
428
  e.stopPropagation();
371
429
  };
372
430
 
431
+ // Right-click: select element first (if not already selected), then open menu.
432
+ const handleContextMenu = useCallback(
433
+ async (event: React.MouseEvent<HTMLDivElement>) => {
434
+ event.preventDefault();
435
+
436
+ // If no element is selected yet, resolve it from the pointer position first.
437
+ const currentSel = selectionRef.current;
438
+ let activeSel: DomEditSelection | null = currentSel;
439
+ if (!currentSel) {
440
+ const pointerEvent = event as unknown as React.PointerEvent<HTMLDivElement>;
441
+ const resolved = await onCanvasPointerMoveRef.current(pointerEvent);
442
+ if (!resolved) return; // Nothing under the cursor — skip menu.
443
+ onSelectionChangeRef.current(resolved, { revealPanel: true });
444
+ // Use `resolved` directly: React state (and therefore selectionRef) won't
445
+ // update synchronously after onSelectionChange — we'd be reading stale null.
446
+ activeSel = resolved;
447
+ } else {
448
+ // Check if the user right-clicked on an unselected element (hover target).
449
+ const hover = hoverSelectionRef.current;
450
+ if (hover && hover.element !== currentSel.element) {
451
+ onSelectionChangeRef.current(hover, { revealPanel: true });
452
+ activeSel = hover;
453
+ }
454
+ }
455
+
456
+ if (!activeSel) return;
457
+ setContextMenu({ x: event.clientX, y: event.clientY, sel: activeSel });
458
+ },
459
+ // eslint-disable-next-line react-hooks/exhaustive-deps
460
+ [],
461
+ );
462
+
373
463
  return (
374
464
  <div
375
465
  ref={overlayRef}
@@ -378,142 +468,59 @@ export const DomEditOverlay = memo(function DomEditOverlay({
378
468
  aria-label="Composition canvas"
379
469
  // Cursor follows marquee rect *state* (re-renders), not the mutable ref.
380
470
  style={marquee.marqueeRect ? { cursor: "crosshair" } : undefined}
381
- onPointerDownCapture={(event) =>
382
- focusDomEditOverlayElement(event.currentTarget as FocusableDomEditOverlay)
383
- }
471
+ onPointerDownCapture={(event) => {
472
+ // A pointer gesture supersedes a pending nudge burst — commit it first
473
+ // so the gesture's member snapshot starts from the nudged position.
474
+ flushNudge();
475
+ focusDomEditOverlayElement(event.currentTarget as FocusableDomEditOverlay);
476
+ }}
384
477
  onPointerDown={handleOverlayPointerDown}
385
478
  onMouseDown={handleOverlayMouseDown}
386
479
  onPointerMove={marquee.onPointerMove}
387
480
  onPointerLeave={() => onCanvasPointerLeaveRef.current()}
388
481
  onPointerUp={marquee.onPointerUp}
389
482
  onPointerCancel={marquee.onPointerCancel}
483
+ onContextMenu={handleContextMenu}
390
484
  >
391
485
  {hoverSelection && hoverRect && compRect.width > 0 && (
392
486
  <div
393
487
  aria-hidden="true"
394
488
  data-dom-edit-hover-box="true"
395
489
  className="pointer-events-none absolute rounded-md border border-studio-accent/80 shadow-[0_0_0_1px_rgba(60,230,172,0.25)]"
396
- style={hugRectForElement(hoverRect, hoverSelection.element)}
490
+ style={{
491
+ ...hugRectForElement(hoverRect, hoverSelection.element),
492
+ transform: hoverRect.angle ? `rotate(${hoverRect.angle}deg)` : undefined,
493
+ }}
397
494
  />
398
495
  )}
399
496
  {hasGroupSelection && groupOverlayItems.length > 1 && groupBounds && compRect.width > 0 && (
400
- <>
401
- {groupOverlayItems.map((item) => (
402
- <div
403
- key={item.key}
404
- aria-hidden="true"
405
- className="pointer-events-none absolute rounded-xl border border-studio-accent/70"
406
- style={{
407
- left: item.rect.left,
408
- top: item.rect.top,
409
- width: item.rect.width,
410
- height: item.rect.height,
411
- }}
412
- />
413
- ))}
414
- <div
415
- data-dom-edit-selection-box="true"
416
- className="pointer-events-auto absolute rounded-xl border border-studio-accent shadow-[0_0_0_1px_rgba(60,230,172,0.3)]"
417
- style={{
418
- left: groupBounds.left,
419
- top: groupBounds.top,
420
- width: groupBounds.width,
421
- height: groupBounds.height,
422
- cursor: allowCanvasMovement && groupCanMove ? "move" : "default",
423
- }}
424
- onPointerDown={(e) => {
425
- if (!allowCanvasMovement || !groupCanMove || e.shiftKey) return;
426
- gestures.startGroupDrag(e);
427
- }}
428
- onMouseDown={suppressBoxMouseDown}
429
- onClick={handleBoxClick}
430
- />
431
- </>
497
+ <DomEditGroupChrome
498
+ groupOverlayItems={groupOverlayItems}
499
+ groupBounds={groupBounds}
500
+ allowCanvasMovement={allowCanvasMovement}
501
+ groupCanMove={groupCanMove}
502
+ gestures={gestures}
503
+ onBoxMouseDown={suppressBoxMouseDown}
504
+ onBoxClick={handleBoxClick}
505
+ />
432
506
  )}
433
507
  {!hasGroupSelection && selection && overlayRect && compRect.width > 0 && (
434
- <>
435
- {allowCanvasMovement && selection.capabilities.canApplyManualRotation && (
436
- <DomEditRotateHandle
437
- overlayRect={overlayRect}
438
- cropOutlineInsetPx={cropOutlineInsetPx}
439
- onStartRotate={(e) => {
440
- e.stopPropagation();
441
- gestures.startGesture("rotate", e);
442
- }}
443
- />
444
- )}
445
- <div
446
- key={selectionKey}
447
- ref={boxRef}
448
- data-dom-edit-selection-box="true"
449
- className={`pointer-events-auto absolute rounded-md ${boxChromeClass}`}
450
- style={{
451
- left: overlayRect.left,
452
- top: overlayRect.top,
453
- width: overlayRect.width,
454
- height: overlayRect.height,
455
- clipPath: boxClipPath,
456
- cursor:
457
- allowCanvasMovement && selection.capabilities.canApplyManualOffset
458
- ? "move"
459
- : "default",
460
- }}
461
- onPointerDown={(e) => {
462
- if (!allowCanvasMovement || e.shiftKey) return;
463
- if (selection.capabilities.canApplyManualOffset) {
464
- gestures.startGesture("drag", e);
465
- return;
466
- }
467
- e.preventDefault();
468
- e.stopPropagation();
469
- e.currentTarget.setPointerCapture(e.pointerId);
470
- blockedMoveRef.current = {
471
- pointerId: e.pointerId,
472
- startX: e.clientX,
473
- startY: e.clientY,
474
- notified: false,
475
- };
476
- }}
477
- onMouseDown={suppressBoxMouseDown}
478
- onClick={handleBoxClick}
479
- >
480
- {cropOutlineInsetPx && (
481
- <div
482
- className="pointer-events-none absolute rounded-md border border-studio-accent/80 shadow-[0_0_0_1px_rgba(60,230,172,0.25)]"
483
- style={{
484
- left: cropOutlineInsetPx.left,
485
- top: cropOutlineInsetPx.top,
486
- right: cropOutlineInsetPx.right,
487
- bottom: cropOutlineInsetPx.bottom,
488
- }}
489
- />
490
- )}
491
- {allowCanvasMovement && selection.capabilities.canApplyManualSize && (
492
- <div
493
- className="absolute -right-1.5 -bottom-1.5 w-3 h-3 rounded-sm bg-studio-accent border border-studio-accent/60"
494
- style={{
495
- cursor: "se-resize",
496
- touchAction: "none",
497
- ...(cropOutlineInsetPx && {
498
- right: cropOutlineInsetPx.right - 6,
499
- bottom: cropOutlineInsetPx.bottom - 6,
500
- }),
501
- }}
502
- onPointerDown={(e) => {
503
- e.stopPropagation();
504
- gestures.startGesture("resize", e);
505
- }}
506
- />
507
- )}
508
- </div>
509
- {selection.capabilities.canCrop && groupSelections.length <= 1 && (
510
- <DomEditCropHandles
511
- selection={selection}
512
- overlayRect={overlayRect}
513
- onStyleCommit={onStyleCommitRef.current}
514
- />
515
- )}
516
- </>
508
+ <DomEditSelectionChrome
509
+ selection={selection}
510
+ overlayRect={overlayRect}
511
+ allowCanvasMovement={allowCanvasMovement}
512
+ cropOutlineInsetPx={cropOutlineInsetPx ?? undefined}
513
+ boxRef={boxRef}
514
+ boxChromeClass={boxChromeClass}
515
+ boxClipPath={boxClipPath}
516
+ selectionKey={selectionKey}
517
+ groupSelectionCount={groupSelections.length}
518
+ blockedMoveRef={blockedMoveRef}
519
+ gestures={gestures}
520
+ onStyleCommit={onStyleCommitRef.current}
521
+ onBoxMouseDown={suppressBoxMouseDown}
522
+ onBoxClick={handleBoxClick}
523
+ />
517
524
  )}
518
525
  {childRects.length > 0 &&
519
526
  compRect.width > 0 &&
@@ -539,6 +546,29 @@ export const DomEditOverlay = memo(function DomEditOverlay({
539
546
  onSelectionChangeRef={onSelectionChangeRef}
540
547
  />
541
548
  <MarqueeOverlay candidateRects={marquee.candidateRects} marqueeRect={marquee.marqueeRect} />
549
+ {contextMenu && (
550
+ <CanvasContextMenu
551
+ x={contextMenu.x}
552
+ y={contextMenu.y}
553
+ selection={contextMenu.sel}
554
+ onClose={() => setContextMenu(null)}
555
+ onDelete={
556
+ onDeleteSelection
557
+ ? (sel) => {
558
+ setContextMenu(null);
559
+ onDeleteSelection(sel);
560
+ }
561
+ : undefined
562
+ }
563
+ onApplyZIndex={
564
+ onApplyZIndex
565
+ ? (patches) => {
566
+ onApplyZIndex(contextMenu.sel, patches);
567
+ }
568
+ : undefined
569
+ }
570
+ />
571
+ )}
542
572
  <GridOverlay
543
573
  visible={gridVisible}
544
574
  spacing={gridSpacing}
@@ -551,8 +581,10 @@ export const DomEditOverlay = memo(function DomEditOverlay({
551
581
  />
552
582
  <SnapGuideOverlay
553
583
  snapGuidesRef={snapGuidesRef}
554
- overlayWidth={compRect.width}
555
- overlayHeight={compRect.height}
584
+ compositionLeft={compRect.left}
585
+ compositionTop={compRect.top}
586
+ compositionWidth={compRect.width}
587
+ compositionHeight={compRect.height}
556
588
  />
557
589
  </div>
558
590
  );
@@ -0,0 +1,90 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { expect, it, vi } from "vitest";
6
+ import type { DomEditSelection } from "./domEditing";
7
+ import { DomEditOverlay } from "./DomEditOverlay";
8
+
9
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
10
+
11
+ const hoverState = vi.hoisted(() => ({ angle: 0 }));
12
+
13
+ vi.mock("./useDomEditOverlayRects", () => ({
14
+ useDomEditOverlayRects: ({ hoverSelectionRef }: { hoverSelectionRef: { current: unknown } }) => ({
15
+ overlayRect: null,
16
+ overlayRectRef: { current: null },
17
+ setOverlayRect: () => undefined,
18
+ hoverRect: hoverSelectionRef.current
19
+ ? {
20
+ left: 20,
21
+ top: 30,
22
+ width: 100,
23
+ height: 40,
24
+ editScaleX: 1,
25
+ editScaleY: 1,
26
+ angle: hoverState.angle,
27
+ }
28
+ : null,
29
+ groupOverlayItems: [],
30
+ groupOverlayItemsRef: { current: [] },
31
+ setGroupOverlayItems: () => undefined,
32
+ childRects: [],
33
+ }),
34
+ }));
35
+
36
+ vi.mock("./useDomEditCompositionRect", () => ({
37
+ useDomEditCompositionRect: () => ({
38
+ left: 0,
39
+ top: 0,
40
+ width: 800,
41
+ height: 450,
42
+ scaleX: 1,
43
+ scaleY: 1,
44
+ }),
45
+ }));
46
+
47
+ vi.mock("./offCanvasIndicatorRefresh", () => ({
48
+ startOffCanvasIndicatorRefresh: () => () => undefined,
49
+ }));
50
+
51
+ function renderHover(angle: number): string {
52
+ hoverState.angle = angle;
53
+ const host = document.createElement("div");
54
+ document.body.append(host);
55
+ const root = createRoot(host);
56
+ const element = document.createElement("div");
57
+ const hoverSelection = { element } as unknown as DomEditSelection;
58
+ act(() => {
59
+ root.render(
60
+ <DomEditOverlay
61
+ iframeRef={{ current: document.createElement("iframe") }}
62
+ activeCompositionPath={null}
63
+ selection={null}
64
+ hoverSelection={hoverSelection}
65
+ onCanvasMouseDown={() => undefined}
66
+ onCanvasPointerMove={() => Promise.resolve(null)}
67
+ onCanvasPointerLeave={() => undefined}
68
+ onSelectionChange={() => undefined}
69
+ onBlockedMove={() => undefined}
70
+ onPathOffsetCommit={() => undefined}
71
+ onGroupPathOffsetCommit={() => undefined}
72
+ onBoxSizeCommit={() => undefined}
73
+ onRotationCommit={() => undefined}
74
+ />,
75
+ );
76
+ });
77
+ const box = host.querySelector<HTMLElement>('[data-dom-edit-hover-box="true"]');
78
+ const transform = box?.style.transform ?? "";
79
+ act(() => root.unmount());
80
+ host.remove();
81
+ return transform;
82
+ }
83
+
84
+ it("rotates the hover box with the element", () => {
85
+ expect(renderHover(30)).toBe("rotate(30deg)");
86
+ });
87
+
88
+ it("leaves the hover box untransformed at angle zero", () => {
89
+ expect(renderHover(0)).toBe("");
90
+ });
@@ -1,8 +1,12 @@
1
1
  import type { PointerEvent as ReactPointerEvent } from "react";
2
2
  import type { OverlayRect } from "./domEditOverlayGeometry";
3
3
 
4
- /** Rotate grab-handle above the selection. Anchors to the crop outline when
5
- * the element is cropped so it stays next to what's visible on screen. */
4
+ /** Rotate handle below the selection: an attached circular-arrows icon chip
5
+ * (no connecting stem). Anchors to the crop outline when the element is
6
+ * cropped so it stays next to what's visible on screen. Presentation only —
7
+ * the rotation gesture measures pointer angles from the element CENTER
8
+ * (resolveDomEditRotationGesture), so the handle position doesn't affect the
9
+ * math. Sits 12px below the bbox, past the bottom crop handle's hit strip. */
6
10
  export function DomEditRotateHandle({
7
11
  overlayRect,
8
12
  cropOutlineInsetPx,
@@ -15,27 +19,43 @@ export function DomEditRotateHandle({
15
19
  const inset = cropOutlineInsetPx ?? { top: 0, right: 0, bottom: 0, left: 0 };
16
20
  const visibleLeft = overlayRect.left + inset.left;
17
21
  const visibleWidth = Math.max(0, overlayRect.width - inset.left - inset.right);
18
- const visibleTop = overlayRect.top + inset.top;
22
+ const visibleBottom = overlayRect.top + overlayRect.height - inset.bottom;
19
23
  return (
20
- <div
21
- className="pointer-events-none absolute"
24
+ <button
25
+ type="button"
26
+ className="pointer-events-auto absolute flex items-center justify-center border-0 bg-transparent p-0"
22
27
  style={{
23
28
  left: visibleLeft + visibleWidth / 2,
24
- top: visibleTop - 34,
25
- width: 28,
26
- height: 34,
29
+ top: visibleBottom + 12,
30
+ width: 22,
31
+ height: 22,
27
32
  transform: "translateX(-50%)",
33
+ touchAction: "none",
34
+ // Closed-hand grab cursor: this handle is grabbed and dragged to rotate.
35
+ cursor: "grabbing",
28
36
  }}
37
+ title="Rotate"
38
+ aria-label="Rotate selection"
39
+ onPointerDown={onStartRotate}
29
40
  >
30
- <div className="absolute left-1/2 top-3 bottom-0 w-px -translate-x-1/2 bg-studio-accent/60" />
31
- <button
32
- type="button"
33
- className="pointer-events-auto absolute left-1/2 top-0 h-3 w-3 -translate-x-1/2 rounded-full border border-studio-accent bg-studio-accent p-0 shadow-[0_0_0_2px_rgba(60,230,172,0.18)]"
34
- style={{ cursor: "grab", touchAction: "none" }}
35
- title="Rotate"
36
- aria-label="Rotate selection"
37
- onPointerDown={onStartRotate}
38
- />
39
- </div>
41
+ <span className="pointer-events-none flex h-[18px] w-[18px] items-center justify-center rounded-full border border-studio-accent/70 bg-studio-surface text-studio-accent shadow-[0_0_3px_rgba(0,0,0,0.45)]">
42
+ <svg
43
+ width="11"
44
+ height="11"
45
+ viewBox="0 0 24 24"
46
+ fill="none"
47
+ stroke="currentColor"
48
+ strokeWidth="2.5"
49
+ strokeLinecap="round"
50
+ strokeLinejoin="round"
51
+ aria-hidden="true"
52
+ >
53
+ <path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" />
54
+ <path d="M21 3v5h-5" />
55
+ <path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" />
56
+ <path d="M8 16H3v5" />
57
+ </svg>
58
+ </span>
59
+ </button>
40
60
  );
41
61
  }
@@ -0,0 +1,80 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act, createRef } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { describe, expect, it, vi } from "vitest";
6
+ import type { DomEditSelection } from "./domEditing";
7
+ import { DomEditSelectionChrome } from "./DomEditSelectionChrome";
8
+
9
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
10
+
11
+ describe("DomEditSelectionChrome crop composition", () => {
12
+ it("places rotated crop UI in exactly one oriented coordinate plane", () => {
13
+ const element = document.createElement("div");
14
+ element.id = "clip";
15
+ element.style.clipPath = "inset(10px)";
16
+ Object.defineProperties(element, {
17
+ offsetWidth: { value: 200 },
18
+ offsetHeight: { value: 100 },
19
+ });
20
+ document.body.append(element);
21
+ vi.spyOn(window, "getComputedStyle").mockReturnValue({
22
+ clipPath: "inset(10px)",
23
+ transform: "matrix(0.8660254, 0.5, -0.5, 0.8660254, 0, 0)",
24
+ } as CSSStyleDeclaration);
25
+ const selection = {
26
+ element,
27
+ id: "clip",
28
+ selector: "#clip",
29
+ capabilities: {
30
+ canCrop: true,
31
+ canApplyManualOffset: true,
32
+ canApplyManualSize: true,
33
+ canApplyManualRotation: true,
34
+ },
35
+ } as unknown as DomEditSelection;
36
+ const host = document.createElement("div");
37
+ document.body.append(host);
38
+ const root = createRoot(host);
39
+ act(() => {
40
+ root.render(
41
+ <DomEditSelectionChrome
42
+ selection={selection}
43
+ overlayRect={{
44
+ left: 100,
45
+ top: 50,
46
+ width: 220,
47
+ height: 130,
48
+ editScaleX: 1,
49
+ editScaleY: 1,
50
+ angle: 30,
51
+ }}
52
+ allowCanvasMovement={true}
53
+ boxRef={createRef()}
54
+ boxChromeClass=""
55
+ boxClipPath={undefined}
56
+ selectionKey="clip"
57
+ groupSelectionCount={0}
58
+ blockedMoveRef={createRef()}
59
+ gestures={{ startGesture: vi.fn() } as never}
60
+ onStyleCommit={vi.fn()}
61
+ onBoxMouseDown={vi.fn()}
62
+ onBoxClick={vi.fn()}
63
+ />,
64
+ );
65
+ });
66
+
67
+ const cropFrame = host.querySelector<HTMLElement>("[data-dom-edit-crop-frame]")!;
68
+ const rotations: string[] = [];
69
+ for (
70
+ let node: HTMLElement | null = cropFrame;
71
+ node && node !== host;
72
+ node = node.parentElement
73
+ ) {
74
+ if (node.style.transform.includes("rotate(")) rotations.push(node.style.transform);
75
+ }
76
+ expect(rotations).toHaveLength(1);
77
+ expect(Number.parseFloat(rotations[0]!.slice("rotate(".length))).toBeCloseTo(30, 5);
78
+ act(() => root.unmount());
79
+ });
80
+ });