@hyperframes/studio 0.8.114 → 0.8.116

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 (266) hide show
  1. package/dist/assets/{hyperframes-player-aXNJKiIg.js → hyperframes-player-mGYvPVpw.js} +1 -1
  2. package/dist/assets/{index-Bn2RJNTJ.js → index-BWHt0-ir.js} +1 -1
  3. package/dist/assets/{index-Dz7doz9l.js → index-DYNaXPVf.js} +203 -203
  4. package/dist/assets/{index-C9N-DGjC.js → index-Dr3hNpvS.js} +1 -1
  5. package/dist/assets/{index-BYnsJnJV.css → index-t1LH6H5H.css} +1 -1
  6. package/dist/{chunk-BIUXP3PG.js → chunk-3F47EFCT.js} +142 -277
  7. package/dist/chunk-3F47EFCT.js.map +1 -0
  8. package/dist/{chunk-K47E2ZJA.js → chunk-5EKWJZLM.js} +504 -17
  9. package/dist/chunk-5EKWJZLM.js.map +1 -0
  10. package/dist/{chunk-VWT7STY3.js → chunk-J6Q572FA.js} +2 -2
  11. package/dist/{domEditingLayers-V67MS4WP.js → domEditingLayers-OL7GLSS4.js} +3 -5
  12. package/dist/index.d.ts +146 -40
  13. package/dist/index.html +2 -2
  14. package/dist/index.js +4021 -3591
  15. package/dist/index.js.map +1 -1
  16. package/dist/player.js +2 -2
  17. package/dist/styles.css +1 -1
  18. package/package.json +9 -8
  19. package/src/App.tsx +2 -2
  20. package/src/captions/hooks/useCaptionSync.ts +10 -6
  21. package/src/components/LinkedSelectionToggle.tsx +3 -2
  22. package/src/components/ProjectUnreachableBanner.tsx +3 -3
  23. package/src/components/dock/Dock.tsx +5 -7
  24. package/src/components/editor/CanvasContextMenu.tsx +3 -2
  25. package/src/components/editor/DomEditCropHandles.test.tsx +27 -0
  26. package/src/components/editor/DomEditCropHandles.tsx +7 -4
  27. package/src/components/editor/DomEditOverlay.tsx +5 -1
  28. package/src/components/editor/FileTreeNodes.tsx +13 -10
  29. package/src/components/editor/MotionPathOverlay.tsx +7 -2
  30. package/src/components/editor/PropertyPanelEmptyState.tsx +12 -2
  31. package/src/components/editor/SnapToolbar.test.tsx +100 -1
  32. package/src/components/editor/SnapToolbar.tsx +43 -5
  33. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +48 -1
  34. package/src/components/editor/audioFxTelemetry.ts +2 -3
  35. package/src/components/editor/commitPreflightHandles.test.tsx +2 -2
  36. package/src/components/editor/cropResize.ts +4 -3
  37. package/src/components/editor/domEditingDom.ts +4 -0
  38. package/src/components/editor/domEditingLayers.ts +1 -5
  39. package/src/components/editor/domGroupMoveBy.test.tsx +252 -0
  40. package/src/components/editor/domGroupMoveBy.ts +95 -0
  41. package/src/components/editor/dragUndoPaint.test.ts +5 -5
  42. package/src/components/editor/gestureHoldsPreviewReload.test.tsx +33 -0
  43. package/src/components/editor/manualEditingAvailability.ts +8 -0
  44. package/src/components/editor/manualEdits.ts +1 -0
  45. package/src/components/editor/manualEditsParsing.ts +7 -0
  46. package/src/components/editor/manualOffsetDrag.ts +5 -6
  47. package/src/components/editor/marqueeCommit.test.tsx +106 -37
  48. package/src/components/editor/marqueeCommit.ts +24 -8
  49. package/src/components/editor/motionPathCommit.test.ts +29 -1
  50. package/src/components/editor/motionPathCommit.ts +21 -9
  51. package/src/components/editor/motionPathSelection.test.ts +6 -31
  52. package/src/components/editor/probeSourceElement.test.ts +45 -0
  53. package/src/components/editor/probeSourceElement.ts +41 -16
  54. package/src/components/editor/propertyPanelFlatTextSection.tsx +10 -7
  55. package/src/components/editor/propertyPanelFont.tsx +3 -2
  56. package/src/components/editor/propertyPanelFxAddMenu.tsx +11 -2
  57. package/src/components/editor/propertyPanelFxCarveModule.tsx +15 -5
  58. package/src/components/editor/propertyPanelFxNodeOpenBody.tsx +3 -0
  59. package/src/components/editor/propertyPanelFxNodeParams.tsx +15 -2
  60. package/src/components/editor/propertyPanelFxNodeRow.tsx +3 -0
  61. package/src/components/editor/propertyPanelFxPresetRun.tsx +10 -2
  62. package/src/components/editor/propertyPanelFxSection.test.tsx +56 -0
  63. package/src/components/editor/propertyPanelFxSection.tsx +30 -28
  64. package/src/components/editor/propertyPanelTypes.ts +6 -3
  65. package/src/components/editor/useColorGradingController.ts +2 -1
  66. package/src/components/editor/useDomEditNudge.test.tsx +37 -0
  67. package/src/components/editor/useDomEditNudge.ts +2 -0
  68. package/src/components/editor/useDomEditOverlayGestures.ts +5 -0
  69. package/src/components/editor/useDomEditZOrder.ts +12 -2
  70. package/src/components/editor/useFxCarveNodes.ts +2 -1
  71. package/src/components/editor/useFxLevelling.ts +2 -1
  72. package/src/components/feedback/projectProvenance.ts +2 -1
  73. package/src/components/nle/NLEContext.test.ts +1 -0
  74. package/src/components/nle/NLEContext.tsx +2 -1
  75. package/src/components/nle/NLEPreview.tsx +12 -10
  76. package/src/components/nle/PreviewPoster.tsx +2 -1
  77. package/src/components/nle/useTimelineEditCallbacks.test.tsx +1 -0
  78. package/src/components/nle/useTimelineEditCallbacks.ts +7 -1
  79. package/src/components/renders/useFfmpegStatus.ts +2 -1
  80. package/src/components/renders/useRenderQueue.ts +5 -4
  81. package/src/components/sidebar/AssetContextMenu.tsx +4 -4
  82. package/src/components/sidebar/AssetsTab.tsx +2 -1
  83. package/src/components/sidebar/GlobalAssetsView.tsx +2 -1
  84. package/src/components/ui/menuStyle.test.ts +61 -0
  85. package/src/components/ui/menuStyle.ts +10 -0
  86. package/src/contexts/DomEditContext.tsx +5 -1
  87. package/src/contexts/TimelineEditContext.tsx +1 -0
  88. package/src/domEditExports.test.tsx +9 -1
  89. package/src/hooks/domEditCommitRunner.ts +1 -1
  90. package/src/hooks/domSelectionTestHarness.ts +23 -0
  91. package/src/hooks/domStyleCommit.propertyNames.test.ts +73 -0
  92. package/src/hooks/domStyleCommit.ts +8 -1
  93. package/src/hooks/draggedGsapPosition.ts +10 -0
  94. package/src/hooks/elementOffsetStager.ts +5 -4
  95. package/src/hooks/gsapDragCommit.test.ts +75 -164
  96. package/src/hooks/gsapDragCommit.ts +4 -125
  97. package/src/hooks/gsapDragPositionCommit.ts +36 -384
  98. package/src/hooks/gsapEditOutcome.ts +22 -2
  99. package/src/hooks/gsapGestureOutcome.test.ts +161 -0
  100. package/src/hooks/gsapGestureOutcome.ts +64 -0
  101. package/src/hooks/gsapGroupPreflight.ts +34 -0
  102. package/src/hooks/gsapKeyframeCommit.ts +3 -12
  103. package/src/hooks/gsapMutationClient.ts +4 -3
  104. package/src/hooks/gsapParsedTween.test-helpers.ts +75 -0
  105. package/src/hooks/gsapParsedTween.test.ts +62 -0
  106. package/src/hooks/gsapParsedTween.ts +243 -0
  107. package/src/hooks/gsapPositionDetection.test.ts +78 -0
  108. package/src/hooks/gsapPositionDetection.ts +52 -33
  109. package/src/hooks/gsapResizeDropPoint.test.ts +4 -3
  110. package/src/hooks/gsapResizeElementSize.test.tsx +1 -0
  111. package/src/hooks/gsapResizeGeometrySweep.test.ts +13 -25
  112. package/src/hooks/gsapResizeIntercept.test.ts +96 -11
  113. package/src/hooks/gsapResizeIntercept.ts +79 -168
  114. package/src/hooks/gsapResizeSweep.test.ts +16 -33
  115. package/src/hooks/gsapRuntimeBridge.sharedTween.test.ts +6 -8
  116. package/src/hooks/gsapRuntimeBridge.test.ts +41 -6
  117. package/src/hooks/gsapRuntimeBridge.ts +105 -131
  118. package/src/hooks/gsapScriptCommitHelpers.ts +2 -1
  119. package/src/hooks/gsapScriptCommitTypes.ts +13 -0
  120. package/src/hooks/gsapShared.ts +7 -0
  121. package/src/hooks/gsapShared.writeTarget.test.ts +45 -57
  122. package/src/hooks/gsapValueAtPlayhead.browser.test.ts +83 -0
  123. package/src/hooks/gsapValueAtPlayhead.realGsap.test.ts +179 -0
  124. package/src/hooks/gsapValueAtPlayhead.test.ts +552 -0
  125. package/src/hooks/gsapValueAtPlayhead.ts +292 -0
  126. package/src/hooks/gsapWholePropertyOffsetCommit.test.ts +43 -0
  127. package/src/hooks/gsapWholePropertyOffsetCommit.ts +2 -3
  128. package/src/hooks/keyframeCacheAstLoad.ts +8 -6
  129. package/src/hooks/newTweenTarget.test.ts +41 -76
  130. package/src/hooks/plainRotation.ts +3 -2
  131. package/src/hooks/previewPromotionSelection.test.tsx +1 -1
  132. package/src/hooks/refreshTimelineRowText.test.ts +65 -0
  133. package/src/hooks/refreshTimelineRowText.ts +29 -0
  134. package/src/hooks/timelineTimingSync.test.ts +1 -0
  135. package/src/hooks/timelineTimingSync.ts +2 -1
  136. package/src/hooks/useAnimatedPropertyCommit.test.tsx +46 -5
  137. package/src/hooks/useAnimatedPropertyCommit.ts +2 -2
  138. package/src/hooks/useAskAgentModal.ts +2 -1
  139. package/src/hooks/useBlockCatalog.ts +2 -1
  140. package/src/hooks/useBlockedTimelineEditToast.ts +12 -3
  141. package/src/hooks/useCaptionDetection.ts +2 -1
  142. package/src/hooks/useCompositionContentLoader.ts +2 -1
  143. package/src/hooks/useDomEditCommitsHelpers.ts +3 -2
  144. package/src/hooks/useDomEditPositionPatchCommit.ts +18 -23
  145. package/src/hooks/useDomEditSession.test.tsx +30 -1
  146. package/src/hooks/useDomEditSession.ts +10 -0
  147. package/src/hooks/useDomEditTextCommits.test.tsx +41 -0
  148. package/src/hooks/useDomEditTextCommits.ts +13 -3
  149. package/src/hooks/useDomEditWiring.ts +4 -2
  150. package/src/hooks/useDomGeometryCommit.test.tsx +25 -6
  151. package/src/hooks/useDomGeometryCommit.ts +8 -4
  152. package/src/hooks/useDomGeometryCommits.test.tsx +1 -0
  153. package/src/hooks/useDomGeometryCommits.ts +3 -2
  154. package/src/hooks/useDomSelection.test.ts +26 -0
  155. package/src/hooks/useDomSelection.ts +10 -3
  156. package/src/hooks/useDomSelectionTypes.ts +6 -1
  157. package/src/hooks/useEditHistoryActions.paint.test.tsx +82 -20
  158. package/src/hooks/useEditHistoryActions.test.tsx +15 -0
  159. package/src/hooks/useEditHistoryActions.ts +16 -5
  160. package/src/hooks/useElementLifecycleOps.ts +2 -1
  161. package/src/hooks/useEnableKeyframes.test.ts +34 -3
  162. package/src/hooks/useEnableKeyframes.ts +7 -4
  163. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +121 -0
  164. package/src/hooks/useExternalFileChangeCoordinator.ts +35 -8
  165. package/src/hooks/useFileManager.projectOwnership.test.tsx +6 -1
  166. package/src/hooks/useFileManager.ts +14 -10
  167. package/src/hooks/useFileTree.ts +3 -2
  168. package/src/hooks/useFrameCapture.ts +5 -1
  169. package/src/hooks/useFreezeFrame.ts +3 -2
  170. package/src/hooks/useGestureCommit.test.tsx +225 -55
  171. package/src/hooks/useGestureCommit.ts +86 -55
  172. package/src/hooks/useGroupCommits.ts +2 -1
  173. package/src/hooks/useGsapAwareEditing.groupPlan.test.tsx +160 -0
  174. package/src/hooks/useGsapAwareEditing.test.tsx +28 -35
  175. package/src/hooks/useGsapAwareEditing.testHelpers.tsx +42 -0
  176. package/src/hooks/useGsapAwareEditing.ts +101 -195
  177. package/src/hooks/useGsapAwareGroupMove.ts +179 -0
  178. package/src/hooks/useGsapInteractionFailureTelemetry.ts +10 -4
  179. package/src/hooks/useGsapKeyframeOps.sdkUsage.test.tsx +114 -0
  180. package/src/hooks/useGsapKeyframeOps.ts +36 -10
  181. package/src/hooks/useGsapScriptCommits.test.tsx +60 -1
  182. package/src/hooks/useGsapScriptCommits.ts +15 -10
  183. package/src/hooks/useGsapSelectionHandlers.test.tsx +1 -1
  184. package/src/hooks/useGsapSelectionHandlers.ts +24 -18
  185. package/src/hooks/useGsapTweenCache.ts +18 -12
  186. package/src/hooks/useLintModal.ts +2 -1
  187. package/src/hooks/usePersistentEditHistory.test.ts +277 -1
  188. package/src/hooks/usePersistentEditHistory.ts +47 -8
  189. package/src/hooks/usePopulateKeyframeCacheForFile.test.tsx +76 -2
  190. package/src/hooks/usePreviewInteraction.test.ts +21 -0
  191. package/src/hooks/usePreviewInteraction.ts +2 -0
  192. package/src/hooks/usePreviewPersistence.history.test.tsx +14 -3
  193. package/src/hooks/usePreviewPersistence.ts +13 -11
  194. package/src/hooks/useProjectFileWriter.ts +20 -19
  195. package/src/hooks/useRemoveBackground.ts +2 -1
  196. package/src/hooks/useRenderClipContent.test.ts +18 -0
  197. package/src/hooks/useRenderClipContent.ts +4 -5
  198. package/src/hooks/useSdkSession.ts +2 -1
  199. package/src/hooks/useServerConnection.ts +4 -3
  200. package/src/hooks/useStudioSelectionPublisher.ts +2 -1
  201. package/src/hooks/useThumbnailStill.test.ts +100 -0
  202. package/src/hooks/useThumbnailStill.ts +37 -0
  203. package/src/hooks/useTimelineDeleteOps.test.tsx +30 -22
  204. package/src/hooks/useTimelineDeleteOps.ts +22 -27
  205. package/src/hooks/useTimelineEditing.test.tsx +1 -1
  206. package/src/index.ts +4 -0
  207. package/src/player/components/AudioWaveform.tsx +3 -2
  208. package/src/player/components/AutomationSelectionMenu.tsx +3 -2
  209. package/src/player/components/ClipContextMenu.test.tsx +73 -0
  210. package/src/player/components/ClipContextMenu.tsx +33 -31
  211. package/src/player/components/CompositionThumbnail.tsx +2 -1
  212. package/src/player/components/KeyframeDiamondContextMenu.tsx +8 -9
  213. package/src/player/components/OutOfSyncBadge.tsx +5 -5
  214. package/src/player/components/TextClipContent.test.ts +19 -0
  215. package/src/player/components/TextClipContent.tsx +27 -0
  216. package/src/player/components/TrackGapContextMenu.tsx +3 -6
  217. package/src/player/components/clipMenuNormalize.ts +2 -1
  218. package/src/player/components/clipMenuToolItems.test.tsx +23 -0
  219. package/src/player/components/clipMenuToolItems.tsx +13 -9
  220. package/src/player/components/clipPeakMap.ts +2 -1
  221. package/src/player/components/timelineCallbacks.ts +1 -0
  222. package/src/player/components/timelineClipDragGestureLifecycle.ts +1 -1
  223. package/src/player/components/timelineClipGestureHandlers.test.ts +94 -0
  224. package/src/player/components/timelineClipGestureHandlers.ts +20 -2
  225. package/src/player/components/timelineEditing.ts +3 -1
  226. package/src/player/components/timelineLinkSelection.ts +35 -1
  227. package/src/player/components/useTimelineClipDrag.keyboard.test.tsx +14 -0
  228. package/src/player/components/useTimelineClipDrag.ts +6 -7
  229. package/src/player/hooks/timelinePlayerSync.test.ts +6 -0
  230. package/src/player/hooks/timelinePlayerSync.ts +6 -1
  231. package/src/player/hooks/useTimelinePlayer.ts +5 -1
  232. package/src/player/lib/thumbnailScheduler.test.ts +16 -0
  233. package/src/player/lib/thumbnailScheduler.ts +10 -2
  234. package/src/player/lib/timelineElementHelpers.test.ts +87 -1
  235. package/src/player/lib/timelineElementHelpers.ts +6 -15
  236. package/src/player/lib/timelineText.ts +40 -0
  237. package/src/player/sceneSwap.ts +2 -1
  238. package/src/player/store/timelineElement.ts +12 -0
  239. package/src/styles/theme.css +3 -0
  240. package/src/telemetry/client.ts +2 -1
  241. package/src/utils/blockInstaller.ts +2 -1
  242. package/src/utils/elementGsap.test.ts +9 -0
  243. package/src/utils/elementGsap.ts +7 -0
  244. package/src/utils/gsapSoftReload.ts +2 -3
  245. package/src/utils/gsapUndoRestore.browser.test.ts +51 -45
  246. package/src/utils/gsapUndoRestore.ts +3 -6
  247. package/src/utils/keyframeUsage.ts +79 -0
  248. package/src/utils/previewFeatureUsage.test.ts +32 -0
  249. package/src/utils/previewFeatureUsage.ts +46 -0
  250. package/src/utils/previewPrefetch.ts +2 -1
  251. package/src/utils/razorSplitTransaction.ts +12 -8
  252. package/src/utils/softReloadTargets.ts +1 -3
  253. package/src/utils/studioApiFetch.test.ts +75 -0
  254. package/src/utils/studioApiFetch.ts +13 -0
  255. package/src/utils/studioFileHistory.ts +4 -1
  256. package/src/utils/studioHelpers.ts +7 -1
  257. package/src/utils/studioPendingEdits.test.ts +23 -6
  258. package/src/utils/studioPendingEdits.ts +32 -8
  259. package/src/utils/studioTelemetry.test.ts +107 -0
  260. package/src/utils/studioTelemetry.ts +51 -5
  261. package/src/utils/timelineCompositionInsert.ts +3 -2
  262. package/src/webmcp/StudioAgentTools.tsx +4 -3
  263. package/dist/chunk-BIUXP3PG.js.map +0 -1
  264. package/dist/chunk-K47E2ZJA.js.map +0 -1
  265. /package/dist/{chunk-VWT7STY3.js.map → chunk-J6Q572FA.js.map} +0 -0
  266. /package/dist/{domEditingLayers-V67MS4WP.js.map → domEditingLayers-OL7GLSS4.js.map} +0 -0
