@hyperframes/studio 0.7.54 → 0.7.56

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (262) hide show
  1. package/dist/assets/{index-uBY329wb.js → index-BWnOxAiH.js} +1 -1
  2. package/dist/assets/{index-CMHYjEZ5.js → index-C4csZims.js} +1 -1
  3. package/dist/assets/index-CmVCjZjp.js +423 -0
  4. package/dist/assets/index-D-GyYi2d.css +1 -0
  5. package/dist/{chunk-SOTCF4DF.js → chunk-5QSIMBEJ.js} +2 -1
  6. package/dist/chunk-5QSIMBEJ.js.map +1 -0
  7. package/dist/{domEditingLayers-2ECJK24D.js → domEditingLayers-6LQGKPOI.js} +2 -2
  8. package/dist/index.d.ts +146 -144
  9. package/dist/index.html +2 -2
  10. package/dist/index.js +40582 -37803
  11. package/dist/index.js.map +1 -1
  12. package/package.json +7 -7
  13. package/src/App.tsx +106 -96
  14. package/src/components/EditorShell.tsx +253 -0
  15. package/src/components/StudioGlobalDragOverlay.tsx +1 -3
  16. package/src/components/StudioLeftSidebar.tsx +13 -3
  17. package/src/components/StudioRightPanel.tsx +8 -3
  18. package/src/components/TimelineToolbar.test.tsx +2 -2
  19. package/src/components/TimelineToolbar.tsx +179 -121
  20. package/src/components/editor/CanvasContextMenu.test.tsx +115 -0
  21. package/src/components/editor/CanvasContextMenu.tsx +198 -0
  22. package/src/components/editor/DomEditCropHandles.test.tsx +74 -3
  23. package/src/components/editor/DomEditCropHandles.tsx +64 -29
  24. package/src/components/editor/DomEditOverlay.test.ts +245 -279
  25. package/src/components/editor/DomEditOverlay.tsx +158 -126
  26. package/src/components/editor/DomEditOverlayHover.test.tsx +90 -0
  27. package/src/components/editor/DomEditRotateHandle.tsx +38 -18
  28. package/src/components/editor/DomEditSelectionChrome.test.tsx +80 -0
  29. package/src/components/editor/DomEditSelectionChrome.tsx +257 -0
  30. package/src/components/editor/LayersPanel.test.ts +66 -2
  31. package/src/components/editor/LayersPanel.tsx +43 -1
  32. package/src/components/editor/OffCanvasIndicators.test.tsx +34 -0
  33. package/src/components/editor/OffCanvasIndicators.tsx +49 -10
  34. package/src/components/editor/SnapGuideOverlay.tsx +28 -23
  35. package/src/components/editor/SnapToolbar.test.tsx +0 -1
  36. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +214 -0
  37. package/src/components/editor/anchoredResizeReleaseShift.test.ts +208 -0
  38. package/src/components/editor/canvasContextMenuZOrder.test.ts +435 -0
  39. package/src/components/editor/canvasContextMenuZOrder.ts +352 -0
  40. package/src/components/editor/domEditNudge.test.ts +80 -0
  41. package/src/components/editor/domEditNudge.ts +44 -0
  42. package/src/components/editor/domEditOverlayGeometry.test.ts +167 -1
  43. package/src/components/editor/domEditOverlayGeometry.ts +280 -20
  44. package/src/components/editor/domEditOverlayGestures.ts +44 -41
  45. package/src/components/editor/domEditOverlayStartGesture.ts +62 -2
  46. package/src/components/editor/domEditResizeLocal.test.ts +131 -0
  47. package/src/components/editor/domEditResizeLocal.ts +125 -0
  48. package/src/components/editor/domEditingDom.ts +1 -1
  49. package/src/components/editor/manualEditsDomPatches.test.ts +47 -0
  50. package/src/components/editor/manualOffsetDrag.ts +55 -12
  51. package/src/components/editor/offCanvasIndicatorGeometry.ts +31 -10
  52. package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +25 -1
  53. package/src/components/editor/resizeDraft.ts +108 -0
  54. package/src/components/editor/snapEngine.test.ts +12 -78
  55. package/src/components/editor/snapEngine.ts +13 -53
  56. package/src/components/editor/useDomEditNudge.test.tsx +228 -0
  57. package/src/components/editor/useDomEditNudge.ts +254 -0
  58. package/src/components/editor/useDomEditOverlayGestures.ts +115 -116
  59. package/src/components/editor/useDomEditOverlayRects.ts +9 -2
  60. package/src/components/nle/AssetPreviewOverlay.tsx +147 -0
  61. package/src/components/nle/NLEContext.test.ts +144 -0
  62. package/src/components/nle/NLEContext.tsx +328 -0
  63. package/src/components/nle/NLEPreview.tsx +1 -1
  64. package/src/components/nle/PreviewOverlays.tsx +240 -0
  65. package/src/components/nle/PreviewPane.tsx +163 -0
  66. package/src/components/nle/TimelinePane.test.ts +60 -0
  67. package/src/components/nle/TimelinePane.tsx +293 -0
  68. package/src/components/nle/TimelineResizeDivider.tsx +8 -2
  69. package/src/components/nle/useCompositionStack.test.tsx +44 -0
  70. package/src/components/nle/useCompositionStack.ts +17 -9
  71. package/src/components/nle/useTimelineEditCallbacks.ts +217 -0
  72. package/src/components/sidebar/AssetCard.tsx +326 -0
  73. package/src/components/sidebar/AssetContextMenu.tsx +15 -30
  74. package/src/components/sidebar/AssetsTab.test.ts +96 -0
  75. package/src/components/sidebar/AssetsTab.tsx +71 -206
  76. package/src/components/sidebar/AudioRow.tsx +39 -2
  77. package/src/components/sidebar/BlocksTab.tsx +8 -1
  78. package/src/components/sidebar/LeftSidebar.tsx +4 -1
  79. package/src/components/sidebar/assetHelpers.ts +29 -0
  80. package/src/contexts/DomEditContext.tsx +9 -0
  81. package/src/contexts/StudioContext.tsx +9 -8
  82. package/src/contexts/TimelineEditContext.tsx +3 -7
  83. package/src/hooks/deleteSelectedKeyframes.ts +35 -0
  84. package/src/hooks/domEditCommitTypes.ts +12 -1
  85. package/src/hooks/domSelectionTestHarness.ts +14 -0
  86. package/src/hooks/gestureTransaction.test.ts +311 -0
  87. package/src/hooks/gestureTransaction.ts +199 -0
  88. package/src/hooks/gsapDragCommit.test.ts +141 -41
  89. package/src/hooks/gsapDragCommit.ts +64 -74
  90. package/src/hooks/gsapDragStaticSetHelpers.ts +9 -21
  91. package/src/hooks/gsapResizeIntercept.test.ts +69 -0
  92. package/src/hooks/gsapResizeIntercept.ts +38 -10
  93. package/src/hooks/gsapRuntimeBridge.test.ts +49 -13
  94. package/src/hooks/gsapRuntimeBridge.ts +4 -4
  95. package/src/hooks/gsapScriptCommitTypes.ts +16 -5
  96. package/src/hooks/gsapShared.test.ts +19 -1
  97. package/src/hooks/gsapShared.ts +14 -0
  98. package/src/hooks/patchDocumentRootDuration.test.ts +71 -0
  99. package/src/hooks/timelineEditingGsap.ts +14 -0
  100. package/src/hooks/timelineEditingHelpers.test.ts +34 -1
  101. package/src/hooks/timelineEditingHelpers.ts +22 -48
  102. package/src/hooks/timelineMoveAdapter.test.ts +77 -0
  103. package/src/hooks/timelineMoveAdapter.ts +37 -0
  104. package/src/hooks/useAnimatedPropertyCommit.test.tsx +36 -8
  105. package/src/hooks/useAnimatedPropertyCommit.ts +31 -26
  106. package/src/hooks/useAppHotkeys.ts +16 -25
  107. package/src/hooks/useBlockHandlers.ts +55 -27
  108. package/src/hooks/useContextMenuDismiss.ts +31 -7
  109. package/src/hooks/useDomEditCommits.test.tsx +154 -1
  110. package/src/hooks/useDomEditCommits.ts +100 -3
  111. package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
  112. package/src/hooks/useDomEditSession.ts +2 -2
  113. package/src/hooks/useDomEditTextCommits.test.tsx +135 -0
  114. package/src/hooks/useDomEditTextCommits.ts +8 -3
  115. package/src/hooks/useDomEditWiring.ts +7 -9
  116. package/src/hooks/useDomGeometryCommits.ts +17 -2
  117. package/src/hooks/useDomSelection.test.ts +2 -36
  118. package/src/hooks/useDomSelection.ts +81 -51
  119. package/src/hooks/useDomSelectionSelectionGuards.test.ts +276 -0
  120. package/src/hooks/useElementLifecycleOps.test.tsx +258 -0
  121. package/src/hooks/useElementLifecycleOps.ts +40 -71
  122. package/src/hooks/useEnableKeyframes.test.ts +142 -1
  123. package/src/hooks/useEnableKeyframes.ts +73 -29
  124. package/src/hooks/useGestureCommit.test.tsx +130 -0
  125. package/src/hooks/useGestureCommit.ts +32 -8
  126. package/src/hooks/useGroupCommits.ts +7 -28
  127. package/src/hooks/useGsapAwareEditing.test.tsx +239 -0
  128. package/src/hooks/useGsapAwareEditing.ts +115 -20
  129. package/src/hooks/useGsapKeyframeOps.test.tsx +59 -0
  130. package/src/hooks/useGsapKeyframeOps.ts +38 -10
  131. package/src/hooks/useGsapScriptCommits.test.tsx +40 -1
  132. package/src/hooks/useGsapScriptCommits.ts +134 -50
  133. package/src/hooks/useGsapSelectionHandlers.ts +49 -16
  134. package/src/hooks/useMusicBeatAnalysis.ts +72 -36
  135. package/src/hooks/usePersistentEditHistory.test.ts +33 -0
  136. package/src/hooks/usePersistentEditHistory.ts +92 -58
  137. package/src/hooks/usePreviewPersistence.ts +25 -7
  138. package/src/hooks/useRazorSplit.history.test.tsx +303 -0
  139. package/src/hooks/useRazorSplit.test.ts +269 -0
  140. package/src/hooks/useRazorSplit.testHelpers.ts +64 -0
  141. package/src/hooks/useRazorSplit.ts +107 -36
  142. package/src/hooks/useRenderClipContent.ts +24 -6
  143. package/src/hooks/useStudioContextValue.ts +15 -5
  144. package/src/hooks/useStudioUrlState.ts +2 -4
  145. package/src/hooks/useTimelineEditing.test.tsx +20 -29
  146. package/src/hooks/useTimelineEditingTypes.ts +6 -0
  147. package/src/hooks/useTimelineGroupEditing.ts +18 -4
  148. package/src/index.ts +1 -1
  149. package/src/player/components/ImageThumbnail.test.tsx +173 -0
  150. package/src/player/components/ImageThumbnail.tsx +160 -0
  151. package/src/player/components/PlayheadIndicator.tsx +50 -5
  152. package/src/player/components/Timeline.test.ts +102 -19
  153. package/src/player/components/Timeline.tsx +194 -194
  154. package/src/player/components/TimelineCanvas.tsx +207 -521
  155. package/src/player/components/TimelineClip.tsx +4 -1
  156. package/src/player/components/TimelineClipDiamonds.tsx +9 -40
  157. package/src/player/components/TimelineEmptyState.tsx +3 -1
  158. package/src/player/components/TimelineLanes.tsx +487 -0
  159. package/src/player/components/TimelineOverlays.tsx +122 -0
  160. package/src/player/components/VideoThumbnail.test.tsx +152 -0
  161. package/src/player/components/VideoThumbnail.tsx +28 -7
  162. package/src/player/components/thumbnailUtils.test.ts +127 -0
  163. package/src/player/components/thumbnailUtils.ts +54 -0
  164. package/src/player/components/timelineCallbacks.ts +18 -18
  165. package/src/player/components/timelineClipChildren.tsx +39 -0
  166. package/src/player/components/timelineClipDragCommit.test.ts +943 -0
  167. package/src/player/components/timelineClipDragCommit.ts +401 -0
  168. package/src/player/components/timelineClipDragPreview.test.ts +144 -0
  169. package/src/player/components/timelineClipDragPreview.ts +323 -38
  170. package/src/player/components/timelineClipDragTypes.ts +65 -0
  171. package/src/player/components/timelineCollision.test.ts +477 -0
  172. package/src/player/components/timelineCollision.ts +263 -0
  173. package/src/player/components/timelineDragDrop.ts +69 -60
  174. package/src/player/components/timelineEditCapabilities.ts +52 -0
  175. package/src/player/components/timelineEditing.test.ts +1 -1
  176. package/src/player/components/timelineEditing.ts +76 -59
  177. package/src/player/components/timelineGroupEditing.test.ts +126 -0
  178. package/src/player/components/timelineGroupEditing.ts +133 -0
  179. package/src/player/components/timelineGroupResizeCommit.ts +54 -0
  180. package/src/player/components/timelineLayout.test.ts +95 -23
  181. package/src/player/components/timelineLayout.ts +120 -61
  182. package/src/player/components/timelineMarquee.test.ts +197 -0
  183. package/src/player/components/timelineMarquee.ts +112 -0
  184. package/src/player/components/timelineMultiDragPreview.test.ts +127 -0
  185. package/src/player/components/timelineMultiDragPreview.ts +106 -0
  186. package/src/player/components/timelineOptimisticRevision.ts +42 -0
  187. package/src/player/components/timelineSnapping.test.ts +134 -0
  188. package/src/player/components/timelineSnapping.ts +110 -0
  189. package/src/player/components/timelineStackingSync.test.ts +244 -0
  190. package/src/player/components/timelineStackingSync.ts +331 -0
  191. package/src/player/components/timelineTheme.ts +6 -1
  192. package/src/player/components/timelineZones.test.ts +425 -0
  193. package/src/player/components/timelineZones.ts +198 -0
  194. package/src/player/components/timelineZoom.test.ts +67 -0
  195. package/src/player/components/timelineZoom.ts +51 -0
  196. package/src/player/components/useResolvedTimelineEditCallbacks.ts +25 -3
  197. package/src/player/components/useTimelineClipDrag.resize.test.tsx +241 -0
  198. package/src/player/components/useTimelineClipDrag.ts +380 -386
  199. package/src/player/components/useTimelineEditPinning.ts +121 -0
  200. package/src/player/components/useTimelineGeometry.ts +129 -0
  201. package/src/player/components/useTimelinePlayhead.ts +4 -16
  202. package/src/player/components/useTimelineRangeSelection.ts +341 -50
  203. package/src/player/components/useTimelineStackingSync.test.tsx +67 -0
  204. package/src/player/components/useTimelineStackingSync.ts +83 -0
  205. package/src/player/hooks/useExpandedTimelineElements.ts +0 -3
  206. package/src/player/hooks/usePlaybackKeyboard.ts +5 -0
  207. package/src/player/hooks/useTimelinePlayer.ts +48 -53
  208. package/src/player/hooks/useTimelinePlayerLoop.ts +85 -0
  209. package/src/player/hooks/useTimelineSyncCallbacks.test.ts +219 -0
  210. package/src/player/hooks/useTimelineSyncCallbacks.ts +104 -4
  211. package/src/player/index.ts +3 -2
  212. package/src/player/lib/time.test.ts +19 -19
  213. package/src/player/lib/time.ts +6 -2
  214. package/src/player/lib/timelineDOM.test.ts +25 -98
  215. package/src/player/lib/timelineDOM.ts +12 -81
  216. package/src/player/lib/timelineElementHelpers.ts +54 -5
  217. package/src/player/lib/timelineIframeHelpers.ts +2 -0
  218. package/src/player/store/playerStore.ts +62 -3
  219. package/src/styles/studio.css +27 -0
  220. package/src/utils/assetClickBehavior.test.ts +104 -0
  221. package/src/utils/assetClickBehavior.ts +75 -0
  222. package/src/utils/assetPreviewStore.ts +33 -0
  223. package/src/utils/blockInstaller.ts +10 -2
  224. package/src/utils/canvasNudgeGate.test.ts +31 -0
  225. package/src/utils/canvasNudgeGate.ts +32 -0
  226. package/src/utils/editHistory.test.ts +35 -0
  227. package/src/utils/gsapSoftReload.test.ts +119 -1
  228. package/src/utils/gsapSoftReload.ts +153 -0
  229. package/src/utils/mediaTypes.ts +3 -2
  230. package/src/utils/resizeDebug.ts +55 -0
  231. package/src/utils/rootDuration.test.ts +90 -1
  232. package/src/utils/rootDuration.ts +76 -13
  233. package/src/utils/sdkCutover.ts +4 -0
  234. package/src/utils/studioFileHistory.ts +37 -7
  235. package/src/utils/studioHelpers.ts +62 -0
  236. package/src/utils/studioPreviewHelpers.test.ts +65 -8
  237. package/src/utils/studioPreviewHelpers.ts +10 -0
  238. package/src/utils/studioTelemetry.ts +4 -1
  239. package/src/utils/studioUiPreferences.test.ts +38 -0
  240. package/src/utils/studioUiPreferences.ts +27 -0
  241. package/src/utils/studioUrlState.test.ts +0 -1
  242. package/src/utils/timelineAssetDrop.ts +62 -0
  243. package/src/utils/timelineDiscovery.ts +0 -17
  244. package/src/utils/timelineInspector.test.ts +121 -0
  245. package/src/utils/timelineInspector.ts +32 -1
  246. package/dist/assets/index-Dq7FEg0K.css +0 -1
  247. package/dist/assets/index-pRhCpGPz.js +0 -423
  248. package/dist/chunk-SOTCF4DF.js.map +0 -1
  249. package/src/components/StudioPreviewArea.tsx +0 -500
  250. package/src/components/nle/NLELayout.test.ts +0 -12
  251. package/src/components/nle/NLELayout.tsx +0 -591
  252. package/src/player/components/TimelineLayerGutter.tsx +0 -61
  253. package/src/player/components/TimelineSelectionOverlays.tsx +0 -55
  254. package/src/player/components/timelineMarqueeSelection.test.ts +0 -87
  255. package/src/player/components/timelineSnapTargets.test.ts +0 -144
  256. package/src/player/components/timelineSnapTargets.ts +0 -164
  257. package/src/player/components/useTimelineClipDrag.test.tsx +0 -506
  258. package/src/player/components/useTimelineClipGroupDrag.ts +0 -417
  259. package/src/player/components/useTimelineMarqueeSelection.test.tsx +0 -231
  260. package/src/player/components/useTimelineMarqueeSelection.ts +0 -276
  261. package/src/utils/timelineDiscovery.test.ts +0 -90
  262. /package/dist/{domEditingLayers-2ECJK24D.js.map → domEditingLayers-6LQGKPOI.js.map} +0 -0
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Right-click context menu for a selected canvas element.
3
+ *
4
+ * Mirrors the look, positioning, and dismiss behavior of
5
+ * player/components/ClipContextMenu.tsx — portaled to document.body,
6
+ * overflow-adjusted, dismissed on outside-click or Escape via
7
+ * useContextMenuDismiss.
8
+ *
9
+ * ── Wiring (z-order persistence) ─────────────────────────────────────────────
10
+ * Z-index changes are applied optimistically to the live iframe element(s) via
11
+ * `resolveZOrderChange`, which returns a MULTI-element patch list (tie-aware:
12
+ * moving a target past an equal-z sibling can require renumbering the affected
13
+ * set). The patches are surfaced through the `onApplyZIndex` prop.
14
+ *
15
+ * The prop MUST be wired at the call site to route through the full persist
16
+ * path. PreviewOverlays.tsx builds the per-patch PatchTargets (the selected
17
+ * element carries its full selection identity; sibling elements are iframe DOM
18
+ * nodes, so their id / selector are derived from the node and they share the
19
+ * selection's sourceFile) and forwards them to handleDomZIndexReorderCommit.
20
+ * ─────────────────────────────────────────────────────────────────────────────
21
+ */
22
+
23
+ import { memo } from "react";
24
+ import { createPortal } from "react-dom";
25
+ import type { DomEditSelection } from "./domEditing";
26
+ import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
27
+ import {
28
+ isZOrderActionEnabled,
29
+ resolveZOrderChange,
30
+ type ZOrderPatch,
31
+ } from "./canvasContextMenuZOrder";
32
+
33
+ interface CanvasContextMenuProps {
34
+ /** Viewport x of the right-click event. */
35
+ x: number;
36
+ /** Viewport y of the right-click event. */
37
+ y: number;
38
+ selection: DomEditSelection;
39
+ onClose: () => void;
40
+ /**
41
+ * Called with the resolved z-order patch list after an optimistic DOM update.
42
+ * Each patch is an { element, zIndex } pair (the target and, when a renumber
43
+ * is needed, affected siblings). Wire to handleDomZIndexReorderCommit (see
44
+ * module-level wiring comment).
45
+ */
46
+ onApplyZIndex?: (patches: ZOrderPatch[]) => void;
47
+ /**
48
+ * Delete the selected element. Wire to handleDomEditElementDelete from
49
+ * useDomEditActionsContext — same path as the Delete/Backspace hotkey.
50
+ * Absent when the caller wires no delete persist path (e.g. a legacy mount):
51
+ * the Delete item is then hidden rather than shown as a silent no-op.
52
+ */
53
+ onDelete?: (selection: DomEditSelection) => void;
54
+ }
55
+
56
+ type ZAction = "bring-forward" | "send-backward" | "bring-to-front" | "send-to-back";
57
+
58
+ const Z_ACTIONS: Array<{ action: ZAction; label: string }> = [
59
+ { action: "bring-forward", label: "Bring forward" },
60
+ { action: "send-backward", label: "Send backward" },
61
+ { action: "bring-to-front", label: "Bring to front" },
62
+ { action: "send-to-back", label: "Send to back" },
63
+ ];
64
+
65
+ export const CanvasContextMenu = memo(function CanvasContextMenu({
66
+ x,
67
+ y,
68
+ selection,
69
+ onClose,
70
+ onApplyZIndex,
71
+ onDelete,
72
+ }: CanvasContextMenuProps) {
73
+ const menuRef = useContextMenuDismiss(onClose);
74
+
75
+ // Gate each item group on the presence of its persist handler. Without the
76
+ // handler the action can't be persisted, so showing it would be a dead-end:
77
+ // a z-write reverts on reload and Delete silently no-ops. Hide the group
78
+ // instead. If nothing is actionable (a legacy mount with no handlers at all),
79
+ // don't render the menu — an empty menu is itself a dead-end.
80
+ const hasZActions = Boolean(onApplyZIndex);
81
+ const hasDelete = Boolean(onDelete);
82
+ const hasDivider = hasZActions && hasDelete;
83
+
84
+ // Overflow correction — match ClipContextMenu approach. Only the rendered
85
+ // groups contribute height (keeps positioning correct when a group is hidden).
86
+ const menuWidth = 200;
87
+ const menuHeight =
88
+ 8 + (hasZActions ? Z_ACTIONS.length * 28 : 0) + (hasDivider ? 1 : 0) + (hasDelete ? 28 : 0) + 8; // padding + items + divider + delete + padding
89
+ const overflowY = y + menuHeight - window.innerHeight;
90
+ const adjustedX = x + menuWidth > window.innerWidth ? x - menuWidth : x;
91
+ const adjustedY = overflowY > 0 ? y - overflowY - 8 : y;
92
+
93
+ const el = selection.element;
94
+
95
+ function handleZAction(action: ZAction) {
96
+ // No persist handler → do NOT touch the live iframe DOM. An optimistic
97
+ // write with nothing to persist just reverts on the next reload.
98
+ if (!onApplyZIndex) return;
99
+ const patches = resolveZOrderChange(el, action);
100
+ if (patches === null) return;
101
+ // Optimistic update — visible immediately even before persist completes.
102
+ for (const patch of patches) {
103
+ patch.element.style.zIndex = String(patch.zIndex);
104
+ const view = patch.element.ownerDocument?.defaultView;
105
+ if (view && view.getComputedStyle(patch.element).position === "static") {
106
+ patch.element.style.position = "relative";
107
+ }
108
+ }
109
+ onApplyZIndex(patches);
110
+ onClose();
111
+ }
112
+
113
+ function handleDelete() {
114
+ if (!onDelete) return;
115
+ onDelete(selection);
116
+ onClose();
117
+ }
118
+
119
+ if (!hasZActions && !hasDelete) return null;
120
+
121
+ // The menu is portaled to document.body, but in the React tree it is still a
122
+ // child of the DomEditOverlay <div>. React synthetic events bubble through the
123
+ // REACT tree (not the DOM tree), so a click on any menu control would otherwise
124
+ // bubble into the overlay's onPointerDown / onMouseDown handlers — which
125
+ // preventDefault() to start a marquee and re-resolve the selection. That
126
+ // preventDefault cancels the button's own click and the item action never runs.
127
+ //
128
+ // Stop pointer/mouse propagation at the menu root so overlay gesture handlers
129
+ // never see these events, and drive the item actions on pointerDown (which
130
+ // fires before any outside-click / dismiss logic can unmount the menu).
131
+ const stopBubble = (e: React.SyntheticEvent) => {
132
+ e.stopPropagation();
133
+ };
134
+
135
+ return createPortal(
136
+ <div
137
+ ref={menuRef}
138
+ className="fixed z-50 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[180px]"
139
+ style={{ left: adjustedX, top: adjustedY }}
140
+ onPointerDown={stopBubble}
141
+ onMouseDown={stopBubble}
142
+ onClick={stopBubble}
143
+ onContextMenu={(e) => {
144
+ // Keep a right-click on the menu itself from re-opening / bubbling.
145
+ e.preventDefault();
146
+ e.stopPropagation();
147
+ }}
148
+ >
149
+ {hasZActions &&
150
+ Z_ACTIONS.map(({ action, label }) => {
151
+ const enabled = isZOrderActionEnabled(el, action);
152
+ return (
153
+ <button
154
+ key={action}
155
+ type="button"
156
+ className={`w-full flex items-center px-3 py-1.5 text-xs text-left ${
157
+ enabled
158
+ ? "text-neutral-300 hover:bg-neutral-800 cursor-pointer"
159
+ : "text-neutral-600 cursor-not-allowed"
160
+ }`}
161
+ disabled={!enabled}
162
+ // Act on pointerDown, not click: a pointerDown that reaches the
163
+ // overlay/document would otherwise re-select or dismiss the menu
164
+ // before the trailing click fires. Running here guarantees the
165
+ // action lands. Guard `button === 0` so a right-press is ignored.
166
+ onPointerDown={(e) => {
167
+ if (e.button !== 0) return;
168
+ e.preventDefault();
169
+ e.stopPropagation();
170
+ if (enabled) handleZAction(action);
171
+ }}
172
+ >
173
+ {label}
174
+ </button>
175
+ );
176
+ })}
177
+
178
+ {hasDivider && <div className="my-1 border-t border-neutral-700/60" />}
179
+
180
+ {hasDelete && (
181
+ <button
182
+ type="button"
183
+ className="w-full flex items-center justify-between px-3 py-1.5 text-xs text-red-400 hover:bg-neutral-800 cursor-pointer text-left"
184
+ onPointerDown={(e) => {
185
+ if (e.button !== 0) return;
186
+ e.preventDefault();
187
+ e.stopPropagation();
188
+ handleDelete();
189
+ }}
190
+ >
191
+ <span>Delete</span>
192
+ <span className="text-neutral-500 text-[10px] ml-3">⌫</span>
193
+ </button>
194
+ )}
195
+ </div>,
196
+ document.body,
197
+ );
198
+ });
@@ -1,7 +1,7 @@
1
1
  // @vitest-environment happy-dom
2
2
  import React, { act } from "react";
3
3
  import { createRoot, type Root } from "react-dom/client";
4
- import { afterEach, describe, expect, it } from "vitest";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
5
  import type { DomEditSelection } from "./domEditing";
6
6
  import type { OverlayRect } from "./domEditOverlayGeometry";
7
7
  import { DomEditCropHandles } from "./DomEditCropHandles";
@@ -33,7 +33,10 @@ function makeEl(id: string, clip: string): HTMLElement {
33
33
  return el;
34
34
  }
35
35
 
36
- function render(el: HTMLElement): { root: Root; rerender: (next: HTMLElement) => void } {
36
+ function render(
37
+ el: HTMLElement,
38
+ onStyleCommit: (property: string, value: string) => Promise<void> | void = () => undefined,
39
+ ): { root: Root; rerender: (next: HTMLElement) => void } {
37
40
  const host = document.createElement("div");
38
41
  document.body.append(host);
39
42
  const root = createRoot(host);
@@ -43,7 +46,7 @@ function render(el: HTMLElement): { root: Root; rerender: (next: HTMLElement) =>
43
46
  <DomEditCropHandles
44
47
  selection={selectionFor(target)}
45
48
  overlayRect={overlayRect}
46
- onStyleCommit={() => undefined}
49
+ onStyleCommit={onStyleCommit}
47
50
  />,
48
51
  );
49
52
  });
@@ -83,4 +86,72 @@ describe("DomEditCropHandles clip lift/restore", () => {
83
86
  act(() => root.unmount());
84
87
  expect(a.style.getPropertyValue("clip-path")).toBe("circle(50% at 50% 50%)");
85
88
  });
89
+
90
+ it("re-lifts synchronously after the commit path re-applies the cropped value", async () => {
91
+ const a = makeEl("a", "inset(10px)");
92
+ let resolveCommit: (() => void) | undefined;
93
+ const pendingCommit = new Promise<void>((resolve) => {
94
+ resolveCommit = resolve;
95
+ });
96
+ const onStyleCommit = vi.fn((property: string, value: string) => {
97
+ a.style.setProperty(property, value);
98
+ return pendingCommit;
99
+ });
100
+ const { root } = render(a, onStyleCommit);
101
+ const handle = document.querySelector<HTMLButtonElement>('[aria-label="Crop right"]');
102
+ expect(handle).toBeTruthy();
103
+
104
+ act(() =>
105
+ handle!.dispatchEvent(
106
+ new PointerEvent("pointerdown", { bubbles: true, pointerId: 1, clientX: 100 }),
107
+ ),
108
+ );
109
+ act(() =>
110
+ handle!.dispatchEvent(
111
+ new PointerEvent("pointermove", { bubbles: true, pointerId: 1, clientX: 80 }),
112
+ ),
113
+ );
114
+ act(() =>
115
+ handle!.dispatchEvent(
116
+ new PointerEvent("pointerup", { bubbles: true, pointerId: 1, clientX: 80 }),
117
+ ),
118
+ );
119
+
120
+ expect(onStyleCommit).toHaveBeenCalledWith("clip-path", "inset(10px 30px 10px 10px)");
121
+ expect(a.style.getPropertyValue("clip-path")).toBe("none");
122
+ resolveCommit?.();
123
+ await act(async () => pendingCommit);
124
+ act(() => root.unmount());
125
+ expect(a.style.getPropertyValue("clip-path")).toBe("inset(10px 30px 10px 10px)");
126
+ });
127
+
128
+ it("re-lifts when the crop commit rejects", async () => {
129
+ const a = makeEl("a", "inset(10px)");
130
+ const onStyleCommit = vi.fn((property: string, value: string) => {
131
+ a.style.setProperty(property, value);
132
+ return Promise.reject(new Error("persist failed"));
133
+ });
134
+ const { root } = render(a, onStyleCommit);
135
+ const handle = document.querySelector<HTMLButtonElement>('[aria-label="Crop right"]');
136
+
137
+ act(() =>
138
+ handle!.dispatchEvent(
139
+ new PointerEvent("pointerdown", { bubbles: true, pointerId: 2, clientX: 100 }),
140
+ ),
141
+ );
142
+ act(() =>
143
+ handle!.dispatchEvent(
144
+ new PointerEvent("pointermove", { bubbles: true, pointerId: 2, clientX: 80 }),
145
+ ),
146
+ );
147
+ await act(async () => {
148
+ handle!.dispatchEvent(
149
+ new PointerEvent("pointerup", { bubbles: true, pointerId: 2, clientX: 80 }),
150
+ );
151
+ await Promise.resolve();
152
+ });
153
+
154
+ expect(a.style.getPropertyValue("clip-path")).toBe("none");
155
+ act(() => root.unmount());
156
+ });
86
157
  });