@@ -1,3 +1,4 @@
1
+ import { trackPreviewFeatureUsed } from "../../utils/previewFeatureUsage";
1
2
  import { memo, useCallback, useEffect, useRef, useState } from "react";
2
3
  import { MagnetStraight, GridFour, Path, Ruler, FrameCorners } from "@phosphor-icons/react";
3
4
  import { usePlayerStore } from "../../player/store/playerStore";
@@ -16,6 +17,16 @@ export const SnapToolbar = memo(function SnapToolbar() {
16
17
  const setMotionPathArmed = usePlayerStore((s) => s.setMotionPathArmed);
17
18
  const popoverRef = useRef<HTMLDivElement>(null);
18
19
  const gridButtonRef = useRef<HTMLButtonElement>(null);
20
+ const gridSpacingAtFocus = useRef<number | null>(null);
21
+ const currentGridSpacing = useRef(prefs.gridSpacing);
22
+ currentGridSpacing.current = prefs.gridSpacing;
23
+ const settleGridSpacing = useCallback(() => {
24
+ const previous = gridSpacingAtFocus.current;
25
+ gridSpacingAtFocus.current = null;
26
+ if (previous !== null && previous !== currentGridSpacing.current)
27
+ trackPreviewFeatureUsed("grid_spacing", "field");
28
+ }, []);
29
+ useEffect(() => settleGridSpacing, [settleGridSpacing]);
19
30
 
20
31
  const updatePrefs = useCallback(
21
32
  (patch: Partial<typeof prefs>) => {
@@ -26,10 +37,12 @@ export const SnapToolbar = memo(function SnapToolbar() {
26
37
 
27
38
  const toggleSnap = useCallback(() => {
28
39
  updatePrefs({ snapEnabled: !prefs.snapEnabled });
40
+ trackPreviewFeatureUsed("snapping", "button");
29
41
  }, [prefs.snapEnabled, updatePrefs]);
30
42
 
31
43
  const toggleGrid = useCallback(() => {
32
44
  updatePrefs({ gridVisible: !prefs.gridVisible });
45
+ trackPreviewFeatureUsed("grid", "button");
33
46
  }, [prefs.gridVisible, updatePrefs]);
34
47
 
35
48
  useEffect(() => {
@@ -41,10 +54,12 @@ export const SnapToolbar = memo(function SnapToolbar() {
41
54
  if (e.key === "s" && !e.metaKey && !e.ctrlKey && !e.altKey) {
42
55
  e.preventDefault();
43
56
  updatePrefs({ snapEnabled: !prefs.snapEnabled });
57
+ if (!e.repeat) trackPreviewFeatureUsed("snapping", "keyboard");
44
58
  }
45
59
  if (e.key === "g" && !e.metaKey && !e.ctrlKey && !e.altKey) {
46
60
  e.preventDefault();
47
61
  updatePrefs({ gridVisible: !prefs.gridVisible });
62
+ if (!e.repeat) trackPreviewFeatureUsed("grid", "keyboard");
48
63
  }
49
64
  };
50
65
  document.addEventListener("keydown", handleKeyDown);
@@ -56,11 +71,12 @@ export const SnapToolbar = memo(function SnapToolbar() {
56
71
  const handleClickOutside = (e: MouseEvent) => {
57
72
  const target = e.target as Node;
58
73
  if (popoverRef.current?.contains(target) || gridButtonRef.current?.contains(target)) return;
74
+ settleGridSpacing();
59
75
  setGridPopoverOpen(false);
60
76
  };
61
77
  document.addEventListener("mousedown", handleClickOutside);
62
78
  return () => document.removeEventListener("mousedown", handleClickOutside);
63
- }, [gridPopoverOpen]);
79
+ }, [gridPopoverOpen, settleGridSpacing]);
64
80
 
65
81
  return (
66
82
  <div
@@ -101,7 +117,10 @@ export const SnapToolbar = memo(function SnapToolbar() {
101
117
  ? "bg-studio-accent/20 text-accent-ink"
102
118
  : "bg-black/40 text-white/60 hover:bg-black/60 hover:text-white/80"
103
119
  }`}
104
- onClick={toggle}
120
+ onClick={() => {
121
+ toggle();
122
+ trackPreviewFeatureUsed(key === "rulerVisible" ? "ruler" : "safe_margins", "button");
123
+ }}
105
124
  title={`${label} ${visible ? "on" : "off"}`}
106
125
  aria-label={`Toggle ${label.toLowerCase()}`}
107
126
  aria-pressed={visible}
@@ -136,6 +155,7 @@ export const SnapToolbar = memo(function SnapToolbar() {
136
155
  onClick={toggleGrid}
137
156
  onContextMenu={(e) => {
138
157
  e.preventDefault();
158
+ if (gridPopoverOpen) settleGridSpacing();
139
159
  setGridPopoverOpen((v) => !v);
140
160
  }}
141
161
  title={
@@ -150,7 +170,10 @@ export const SnapToolbar = memo(function SnapToolbar() {
150
170
  <button
151
171
  type="button"
152
172
  className="absolute -right-0.5 -bottom-0.5 rounded-sm p-0.5 text-white/50 hover:text-white/90 bg-black/50"
153
- onClick={() => setGridPopoverOpen((v) => !v)}
173
+ onClick={() => {
174
+ if (gridPopoverOpen) settleGridSpacing();
175
+ setGridPopoverOpen((v) => !v);
176
+ }}
154
177
  title="Grid options"
155
178
  aria-label="Grid options"
156
179
  aria-expanded={gridPopoverOpen}
@@ -173,9 +196,21 @@ export const SnapToolbar = memo(function SnapToolbar() {
173
196
  max={500}
174
197
  step={10}
175
198
  value={prefs.gridSpacing}
199
+ onFocus={() => {
200
+ gridSpacingAtFocus.current = prefs.gridSpacing;
201
+ }}
202
+ onBlur={settleGridSpacing}
203
+ onKeyDown={(e) => {
204
+ if (e.key === "Enter") e.currentTarget.blur();
205
+ }}
176
206
  onChange={(e) => {
177
207
  const val = Number.parseInt(e.target.value, 10);
178
- if (Number.isFinite(val) && val >= 10 && val <= 500) {
208
+ if (
209
+ Number.isFinite(val) &&
210
+ val >= 10 &&
211
+ val <= 500 &&
212
+ val !== prefs.gridSpacing
213
+ ) {
179
214
  updatePrefs({ gridSpacing: val });
180
215
  }
181
216
  }}
@@ -186,7 +221,10 @@ export const SnapToolbar = memo(function SnapToolbar() {
186
221
  <input
187
222
  type="checkbox"
188
223
  checked={prefs.snapToGrid}
189
- onChange={() => updatePrefs({ snapToGrid: !prefs.snapToGrid })}
224
+ onChange={() => {
225
+ updatePrefs({ snapToGrid: !prefs.snapToGrid });
226
+ trackPreviewFeatureUsed("snap_to_grid", "button");
227
+ }}
190
228
  className="accent-studio-accent"
191
229
  />
192
230
  <span>Snap to grid</span>
@@ -1,6 +1,8 @@
1
1
  // @vitest-environment happy-dom
2
2
 
3
3
  import { afterEach, describe, expect, it, vi } from "vitest";
4
+ import { trackStudioEvent } from "../../utils/studioTelemetry";
5
+ vi.mock("../../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
4
6
  import { DomEditSaveQueueOpenError } from "../../utils/domEditSaveQueue";
5
7
  import type { DomEditSelection } from "./domEditing";
6
8
  import type { GestureState, UseDomEditOverlayGesturesOptions } from "./domEditOverlayGestures";
@@ -178,7 +180,7 @@ function buildHarness(
178
180
  };
179
181
 
180
182
  const handlers = createDomEditOverlayGestureHandlers(opts);
181
- return { handlers, commits, selection };
183
+ return { handlers, commits, selection, opts };
182
184
  }
183
185
 
184
186
  type OverlayRectLike = {
@@ -287,3 +289,48 @@ describe("anchored corner resize — the release commit feeds the center-pin off
287
289
  expect(consoleError).toHaveBeenCalledWith("resize commit failed", failure);
288
290
  });
289
291
  });
292
+
293
+ describe("resize usage at pointer release", () => {
294
+ it("counts one completed resize despite multiple pointer moves", async () => {
295
+ vi.mocked(trackStudioEvent).mockClear();
296
+ const h = buildHarness(async () => ({ ok: true, changed: true }));
297
+ await finishResize(h.handlers);
298
+ expect(trackStudioEvent).toHaveBeenCalledExactlyOnceWith("feature_used", {
299
+ feature: "resize",
300
+ surface: "preview",
301
+ method: "drag",
302
+ });
303
+ });
304
+ it.each([undefined, { ok: true as const, changed: false }])(
305
+ "does not count an unchanged or unconfirmed resize",
306
+ async (result) => {
307
+ vi.mocked(trackStudioEvent).mockClear();
308
+ const h = buildHarness(async () => result);
309
+ await finishResize(h.handlers);
310
+ expect(trackStudioEvent).not.toHaveBeenCalled();
311
+ },
312
+ );
313
+ });
314
+
315
+ describe("move and rotation usage", () => {
316
+ it.each(["drag", "rotate"] as const)("counts %s once after pointer release", async (kind) => {
317
+ vi.mocked(trackStudioEvent).mockClear();
318
+ const h = buildHarness();
319
+ h.selection.capabilities.canApplyManualRotation = true;
320
+ const saved = vi.fn(async () => ({ ok: true as const, changed: true }));
321
+ h.opts.onPathOffsetCommitRef.current = saved;
322
+ h.opts.onRotationCommitRef.current = saved;
323
+ h.handlers.startGesture(kind, evt(200, 50));
324
+ h.handlers.onPointerMove(evt(150, 120));
325
+ h.handlers.onPointerMove(evt(100, 150));
326
+ expect(trackStudioEvent).not.toHaveBeenCalled();
327
+ h.handlers.onPointerUp(evt(100, 150));
328
+ await Promise.resolve();
329
+ expect(saved).toHaveBeenCalledTimes(1);
330
+ expect(trackStudioEvent).toHaveBeenCalledExactlyOnceWith("feature_used", {
331
+ feature: kind === "drag" ? "move" : "rotate",
332
+ surface: "preview",
333
+ method: "drag",
334
+ });
335
+ });
336
+ });
@@ -10,9 +10,8 @@
10
10
  * filenames, not composition paths, not chain JSON. Every property below is
11
11
  * either a fixed identifier from our own catalogue (a preset id, an effect
12
12
  * type, a parameter key), a number, or a boolean. `studioTelemetry.ts` already
13
- * strips the query string off `url_hash` for exactly this reason — the ids in
14
- * it are the author's own. The rack sees the same class of data and must hold
15
- * the same line.
13
+ * sends only a route kind in `url_hash` for the same reason. The rack sees
14
+ * the same class of data and must hold the same line.
16
15
  *
17
16
  * The second rule is about volume: **commit, not preview.** Every control in
18
17
  * the rack has a preview path that fires continuously while a slider moves and
@@ -287,7 +287,7 @@ describe("handles follow what Studio would commit", () => {
287
287
  { x: 0, y: 0 },
288
288
  commitList,
289
289
  preview,
290
- vi.fn(),
290
+ vi.fn(async () => undefined),
291
291
  undefined,
292
292
  preflightOnly,
293
293
  );
@@ -401,7 +401,7 @@ describe("handles follow what Studio would commit", () => {
401
401
  { x: 0, y: 0 },
402
402
  [stagger],
403
403
  preview,
404
- vi.fn(),
404
+ vi.fn(async () => undefined),
405
405
  undefined,
406
406
  groupPreflight,
407
407
  );
@@ -1,3 +1,4 @@
1
+ import type { DomEditPersistOutcome } from "../../hooks/domEditCommitTypes";
1
2
  import type { PatchOperation } from "../../utils/sourcePatcher";
2
3
  import type { CommitMutation } from "../../hooks/gsapScriptCommitTypes";
3
4
  import type { DomEditSelection } from "./domEditingTypes";
@@ -94,7 +95,7 @@ type PatchCommit = (
94
95
  selection: DomEditSelection,
95
96
  patches: PatchOperation[],
96
97
  options: { label: string; coalesceKey: string; coalesceMs?: number },
97
- ) => Promise<void>;
98
+ ) => Promise<DomEditPersistOutcome | undefined>;
98
99
 
99
100
  /** Stage the crop and save it under the resize's undo key, taking it back off the element if that fails. */
100
101
  export async function saveCropResize(
@@ -102,11 +103,11 @@ export async function saveCropResize(
102
103
  selection: DomEditSelection,
103
104
  commit: PatchCommit,
104
105
  coalesceKey: string,
105
- ): Promise<void> {
106
+ ): Promise<DomEditPersistOutcome | undefined> {
106
107
  const crop = stage();
107
108
  if (!crop) return;
108
109
  try {
109
- await commit(selection, [crop.patch], {
110
+ return await commit(selection, [crop.patch], {
110
111
  label: "Resize layer",
111
112
  coalesceKey,
112
113
  coalesceMs: Number.POSITIVE_INFINITY,
@@ -30,6 +30,10 @@ export function isTextBearingTag(tagName: string): boolean {
30
30
  return ["div", "span", "p", "strong", "h1", "h2", "h3", "h4", "h5", "h6"].includes(tagName);
31
31
  }
32
32
 
33
+ export function isEditableTextLeaf(el: HTMLElement): boolean {
34
+ return isTextBearingTag(el.tagName.toLowerCase()) && el.children.length === 0;
35
+ }
36
+
33
37
  /** Does this node render AT ALL, ignoring what it inherits? Sole owner of the
34
38
  * rule; the walk below only decides which nodes to ask it about. */
35
39
  function elementRendersItself(win: Window, el: HTMLElement): boolean {
@@ -23,8 +23,8 @@ import {
23
23
  getInlineStyles,
24
24
  getSelectorIndex,
25
25
  getSourceFileForElement,
26
+ isEditableTextLeaf,
26
27
  isHtmlElement,
27
- isTextBearingTag,
28
28
  } from "./domEditingDom";
29
29
  import {
30
30
  findElementForSelection,
@@ -35,10 +35,6 @@ import { isCompositionRootLayer } from "./domEditingRootLayer";
35
35
  import { withSelectorIndexPass } from "../../utils/sourceScopedSelectorIndex";
36
36
  import { type DomEditLayerWalkCache, readDomEditLayerWalkEntry } from "./domEditLayerWalkCache";
37
37
 
38
- export function isEditableTextLeaf(el: HTMLElement): boolean {
39
- return isTextBearingTag(el.tagName.toLowerCase()) && el.children.length === 0;
40
- }
41
-
42
38
  function sameTagChildIndex(el: HTMLElement): number {
43
39
  let index = 0;
44
40
  let sibling = el.previousElementSibling;
@@ -0,0 +1,252 @@
1
+ // @vitest-environment happy-dom
2
+ import { act } from "react";
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+ import type { DomEditSelection } from "./domEditingTypes";
5
+ import type { DomEditGroupPathOffsetCommit } from "./domEditOverlayGestures";
6
+ import { STUDIO_OFFSET_X_PROP, STUDIO_OFFSET_Y_PROP } from "./manualEdits";
7
+ import { isStudioManualEditGestureLiveIn } from "./manualEditsDom";
8
+ import { readTranslatePx } from "./plainTranslate";
9
+ import { moveDomGroupBy } from "./domGroupMoveBy";
10
+ import { GsapEditBlockedError } from "../../hooks/gsapEditOutcome";
11
+
12
+ Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
13
+
14
+ const mocks = vi.hoisted(() => ({ drag: vi.fn() }));
15
+ vi.mock("../../hooks/gsapRuntimeBridge", () => ({
16
+ tryGsapDragIntercept: mocks.drag,
17
+ tryGsapRotationIntercept: vi.fn(),
18
+ }));
19
+ vi.mock("../../hooks/useAnimatedPropertyCommit", () => ({
20
+ useAnimatedPropertyCommit: () => ({
21
+ commitAnimatedProperty: vi.fn(),
22
+ commitAnimatedProperties: vi.fn(),
23
+ }),
24
+ }));
25
+ vi.mock("../../hooks/useSafeGsapCommitMutation", () => ({
26
+ useGsapSaveFailureTelemetry: () => vi.fn(),
27
+ useSafeGsapCommitMutation: (commit: unknown) => commit,
28
+ }));
29
+ vi.mock("../../utils/studioSaveDiagnostics", async (importOriginal) => ({
30
+ ...(await importOriginal<typeof import("../../utils/studioSaveDiagnostics")>()),
31
+ trackStudioEditBlocked: vi.fn(),
32
+ trackStudioSaveFailure: vi.fn(),
33
+ }));
34
+
35
+ import { mountGsapAwareEditing } from "../../hooks/useGsapAwareEditing.testHelpers";
36
+
37
+ afterEach(() => {
38
+ vi.clearAllMocks();
39
+ document.body.innerHTML = "";
40
+ });
41
+
42
+ /** A preview iframe drawn at half size: 200x100 composition px in a 100x50 box. */
43
+ function preview(): Document {
44
+ const iframe = document.createElement("iframe");
45
+ document.body.append(iframe);
46
+ const win = iframe.contentWindow!;
47
+ Object.defineProperty(win, "frameElement", { configurable: true, value: iframe });
48
+ Object.defineProperty(win, "innerWidth", { configurable: true, value: 200 });
49
+ Object.defineProperty(win, "innerHeight", { configurable: true, value: 100 });
50
+ iframe.getBoundingClientRect = () => new DOMRect(50, 40, 100, 50);
51
+ return iframe.contentDocument!;
52
+ }
53
+
54
+ const turned = (deg: number, scale: number) => {
55
+ const r = (deg * Math.PI) / 180;
56
+ return [scale * Math.cos(r), scale * Math.sin(r), -scale * Math.sin(r), scale * Math.cos(r)];
57
+ };
58
+
59
+ /** An element whose box centre is `at` plus its own move carried through its ancestors' linear map. */
60
+ function layer(doc: Document, id: string, parent = [1, 0, 0, 1], gsap?: object) {
61
+ const element = Object.assign(doc.createElement("div"), gsap ? { _gsap: gsap } : {});
62
+ doc.body.append(element);
63
+ const own = () => {
64
+ if (!gsap) return readTranslatePx(element);
65
+ const read = (prop: string) => Number.parseFloat(element.style.getPropertyValue(prop)) || 0;
66
+ return { x: read(STUDIO_OFFSET_X_PROP), y: read(STUDIO_OFFSET_Y_PROP) };
67
+ };
68
+ element.getBoundingClientRect = () => {
69
+ const { x, y } = own();
70
+ const [a, b, c, d] = parent as [number, number, number, number];
71
+ return new DOMRect(60 + a * x + c * y - 20, 40 + b * x + d * y - 10, 40, 20);
72
+ };
73
+ const selection = {
74
+ element,
75
+ id,
76
+ selector: `#${id}`,
77
+ label: id,
78
+ capabilities: { canApplyManualOffset: true },
79
+ } as unknown as DomEditSelection;
80
+ const centre = () => {
81
+ const r = element.getBoundingClientRect();
82
+ return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
83
+ };
84
+ return { element, selection, centre };
85
+ }
86
+
87
+ function mountGroupCommit() {
88
+ const showToast = vi.fn();
89
+ const save = vi.fn().mockResolvedValue(undefined);
90
+ const stageElementPositionOffset = vi.fn(() => ({ save, rollback: vi.fn() }));
91
+ const { editing, root } = mountGsapAwareEditing({ showToast, stageElementPositionOffset });
92
+ const commit = (u: DomEditGroupPathOffsetCommit[], o?: { refusalToast?: boolean }) =>
93
+ editing().handleGsapAwareGroupPathOffsetCommit(u, o);
94
+ const moveBy = (moves: Parameters<typeof moveDomGroupBy>[0]) =>
95
+ act(() => moveDomGroupBy(moves, (u) => commit(u, { refusalToast: false })));
96
+ return {
97
+ commit: (u: DomEditGroupPathOffsetCommit[]) => commit(u),
98
+ moveBy,
99
+ showToast,
100
+ stageElementPositionOffset,
101
+ root,
102
+ };
103
+ }
104
+
105
+ const moved = (from: { x: number; y: number }, to: { x: number; y: number }) => ({
106
+ x: to.x - from.x,
107
+ y: to.y - from.y,
108
+ });
109
+
110
+ describe("handleDomGroupMoveBy", () => {
111
+ it("moves each box by its delta in composition px, saved under one undo entry", async () => {
112
+ const doc = preview();
113
+ const offset = layer(doc, "offset");
114
+ offset.element.style.setProperty("translate", "30px 0px");
115
+ const halved = layer(doc, "halved", [0.5, 0, 0, 0.5]);
116
+ const before = [offset.centre(), halved.centre()];
117
+ const { moveBy, stageElementPositionOffset, showToast, root } = mountGroupCommit();
118
+
119
+ await moveBy([
120
+ { selection: offset.selection, delta: { x: 10, y: 0 } },
121
+ { selection: halved.selection, delta: { x: 0, y: -5 } },
122
+ ]);
123
+
124
+ expect(moved(before[0]!, offset.centre())).toEqual({ x: 10, y: 0 });
125
+ expect(moved(before[1]!, halved.centre())).toEqual({ x: 0, y: -5 });
126
+ const calls = stageElementPositionOffset.mock.calls as unknown as unknown[][];
127
+ expect(calls.map((call) => call[1])).toEqual([
128
+ { x: 40, y: 0 },
129
+ { x: 0, y: -10 },
130
+ ]);
131
+ expect(calls[0]![3]).toMatch(/^group-drag:\d+$/);
132
+ expect(calls[1]![3]).toBe(calls[0]![3]);
133
+ expect(showToast).not.toHaveBeenCalled();
134
+ expect(isStudioManualEditGestureLiveIn(doc)).toBe(false);
135
+ act(() => root.unmount());
136
+ });
137
+
138
+ it("lands exactly under a half-scale parent with a rotated ancestor", async () => {
139
+ const doc = preview();
140
+ const tilted = layer(doc, "tilted", turned(30, 0.5));
141
+ const before = tilted.centre();
142
+ const { moveBy, root } = mountGroupCommit();
143
+
144
+ await moveBy([{ selection: tilted.selection, delta: { x: 10, y: 0 } }]);
145
+
146
+ const delta = moved(before, tilted.centre());
147
+ // Exact to the 0.001 px a translate is written with.
148
+ expect(delta.x).toBeCloseTo(10, 3);
149
+ expect(delta.y).toBeCloseTo(0, 3);
150
+ act(() => root.unmount());
151
+ });
152
+
153
+ it("moves a member GSAP positions through the GSAP write, planned and written at one delta", async () => {
154
+ mocks.drag.mockResolvedValue({ status: "persisted" });
155
+ const doc = preview();
156
+ const animated = layer(doc, "animated", [1, 0, 0, 1], { renderTransform: () => {} });
157
+ const before = animated.centre();
158
+ const { moveBy, stageElementPositionOffset, root } = mountGroupCommit();
159
+
160
+ await moveBy([{ selection: animated.selection, delta: { x: 10, y: 0 } }]);
161
+
162
+ const write = mocks.drag.mock.calls.find((call) => !call[6]?.preflightOnly);
163
+ expect(write?.[0]).toBe(animated.selection);
164
+ expect(write?.[1]).toEqual({ x: 10, y: 0 });
165
+ const plan = mocks.drag.mock.calls.find((call) => call[6]?.preflightOnly);
166
+ expect(plan?.[1]).toEqual(write?.[1]);
167
+ expect(moved(before, animated.centre())).toEqual({ x: 10, y: 0 });
168
+ expect(stageElementPositionOffset).not.toHaveBeenCalled();
169
+ act(() => root.unmount());
170
+ });
171
+
172
+ it("skips a zero delta, and writes nothing when every delta is zero", async () => {
173
+ const doc = preview();
174
+ const still = layer(doc, "still");
175
+ const { moveBy, stageElementPositionOffset, root } = mountGroupCommit();
176
+
177
+ await moveBy([{ selection: still.selection, delta: { x: 0, y: 0 } }]);
178
+
179
+ expect(stageElementPositionOffset).not.toHaveBeenCalled();
180
+ act(() => root.unmount());
181
+ });
182
+
183
+ it.each([
184
+ [
185
+ "a member that can't take a manual move",
186
+ (doc: Document) => {
187
+ const locked = layer(doc, "locked");
188
+ Object.assign(locked.selection.capabilities, {
189
+ canApplyManualOffset: false,
190
+ reasonIfDisabled: "Locked by a helper loop.",
191
+ });
192
+ return locked;
193
+ },
194
+ /helper loop/,
195
+ ],
196
+ [
197
+ "a member an animation took over",
198
+ (doc: Document) => layer(doc, "folded", [1, 0, 0, 1], { x: "94px" }),
199
+ /animation took over/,
200
+ ],
201
+ [
202
+ "a member the GSAP write refuses",
203
+ (doc: Document) => {
204
+ mocks.drag.mockResolvedValue({ status: "blocked", reason: "no-selector" });
205
+ return layer(doc, "blocked", [1, 0, 0, 1], { renderTransform: () => {} });
206
+ },
207
+ new GsapEditBlockedError("no-selector").message,
208
+ ],
209
+ ])("refuses %s before any write, without a Studio toast", async (_, make, reason) => {
210
+ const doc = preview();
211
+ const fine = layer(doc, "fine");
212
+ const refused = make(doc);
213
+ const before = [fine.centre(), refused.centre()];
214
+ const { moveBy, stageElementPositionOffset, showToast, root } = mountGroupCommit();
215
+
216
+ await expect(
217
+ moveBy([
218
+ { selection: fine.selection, delta: { x: 10, y: 0 } },
219
+ { selection: refused.selection, delta: { x: 10, y: 0 } },
220
+ ]),
221
+ ).rejects.toThrow(reason);
222
+
223
+ expect(stageElementPositionOffset).not.toHaveBeenCalled();
224
+ expect(mocks.drag.mock.calls.some((call) => !call[6]?.preflightOnly)).toBe(false);
225
+ expect(showToast).not.toHaveBeenCalled();
226
+ expect([fine.centre(), refused.centre()]).toEqual(before);
227
+ expect(isStudioManualEditGestureLiveIn(doc)).toBe(false);
228
+ act(() => root.unmount());
229
+ });
230
+
231
+ it.each([
232
+ ["an animation took over", () => layer(preview(), "folded", [1, 0, 0, 1], { x: "94px" }), true],
233
+ [
234
+ "the GSAP write refuses",
235
+ () => {
236
+ mocks.drag.mockResolvedValue({ status: "blocked", reason: "no-selector" });
237
+ return layer(preview(), "blocked", [1, 0, 0, 1], { renderTransform: () => {} });
238
+ },
239
+ false,
240
+ ],
241
+ ])("still toasts on a canvas group drag when %s", async (_, make, plainTranslate) => {
242
+ const { selection } = make();
243
+ const { commit, showToast, root } = mountGroupCommit();
244
+
245
+ await expect(
246
+ act(() => commit([{ selection, next: { x: 1, y: 2 }, plainTranslate }])),
247
+ ).rejects.toThrow();
248
+
249
+ expect(showToast).toHaveBeenCalledWith(expect.any(String), "error");
250
+ act(() => root.unmount());
251
+ });
252
+ });
@@ -0,0 +1,95 @@
1
+ import type { DomEditSelection } from "./domEditing";
2
+ import type { DomEditGroupPathOffsetCommit } from "./domEditOverlayGestures";
3
+ import { canCanvasNudgeTargets } from "./domEditNudge";
4
+ import { isStudioManualEditGestureCurrent, restoreStudioPathOffset } from "./manualEdits";
5
+ import { countStudioManualEditSave } from "./manualEditsDom";
6
+ import {
7
+ applyManualOffsetDragCommit,
8
+ createManualOffsetDragMember,
9
+ endManualOffsetDragMembers,
10
+ restoreManualOffsetDragMembers,
11
+ toTopScreenPoint,
12
+ type ManualOffsetDragMember,
13
+ } from "./manualOffsetDrag";
14
+
15
+ export interface DomGroupMove {
16
+ selection: DomEditSelection;
17
+ /** Composition px: the element's box moves by exactly this in the film. */
18
+ delta: { x: number; y: number };
19
+ }
20
+
21
+ /** Composition px to the top page's px; the preview frames only scale and shift. */
22
+ function toScreen(doc: Document, delta: { x: number; y: number }) {
23
+ const origin = toTopScreenPoint(doc, { x: 0, y: 0 });
24
+ const moved = toTopScreenPoint(doc, delta);
25
+ if (!origin || !moved) throw new Error("The preview has no measurable size.");
26
+ return { x: moved.x - origin.x, y: moved.y - origin.y };
27
+ }
28
+
29
+ function memberFor(
30
+ selection: DomEditSelection,
31
+ scale: { x: number; y: number },
32
+ ): ManualOffsetDragMember {
33
+ const { element } = selection;
34
+ const box = element.getBoundingClientRect();
35
+ const result = createManualOffsetDragMember({
36
+ key: selection.id ?? selection.selector ?? selection.label,
37
+ selection,
38
+ element,
39
+ rect: {
40
+ left: box.left,
41
+ top: box.top,
42
+ width: box.width,
43
+ height: box.height,
44
+ editScaleX: scale.x,
45
+ editScaleY: scale.y,
46
+ },
47
+ gesture: "drag",
48
+ });
49
+ if (!result.ok) throw new Error(result.reason);
50
+ return result.member;
51
+ }
52
+
53
+ /**
54
+ * Moves each element by its delta as one group drag would drop it: one save, one undo entry.
55
+ * Any member that can't move refuses the whole call before anything is drawn or written.
56
+ */
57
+ export async function moveDomGroupBy(
58
+ moves: DomGroupMove[],
59
+ commitGroup: (updates: DomEditGroupPathOffsetCommit[]) => Promise<unknown>,
60
+ ): Promise<void> {
61
+ const moving = moves.filter(({ delta }) => delta.x !== 0 || delta.y !== 0);
62
+ if (moving.length === 0) return;
63
+ const selections = moving.map(({ selection }) => selection);
64
+ if (!canCanvasNudgeTargets(selections)) {
65
+ const blocked = selections.find((s) => !s.capabilities.canApplyManualOffset);
66
+ throw new Error(blocked?.capabilities.reasonIfDisabled ?? `${blocked?.label} can't be moved.`);
67
+ }
68
+ const screens = moving.map(({ selection, delta }) =>
69
+ toScreen(selection.element.ownerDocument, delta),
70
+ );
71
+ const members: ManualOffsetDragMember[] = [];
72
+ try {
73
+ for (const selection of selections)
74
+ members.push(memberFor(selection, toScreen(selection.element.ownerDocument, { x: 1, y: 1 })));
75
+ } catch (error) {
76
+ restoreManualOffsetDragMembers(members);
77
+ throw error;
78
+ }
79
+ const updates = members.map((member, index) => ({
80
+ selection: member.selection,
81
+ next: applyManualOffsetDragCommit(member, screens[index]!.x, screens[index]!.y),
82
+ plainTranslate: member.plainTranslate,
83
+ }));
84
+ try {
85
+ await countStudioManualEditSave(members[0]!.element, () => commitGroup(updates));
86
+ } catch (error) {
87
+ for (const member of members) {
88
+ if (isStudioManualEditGestureCurrent(member.element, member.gestureToken))
89
+ restoreStudioPathOffset(member.element, member.initialPathOffset);
90
+ }
91
+ throw error;
92
+ } finally {
93
+ endManualOffsetDragMembers(members);
94
+ }
95
+ }
@@ -5,7 +5,7 @@ import type { GestureState } from "./domEditOverlayGestures";
5
5
  import { createDomEditOverlayGestureHandlers } from "./useDomEditOverlayGestures";
6
6
  import {
7
7
  hasStudioPendingEdits,
8
- revertNewestStudioPendingEdit,
8
+ paintBackNewestStudioPendingEdit,
9
9
  } from "../../utils/studioPendingEdits";
10
10
 
11
11
  afterEach(() => {
@@ -59,13 +59,13 @@ it("a drag whose save is still running can be painted back at once, and shown ag
59
59
  const moved = element.style.getPropertyValue("translate");
60
60
  expect(moved).not.toBe("40px 30px");
61
61
 
62
- const showAgain = revertNewestStudioPendingEdit();
62
+ const shown = paintBackNewestStudioPendingEdit();
63
63
  expect(element.style.getPropertyValue("translate")).toBe("40px 30px");
64
- expect(revertNewestStudioPendingEdit()).toBeNull();
65
- showAgain!();
64
+ expect(paintBackNewestStudioPendingEdit()).toBeNull();
65
+ shown!.showAgain();
66
66
  expect(element.style.getPropertyValue("translate")).toBe(moved);
67
67
 
68
68
  saved();
69
69
  await vi.waitFor(() => expect(hasStudioPendingEdits()).toBe(false));
70
- expect(revertNewestStudioPendingEdit()).toBeNull();
70
+ expect(paintBackNewestStudioPendingEdit()).toBeNull();
71
71
  });