@@ -31,12 +31,16 @@ interface DomEditCropHandlesProps {
31
31
  onStyleCommit?: (property: string, value: string) => Promise<void> | void;
32
32
  }
33
33
 
34
- // Gap (px) between an edge handle and the element edge, so the handle sits
35
- // clear of the element body and can't intercept a move-drag.
36
- const EDGE_HANDLE_GAP = 8;
34
+ // Hit-strip size (px) for an edge crop handle: THICKNESS extends outward from
35
+ // the crop edge (flush against it, never over the element body, so a body
36
+ // drag always MOVES), LENGTH runs along the edge. The visible pill is smaller
37
+ // and centered inside the strip.
38
+ const EDGE_HIT_THICKNESS = 12;
39
+ const EDGE_HIT_LENGTH = 32;
37
40
 
38
- /** Place an edge handle just OUTSIDE the given crop edge (translate pushes it
39
- * fully past the boundary). Keeps the element body free for moving. */
41
+ /** Place an edge handle's hit strip just OUTSIDE the given crop edge
42
+ * (translate pushes it fully past the boundary). Keeps the element body free
43
+ * for moving. Corners stay free for the selection's own resize handles. */
40
44
  function edgeHandlePlacement(
41
45
  edge: CropEdge,
42
46
  rect: { left: number; top: number; width: number; height: number },
@@ -44,27 +48,36 @@ function edgeHandlePlacement(
44
48
  const cx = rect.left + rect.width / 2;
45
49
  const cy = rect.top + rect.height / 2;
46
50
  if (edge === "top") {
47
- return { left: cx, top: rect.top - EDGE_HANDLE_GAP, transform: "translate(-50%, -100%)" };
51
+ return { left: cx, top: rect.top, transform: "translate(-50%, -100%)" };
48
52
  }
49
53
  if (edge === "bottom") {
50
- return {
51
- left: cx,
52
- top: rect.top + rect.height + EDGE_HANDLE_GAP,
53
- transform: "translate(-50%, 0)",
54
- };
54
+ return { left: cx, top: rect.top + rect.height, transform: "translate(-50%, 0)" };
55
55
  }
56
56
  if (edge === "left") {
57
- return { left: rect.left - EDGE_HANDLE_GAP, top: cy, transform: "translate(-100%, -50%)" };
57
+ return { left: rect.left, top: cy, transform: "translate(-100%, -50%)" };
58
58
  }
59
- return {
60
- left: rect.left + rect.width + EDGE_HANDLE_GAP,
61
- top: cy,
62
- transform: "translate(0, -50%)",
63
- };
59
+ return { left: rect.left + rect.width, top: cy, transform: "translate(0, -50%)" };
64
60
  }
65
61
 
66
62
  const EDGES: CropEdge[] = ["top", "right", "bottom", "left"];
67
63
 
64
+ /** Hit-strip + pill dimensions for an edge handle, keyed on its orientation. */
65
+ function edgeHandleMetrics(vertical: boolean): {
66
+ hitWidth: number;
67
+ hitHeight: number;
68
+ cursor: string;
69
+ pillWidth: number;
70
+ pillHeight: number;
71
+ } {
72
+ return {
73
+ hitWidth: vertical ? EDGE_HIT_THICKNESS : EDGE_HIT_LENGTH,
74
+ hitHeight: vertical ? EDGE_HIT_LENGTH : EDGE_HIT_THICKNESS,
75
+ cursor: vertical ? "ew-resize" : "ns-resize",
76
+ pillWidth: vertical ? 4 : 24,
77
+ pillHeight: vertical ? 24 : 4,
78
+ };
79
+ }
80
+
68
81
  /**
69
82
  * Always-on crop, integrated with the selection (no crop "mode"): while a
70
83
  * croppable element is selected its clip is lifted so the FULL content shows and
@@ -83,6 +96,7 @@ export function DomEditCropHandles({
83
96
  }: DomEditCropHandlesProps) {
84
97
  const gestureRef = useRef<CropGestureState | null>(null);
85
98
  const [dragging, setDragging] = useState(false);
99
+ const [hotEdge, setHotEdge] = useState<CropEdge | null>(null);
86
100
  // readElementCropInsets returns null for a clip this tool can't represent
87
101
  // (circle/polygon/non-px inset): the crop UI must fully stand down for that
88
102
  // element — no lift, no handles — or select+deselect replaces the authored
@@ -209,10 +223,9 @@ export function DomEditCropHandles({
209
223
  setDragging(false);
210
224
  if (!gesture.didMove) return;
211
225
  // Commit to the file. The commit path re-applies the value to the live
212
- // element, so re-lift afterwards to keep showing the full content + dim while
213
- // the element stays selected. Re-lift on both fulfilment and rejection so a
214
- // failed commit still restores the crop-mode presentation (and the rejection
215
- // is handled rather than left unhandled).
226
+ // element synchronously, so re-lift in the same turn to keep showing the full
227
+ // content + dim while selected. Re-lift again on rejection so a failed commit
228
+ // still restores crop-mode presentation without an unhandled rejection.
216
229
  const el = selection.element;
217
230
  const reLift = () => {
218
231
  if (liftedRef.current) el.style.setProperty("clip-path", "none");
@@ -223,12 +236,16 @@ export function DomEditCropHandles({
223
236
  state.insets.right > 0 ||
224
237
  state.insets.bottom > 0 ||
225
238
  state.insets.left > 0;
226
- void Promise.resolve(onStyleCommit?.("clip-path", committedValue)).then(() => {
239
+ const commit = onStyleCommit?.("clip-path", committedValue);
240
+ // handleDomStyleCommit applies the persisted value to the live element
241
+ // synchronously before its first await. Restore the crop-mode lift in this
242
+ // same turn so the browser never paints that intermediate cropped state.
243
+ reLift();
244
+ void Promise.resolve(commit).then(() => {
227
245
  // Only a landed commit makes the rebuilt inset the restore value; a
228
246
  // failed one keeps restoring the pre-lift clip. Store the value itself —
229
247
  // by deselect time, render state describes the next selection.
230
248
  committedClipRef.current = cropped ? committedValue : "";
231
- reLift();
232
249
  }, reLift);
233
250
  };
234
251
 
@@ -304,6 +321,7 @@ export function DomEditCropHandles({
304
321
  <button
305
322
  type="button"
306
323
  aria-label="Reposition crop"
324
+ title="Reposition crop"
307
325
  data-dom-edit-crop-handle="true"
308
326
  className="pointer-events-auto absolute rounded-full border-2 border-studio-accent bg-studio-accent/30 shadow-[0_0_0_1px_rgba(0,0,0,0.4)]"
309
327
  style={{
@@ -323,31 +341,48 @@ export function DomEditCropHandles({
323
341
  )}
324
342
  {/* Edge handles — drag a side to crop it. Positioned just OUTSIDE the crop
325
343
  edge (via edgeHandlePlacement) so they never overlap the element body:
326
- dragging the body always MOVES, only a handle crops. */}
344
+ dragging the body always MOVES, only a handle crops. The pill is
345
+ hover-revealed (or shown while dragging / once a crop exists) so the
346
+ resting selection chrome stays uncluttered; the hit strip is always
347
+ live, and the title names the affordance. */}
327
348
  {EDGES.map((edge) => {
328
349
  const vertical = edge === "left" || edge === "right";
329
350
  const place = edgeHandlePlacement(edge, cropRect);
351
+ const revealed = dragging || hasCrop || hotEdge === edge;
352
+ const m = edgeHandleMetrics(vertical);
330
353
  return (
331
354
  <button
332
355
  key={edge}
333
356
  type="button"
334
357
  aria-label={`Crop ${edge}`}
358
+ title="Crop"
335
359
  data-dom-edit-crop-handle="true"
336
- className="pointer-events-auto absolute rounded-full bg-studio-accent shadow-[0_0_0_1px_rgba(0,0,0,0.4)]"
360
+ className="pointer-events-auto absolute flex items-center justify-center border-0 bg-transparent p-0"
337
361
  style={{
338
362
  left: place.left,
339
363
  top: place.top,
340
- width: vertical ? 5 : 26,
341
- height: vertical ? 26 : 5,
364
+ width: m.hitWidth,
365
+ height: m.hitHeight,
342
366
  transform: place.transform,
343
- cursor: vertical ? "ew-resize" : "ns-resize",
367
+ cursor: m.cursor,
344
368
  touchAction: "none",
345
369
  }}
370
+ onPointerEnter={() => setHotEdge(edge)}
371
+ onPointerLeave={() => setHotEdge((prev) => (prev === edge ? null : prev))}
346
372
  onPointerDown={(event) => startCropGesture(edge, event)}
347
373
  onPointerMove={updateCropGesture}
348
374
  onPointerUp={finishCropGesture}
349
375
  onPointerCancel={cancelCropGesture}
350
- />
376
+ >
377
+ <span
378
+ className="pointer-events-none rounded-full bg-studio-accent/90 shadow-[0_0_0_1px_rgba(0,0,0,0.4)] transition-opacity duration-100"
379
+ style={{
380
+ width: m.pillWidth,
381
+ height: m.pillHeight,
382
+ opacity: revealed ? 1 : 0,
383
+ }}
384
+ />
385
+ </button>
351
386
  );
352
387
  })}
353
388
  </div>