@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
@@ -24,6 +24,7 @@ import {
24
24
  import { fxAutomationTarget, type HfAutomationLane } from "@hyperframes/core/audio-automation";
25
25
  import { clipStart } from "./propertyPanelAudioFxGroupUtils.js";
26
26
  import { clipAudioOnItsClock, type ClipClock } from "./clipAudioClock.js";
27
+ import { studioApiFetch } from "../../utils/studioApiFetch";
27
28
 
28
29
  /** Decode rate every carve measurement shares — see `measureCarve`. */
29
30
  const DECODE_SAMPLE_RATE = 48000;
@@ -95,7 +96,7 @@ export async function measureCarve(
95
96
  if (!Ctor) return null;
96
97
  // Each clip as it plays: trimmed to its in-point and duration, and at its own speed.
97
98
  const decode = async (relative: string, clock: ClipClock): Promise<Float32Array> => {
98
- const res = await fetch(new URL(relative, doc.baseURI).href);
99
+ const res = await studioApiFetch(new URL(relative, doc.baseURI).href);
99
100
  const buffer = await new Ctor(1, 1, DECODE_SAMPLE_RATE).decodeAudioData(
100
101
  await res.arrayBuffer(),
101
102
  );
@@ -26,6 +26,7 @@ import { trackLeveller } from "./audioFxTelemetry.js";
26
26
  import type { DomEditSelection } from "./domEditingTypes";
27
27
  import { clipSourceWindow, readClipClock } from "./clipAudioClock.js";
28
28
  import { useAuditionTransport } from "./useAuditionTransport.js";
29
+ import { studioApiFetch } from "../../utils/studioApiFetch";
29
30
 
30
31
  /**
31
32
  * Rate the track is decoded at. Analysis is self-consistent because it reads
@@ -63,7 +64,7 @@ export function useFxLevelling(
63
64
  (window as unknown as { webkitOfflineAudioContext?: typeof OfflineAudioContext })
64
65
  .webkitOfflineAudioContext;
65
66
  if (!Ctor) return null;
66
- const res = await fetch(new URL(src, doc.baseURI).href);
67
+ const res = await studioApiFetch(new URL(src, doc.baseURI).href);
67
68
  const buffer = await new Ctor(1, 1, DECODE_SAMPLE_RATE).decodeAudioData(
68
69
  await res.arrayBuffer(),
69
70
  );
@@ -17,6 +17,7 @@ import { buildProjectApiPath } from "../../utils/projectRouting";
17
17
 
18
18
  import type { FeedbackContext } from "./feedbackTrigger";
19
19
  import { isMediaFile } from "../../utils/mediaTypes";
20
+ import { studioApiFetch } from "../../utils/studioApiFetch";
20
21
 
21
22
  /** Written by `hyperframes init`; absent in a hand-made or copied project. */
22
23
  const CONFIG_FILE = "hyperframes.json";
@@ -53,7 +54,7 @@ export async function captureProjectProvenance(
53
54
  if (!scaffolded) return;
54
55
 
55
56
  try {
56
- const res = await fetch(
57
+ const res = await studioApiFetch(
57
58
  buildProjectApiPath(projectId, `/files/${encodeURIComponent(CONFIG_FILE)}`),
58
59
  );
59
60
  if (!res.ok) return;
@@ -126,6 +126,7 @@ describe("NLEProvider — composition source-map scoping", () => {
126
126
  expect(observed.filter((map) => map.size === 0)).toHaveLength(2);
127
127
  expect(fetchMock).toHaveBeenCalledWith("/api/projects/project-b/files/index.html", {
128
128
  signal: expect.any(AbortSignal),
129
+ credentials: "omit",
129
130
  });
130
131
  expect(observed.at(-1)).toEqual(new Map([["b-comp", "compositions/b.html"]]));
131
132
 
@@ -9,6 +9,7 @@ import { setCompositionSourceMap } from "../editor/domEditingDom";
9
9
  import { ensureMotionPathPluginLoaded } from "../../utils/gsapSoftReload";
10
10
  import { useAssetPreviewStore } from "../../utils/assetPreviewStore";
11
11
  import { createStableContext } from "../../utils/hmrStableContext";
12
+ import { studioApiFetch } from "../../utils/studioApiFetch";
12
13
 
13
14
  export function shouldDisableTimelineWhileCompositionLoading(compositionLoading: boolean): boolean {
14
15
  return compositionLoading;
@@ -209,7 +210,7 @@ export function NLEProvider({
209
210
  setCompositionSourceMap(emptyMap);
210
211
  onCompIdToSrcChangeRef.current?.(emptyMap);
211
212
 
212
- fetch(buildProjectApiPath(projectId, `/files/index.html`), {
213
+ studioApiFetch(buildProjectApiPath(projectId, `/files/index.html`), {
213
214
  signal: controller.signal,
214
215
  })
215
216
  .then((r) => {
@@ -12,7 +12,7 @@ import {
12
12
  type RefObject,
13
13
  } from "react";
14
14
  import { Player } from "../../player";
15
- import type { PreviewIframeSlot } from "../../player/hooks/useTimelineSyncCallbacks";
15
+ import type { NLEContextValue } from "./NLEContext";
16
16
  import {
17
17
  DEFAULT_PREVIEW_ZOOM,
18
18
  canStartPreviewPan,
@@ -29,16 +29,18 @@ import { RULER_GUTTER_PX, usePreviewGuidesStore } from "../editor/previewGuidesS
29
29
  import { PreviewZoomOverlay, usePreviewNavigator } from "./PreviewZoomOverlay";
30
30
  import { usePreviewFirstFrameTelemetry } from "../../player/hooks/usePreviewFirstFrameTelemetry";
31
31
  import { PreviewPoster, usePreviewPoster } from "./PreviewPoster";
32
- interface NLEPreviewProps {
33
- projectId: string;
32
+ interface NLEPreviewProps extends Pick<
33
+ NLEContextValue,
34
+ | "projectId"
35
+ | "onIframeLoad"
36
+ | "previewSlots"
37
+ | "onShadowIframeLoad"
38
+ | "onShadowReadyChange"
39
+ | "onShadowError"
40
+ | "setShadowIframeNode"
41
+ | "resetPreviewSlots"
42
+ > {
34
43
  iframeRef: RefObject<HTMLIFrameElement | null>;
35
- onIframeLoad: () => void;
36
- previewSlots: PreviewIframeSlot[];
37
- onShadowIframeLoad: (gen: number) => void;
38
- onShadowReadyChange: (gen: number, ready: boolean) => void;
39
- onShadowError: (gen: number, message: string) => void;
40
- setShadowIframeNode: (node: HTMLIFrameElement | null) => void;
41
- resetPreviewSlots: () => void;
42
44
  onCompositionLoadingChange?: (loading: boolean) => void;
43
45
  portrait?: boolean;
44
46
  directUrl?: string;
@@ -6,6 +6,7 @@ import {
6
6
  type ThumbnailRequest,
7
7
  } from "../../player/lib/thumbnailScheduler";
8
8
  import type { PreviewCompositionSize } from "../../utils/previewCompositionSize";
9
+ import { studioApiFetch } from "../../utils/studioApiFetch";
9
10
 
10
11
  /** Frame 0 as the thumbnail route last rendered it; `cachedOnly` never starts a render. */
11
12
  function previewPosterUrl(projectId: string, cachedOnly: boolean): string {
@@ -107,7 +108,7 @@ export function renderPosterForNextOpen(projectId: string): void {
107
108
  priority: "overscan",
108
109
  discardWhenReleased: true,
109
110
  load: async (signal) => {
110
- await (await fetch(url, { signal })).arrayBuffer();
111
+ await (await studioApiFetch(url, { signal })).arrayBuffer();
111
112
  return { value: { kind: "image", url, aspect: 16 / 9 }, weight: 0 };
112
113
  },
113
114
  };
@@ -409,6 +409,7 @@ describe("useTimelineEditCallbacks — flat tween keyframe lanes", () => {
409
409
  expect(mocks.actions.handleGsapRemoveAllKeyframes).toHaveBeenCalledWith(
410
410
  scaleAnimation.id,
411
411
  circleSelection,
412
+ false,
412
413
  );
413
414
  view.unmount();
414
415
  });
@@ -1,4 +1,5 @@
1
1
  import type { TimelineEditOutcome } from "../../hooks/timelineEditPermission";
2
+ import { trackKeyframeUsage } from "../../utils/keyframeUsage";
2
3
  import { useCallback, useMemo } from "react";
3
4
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
4
5
  import type { TimelineElement } from "../../player";
@@ -259,7 +260,12 @@ export function useTimelineEditCallbacks({
259
260
  if (!selection) return;
260
261
  // Serial: each removal rewrites the same source file, so dispatching
261
262
  // them together would have the later writes read a pre-edit document.
262
- for (const anim of anims) await handleGsapRemoveAllKeyframes(anim.id, selection);
263
+ let changed = false;
264
+ for (const anim of anims) {
265
+ const result = await handleGsapRemoveAllKeyframes(anim.id, selection, false);
266
+ changed = result || changed;
267
+ }
268
+ if (changed) trackKeyframeUsage("remove_all");
263
269
  });
264
270
  },
265
271
  onDeleteKeyframe: (elId, keyframe) => {
@@ -1,4 +1,5 @@
1
1
  import { useCallback, useEffect, useRef, useState } from "react";
2
+ import { studioApiFetch } from "../../utils/studioApiFetch";
2
3
 
3
4
  /**
4
5
  * What the dev server knows about this machine's FFmpeg, as reported by
@@ -59,7 +60,7 @@ function parseStatus(body: unknown): ProbeResult {
59
60
 
60
61
  async function probe(): Promise<ProbeResult> {
61
62
  try {
62
- const res = await fetch("/api/environment/ffmpeg");
63
+ const res = await studioApiFetch("/api/environment/ffmpeg");
63
64
  return res.ok ? parseStatus(await res.json()) : null;
64
65
  } catch {
65
66
  return null;
@@ -8,6 +8,7 @@ import { generateId } from "../../utils/generateId";
8
8
  import { readServerError } from "./serverError";
9
9
  import { ffmpegInstallMessage, useFfmpegStatus } from "./useFfmpegStatus";
10
10
  import { requestStudioFeedback, type FeedbackContext } from "../feedback/feedbackTrigger";
11
+ import { studioApiFetch } from "../../utils/studioApiFetch";
11
12
 
12
13
  export interface RenderJob {
13
14
  id: string;
@@ -126,7 +127,7 @@ export function useRenderQueue(
126
127
  const loadRenders = useCallback(async () => {
127
128
  if (!projectId) return;
128
129
  try {
129
- const res = await fetch(buildProjectApiPath(projectId, `/renders`));
130
+ const res = await studioApiFetch(buildProjectApiPath(projectId, `/renders`));
130
131
  if (!res.ok) {
131
132
  setLoadError(`Couldn't load render history (server error ${res.status}).`);
132
133
  return;
@@ -262,7 +263,7 @@ export function useRenderQueue(
262
263
  }
263
264
  let res: Response;
264
265
  try {
265
- res = await fetch(buildProjectApiPath(projectId, `/render`), {
266
+ res = await studioApiFetch(buildProjectApiPath(projectId, `/render`), {
266
267
  method: "POST",
267
268
  headers: { "Content-Type": "application/json" },
268
269
  body: JSON.stringify(body),
@@ -376,7 +377,7 @@ export function useRenderQueue(
376
377
  ),
377
378
  );
378
379
  try {
379
- const res = await fetch(`/api/render/${jobId}/cancel`, { method: "POST" });
380
+ const res = await studioApiFetch(`/api/render/${jobId}/cancel`, { method: "POST" });
380
381
  if (!res.ok && res.status !== 404) {
381
382
  setActionError("Couldn't cancel on the server — the render may still be running.");
382
383
  return;
@@ -403,7 +404,7 @@ export function useRenderQueue(
403
404
  setActionError(null);
404
405
  closeActiveEventSource(jobId);
405
406
  try {
406
- const res = await fetch(`/api/render/${jobId}`, { method: "DELETE" });
407
+ const res = await studioApiFetch(`/api/render/${jobId}`, { method: "DELETE" });
407
408
  if (!res.ok) {
408
409
  setActionError("Couldn't delete the render — it's still on disk.");
409
410
  return;
@@ -1,5 +1,6 @@
1
1
  import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
2
2
  import { filename } from "./assetHelpers";
3
+ import { menuClasses } from "../ui/menuStyle";
3
4
 
4
5
  /** Reject names that would escape the asset directory or break paths. */
5
6
  function isValidAssetName(name: string): boolean {
@@ -94,8 +95,7 @@ export function ContextMenu({
94
95
  onClose();
95
96
  }, [renameDraft, asset, onRename, onClose]);
96
97
 
97
- const itemCls =
98
- "w-full text-left px-3 py-1.5 text-neutral-300 hover:bg-neutral-800 focus-visible:bg-neutral-800 outline-hidden active:bg-neutral-700/70 transition-colors";
98
+ const itemCls = `${menuClasses.row} ${menuClasses.rowEnabled} active:bg-neutral-700/70 transition-colors`;
99
99
 
100
100
  return (
101
101
  <div
@@ -110,7 +110,7 @@ export function ContextMenu({
110
110
  ref={menuRef}
111
111
  role="menu"
112
112
  aria-label={`Actions for ${filename(asset)}`}
113
- className="absolute bg-neutral-900 border border-neutral-700 rounded-lg shadow-xl py-1 min-w-[160px] text-xs"
113
+ className={`${menuClasses.panel} absolute min-w-[160px] text-xs`}
114
114
  style={{ left: pos.x, top: pos.y }}
115
115
  onClick={(e) => e.stopPropagation()}
116
116
  >
@@ -147,7 +147,7 @@ export function ContextMenu({
147
147
  <button
148
148
  role="menuitem"
149
149
  onClick={() => setMode("confirm-delete")}
150
- className={`${itemCls} text-danger-ink`}
150
+ className={`${menuClasses.row} ${menuClasses.rowDanger} active:bg-neutral-700/70 transition-colors`}
151
151
  >
152
152
  Delete
153
153
  </button>
@@ -16,6 +16,7 @@ import {
16
16
  import { AudioRow } from "./AudioRow";
17
17
  import { GlobalAssetsView } from "./GlobalAssetsView";
18
18
  import { AssetCard, FontRow } from "./AssetCard";
19
+ import { studioApiFetch } from "../../utils/studioApiFetch";
19
20
 
20
21
  interface AssetsTabProps {
21
22
  projectId: string;
@@ -217,7 +218,7 @@ export const AssetsTab = memo(function AssetsTab({
217
218
  useEffect(() => {
218
219
  if (manifest404Ref.current.has(projectId)) return;
219
220
  let cancelled = false;
220
- fetch(buildProjectApiPath(projectId, `/preview/.media/manifest.jsonl`))
221
+ studioApiFetch(buildProjectApiPath(projectId, `/preview/.media/manifest.jsonl`))
221
222
  .then((r) => {
222
223
  if (!r.ok) {
223
224
  manifest404Ref.current.add(projectId);
@@ -1,4 +1,5 @@
1
1
  import { useEffect, useMemo, useState } from "react";
2
+ import { studioApiFetch } from "../../utils/studioApiFetch";
2
3
 
3
4
  // Cross-project asset view — the global media-use cache (~/.media), fetched from
4
5
  // /api/assets/global. Self-contained (owns its fetch + state) so AssetsTab stays
@@ -43,7 +44,7 @@ export function GlobalAssetsView({ searchQuery }: { searchQuery: string }) {
43
44
  const [records, setRecords] = useState<GlobalAssetRecord[] | null>(null);
44
45
  useEffect(() => {
45
46
  let cancelled = false;
46
- fetch("/api/assets/global")
47
+ studioApiFetch("/api/assets/global")
47
48
  .then((r) => (r.ok ? r.json() : { assets: [] }))
48
49
  .then((d) => {
49
50
  if (!cancelled) setRecords(Array.isArray(d.assets) ? d.assets : []);
@@ -0,0 +1,61 @@
1
+ import { readdirSync, readFileSync, statSync } from "node:fs";
2
+ import { join, relative } from "node:path";
3
+ import { describe, expect, it } from "vitest";
4
+ import { menuClasses } from "./menuStyle";
5
+
6
+ const spacing = /(^| )-?(p|py|pt|pb|m|my|mt|mb)-\d/;
7
+ const root = join(__dirname, "../..");
8
+
9
+ /** Panels that are not a column of rows: a dialog, a crop toolbar, a preset card and a bare speed list. */
10
+ const NOT_ROW_MENUS = new Set([
11
+ "player/components/AudioGainDialog.tsx",
12
+ "components/editor/CropPresetBar.tsx",
13
+ "components/editor/EaseCurveSection.tsx",
14
+ "player/components/SpeedMenu.tsx",
15
+ ]);
16
+
17
+ function sources(dir: string): string[] {
18
+ return readdirSync(dir).flatMap((name) => {
19
+ const path = join(dir, name);
20
+ if (statSync(path).isDirectory()) return sources(path);
21
+ return /\.tsx$/.test(name) && !/\.test\./.test(name) ? [path] : [];
22
+ });
23
+ }
24
+
25
+ const files = sources(root).map((path) => ({
26
+ name: relative(root, path).replaceAll("\\", "/"),
27
+ text: readFileSync(path, "utf8"),
28
+ }));
29
+
30
+ describe("menu style", () => {
31
+ it("adds no padding or margin around a menu's rows", () => {
32
+ for (const key of ["panel", "group", "divider"] as const)
33
+ expect(menuClasses[key], key).not.toMatch(spacing);
34
+ });
35
+
36
+ it("clips the panel to its own rounded corners, so a highlight never pokes out of them", () => {
37
+ expect(menuClasses.panel).toContain("overflow-hidden");
38
+ expect(menuClasses.panel).toMatch(/(^| )rounded-/);
39
+ });
40
+
41
+ it("is the only place a menu panel's fill, border and shadow are written", () => {
42
+ const handBuilt = files
43
+ .filter(({ name }) => !NOT_ROW_MENUS.has(name))
44
+ .filter(({ text }) =>
45
+ [...text.matchAll(/["'`]([^"'`]*)["'`]/g)].some(
46
+ ([, literal]) =>
47
+ /shadow-(lg|xl)/.test(literal!) && /bg-(neutral-900|panel-bg-2)\b/.test(literal!),
48
+ ),
49
+ )
50
+ .map(({ name }) => name);
51
+ expect(handBuilt).toEqual([]);
52
+ });
53
+
54
+ it("is the panel of every role=menu, wherever its element is written", () => {
55
+ const unowned = files
56
+ .filter(({ text }) => text.includes('role="menu"'))
57
+ .filter(({ name, text }) => !NOT_ROW_MENUS.has(name) && !text.includes("menuClasses.panel"))
58
+ .map(({ name }) => name);
59
+ expect(unowned).toEqual([]);
60
+ });
61
+ });
@@ -0,0 +1,10 @@
1
+ /** The one owner of context menu spacing: rows touch the panel, which clips to its rounded corners. */
2
+ export const menuClasses = {
3
+ panel: "overflow-hidden rounded-md border border-neutral-700 bg-neutral-900 shadow-lg",
4
+ group: "empty:hidden border-b border-neutral-700/60",
5
+ divider: "border-t border-neutral-700/60",
6
+ row: "w-full px-3 py-1.5 text-left text-xs outline-hidden",
7
+ rowEnabled: "text-neutral-300 hover:bg-neutral-800 focus-visible:bg-neutral-800 cursor-pointer",
8
+ rowDanger: "text-danger-ink hover:bg-danger/25 focus-visible:bg-danger/25 cursor-pointer",
9
+ rowDisabled: "text-neutral-600 cursor-not-allowed",
10
+ } as const;
@@ -24,6 +24,7 @@ const SAVES_AN_EDIT = {
24
24
  handleDomAttributeBatchCommit: true,
25
25
  handleDomPathOffsetCommit: true,
26
26
  handleDomGroupPathOffsetCommit: true,
27
+ handleDomGroupMoveBy: true,
27
28
  handleDomZIndexReorderCommit: true,
28
29
  handleDomBoxSizeCommit: true,
29
30
  handleDomRotationCommit: true,
@@ -179,6 +180,7 @@ export function DomEditProvider({
179
180
  handleDomAttributeBatchCommit,
180
181
  handleDomPathOffsetCommit,
181
182
  handleDomGroupPathOffsetCommit,
183
+ handleDomGroupMoveBy,
182
184
  handleDomZIndexReorderCommit,
183
185
  handleDomBoxSizeCommit,
184
186
  handleDomRotationCommit,
@@ -251,7 +253,7 @@ export function DomEditProvider({
251
253
  commitMutationRef.current = commitMutation;
252
254
 
253
255
  const stableCommitMutation = useCallback<DomEditActionsValue["commitMutation"]>(
254
- (mutation, options) => commitMutationRef.current(mutation, options),
256
+ (mutation, options, selection) => commitMutationRef.current(mutation, options, selection),
255
257
  [],
256
258
  );
257
259
 
@@ -274,6 +276,7 @@ export function DomEditProvider({
274
276
  handleDomAttributeBatchCommit,
275
277
  handleDomPathOffsetCommit,
276
278
  handleDomGroupPathOffsetCommit,
279
+ handleDomGroupMoveBy,
277
280
  handleDomZIndexReorderCommit,
278
281
  handleDomBoxSizeCommit,
279
282
  handleDomRotationCommit,
@@ -350,6 +353,7 @@ export function DomEditProvider({
350
353
  handleDomAttributeBatchCommit,
351
354
  handleDomPathOffsetCommit,
352
355
  handleDomGroupPathOffsetCommit,
356
+ handleDomGroupMoveBy,
353
357
  handleDomZIndexReorderCommit,
354
358
  handleDomBoxSizeCommit,
355
359
  handleDomRotationCommit,
@@ -46,6 +46,7 @@ const EDIT_CALLBACK_KEY_SET: Record<keyof TimelineEditCallbacks, true> = {
46
46
  onRazorSplit: true,
47
47
  onRazorSplitAll: true,
48
48
  onFreezeFrame: true,
49
+ clipMenuTools: true,
49
50
  onNotice: true,
50
51
  onDeleteKeyframe: true,
51
52
  onDeleteAllKeyframes: true,
@@ -79,7 +79,7 @@ describe("DOM editing package exports", () => {
79
79
  front.id = "front";
80
80
  parent.append(back, front);
81
81
  document.body.append(parent);
82
- const commitZ = vi.fn(() => Promise.resolve());
82
+ const commitZ = vi.fn(async () => ({ durable: true, changed: true }));
83
83
  const session = {
84
84
  handleDomZIndexReorderCommit: commitZ,
85
85
  } as unknown as Parameters<typeof DomEditProvider>[0]["value"];
@@ -107,6 +107,11 @@ describe("DOM editing package exports", () => {
107
107
  await act(async () => {
108
108
  expect(zOrder?.apply(sel, toFront)).toBe(true);
109
109
  });
110
+ expect(trackStudioEvent).toHaveBeenCalledWith("feature_used", {
111
+ feature: "z_order",
112
+ surface: "preview",
113
+ method: "button",
114
+ });
110
115
  expect(commitZ).toHaveBeenCalledTimes(1);
111
116
  expect(commitZ).toHaveBeenCalledWith(
112
117
  expect.arrayContaining([expect.objectContaining({ element: back, id: "back" })]),
@@ -159,3 +164,6 @@ describe("DOM editing package exports", () => {
159
164
  await act(async () => root.unmount());
160
165
  });
161
166
  });
167
+
168
+ vi.mock("./utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
169
+ import { trackStudioEvent } from "./utils/studioTelemetry";
@@ -94,7 +94,7 @@ export type DomEditCommitDeclineReason =
94
94
  | "persist-failed";
95
95
 
96
96
  export type DomEditCommitOutcome =
97
- | { ok: true; persistence?: DomEditPersistOutcome }
97
+ | { ok: true; persistence?: DomEditPersistOutcome; changed?: boolean }
98
98
  | { ok: false; reason: DomEditCommitDeclineReason };
99
99
 
100
100
  export function domEditCommitDeclined(reason: DomEditCommitDeclineReason): DomEditCommitOutcome {
@@ -4,6 +4,7 @@ import type React from "react";
4
4
  import { act } from "react";
5
5
  import { createRoot, type Root } from "react-dom/client";
6
6
  import type { DomEditSelection } from "../components/editor/domEditing";
7
+ import { buildStableSelector, getSelectorIndex } from "../components/editor/domEditingDom";
7
8
 
8
9
  export function installReactActEnvironment(): void {
9
10
  Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
@@ -61,3 +62,25 @@ export function makeSelection(label: string, element: HTMLElement): DomEditSelec
61
62
  },
62
63
  };
63
64
  }
65
+
66
+ /** Built as getDomLayerPatchTarget builds it: a bare class for an element with no id or hf-id. */
67
+ export function stableSelectionFor(el: HTMLElement): DomEditSelection {
68
+ const selector = buildStableSelector(el);
69
+ return {
70
+ element: el,
71
+ id: el.id || undefined,
72
+ hfId: el.getAttribute("data-hf-id") || undefined,
73
+ selector,
74
+ selectorIndex: getSelectorIndex(document, el, selector, "index.html", null),
75
+ sourceFile: "index.html",
76
+ dataAttributes: { start: "0", duration: "2" },
77
+ } as unknown as DomEditSelection;
78
+ }
79
+
80
+ export function mountGroupSiblings(count: number, withIds = false): HTMLElement[] {
81
+ const groups = Array.from({ length: count }, (_, i) =>
82
+ withIds ? `<div class="group" id="group-${i}"></div>` : `<div class="group"></div>`,
83
+ );
84
+ document.body.innerHTML = `<div id="scene" class="clip" data-start="0" data-duration="2">${groups.join("")}</div>`;
85
+ return Array.from(document.querySelectorAll<HTMLElement>(".group"));
86
+ }
@@ -0,0 +1,73 @@
1
+ // @vitest-environment jsdom
2
+ import { expect, it } from "vitest";
3
+ import type { DomEditSelection } from "../components/editor/domEditing";
4
+ import type { PatchOperation } from "../utils/sourcePatcher";
5
+ import { commitDomStyles, type DomStyleCommitContext } from "./domStyleCommit";
6
+
7
+ const selection = {
8
+ id: "target",
9
+ selector: "#target",
10
+ label: "Target",
11
+ tagName: "div",
12
+ sourceFile: "index.html",
13
+ compositionPath: "index.html",
14
+ textFields: [],
15
+ inlineStyles: {},
16
+ computedStyles: {},
17
+ dataAttributes: {},
18
+ capabilities: { canSelect: true, canEditStyles: true },
19
+ } as unknown as DomEditSelection;
20
+
21
+ async function savedPatches(styles: Record<string, string>) {
22
+ const saves: Array<{ operations: PatchOperation[]; label?: string }> = [];
23
+ const context: DomStyleCommitContext = {
24
+ activeCompPath: "index.html",
25
+ previewIframeRef: { current: null },
26
+ showToast: () => undefined,
27
+ versions: new Map(),
28
+ persistDomEditOperations: (async (
29
+ _selection: DomEditSelection,
30
+ operations: PatchOperation[],
31
+ options?: { label?: string },
32
+ ) => {
33
+ saves.push({ operations, label: options?.label });
34
+ }) as unknown as DomStyleCommitContext["persistDomEditOperations"],
35
+ };
36
+ await commitDomStyles(context, selection, styles);
37
+ return saves;
38
+ }
39
+
40
+ const written = (operations: PatchOperation[]) =>
41
+ operations.map((op) => [(op as { property: string }).property, (op as { value: string }).value]);
42
+
43
+ it("writes a style named the JavaScript way under its CSS name", async () => {
44
+ const saves = await savedPatches({ backgroundColor: "#ff0000" });
45
+ expect(saves.map((save) => written(save.operations))).toEqual([
46
+ [["background-color", "#ff0000"]],
47
+ ]);
48
+ });
49
+
50
+ it("saves a map mixing both forms as one patch and one undo step, all under CSS names", async () => {
51
+ const saves = await savedPatches({
52
+ borderRadius: "4",
53
+ "border-color": "red",
54
+ WebkitTextStroke: "1px black",
55
+ msTransform: "none",
56
+ });
57
+ expect(saves).toHaveLength(1);
58
+ expect(saves[0]!.label).toBe("Edit layer style");
59
+ expect(written(saves[0]!.operations)).toEqual([
60
+ ["border-radius", "4px"],
61
+ ["border-color", "red"],
62
+ ["-webkit-text-stroke", "1px black"],
63
+ ["-ms-transform", "none"],
64
+ ]);
65
+ });
66
+
67
+ it("keeps a custom property's name exactly as given", async () => {
68
+ const saves = await savedPatches({ "--brand": "#123456", "--brandAccent": "#654321" });
69
+ expect(written(saves[0]!.operations)).toEqual([
70
+ ["--brand", "#123456"],
71
+ ["--brandAccent", "#654321"],
72
+ ]);
73
+ });
@@ -1,5 +1,6 @@
1
1
  import type { PatchOperation } from "../utils/sourcePatcher";
2
2
  import {
3
+ cssPropertyName,
3
4
  isImageBackgroundValue,
4
5
  isManualGeometryStyleProperty,
5
6
  normalizeDomEditStyleValue,
@@ -43,8 +44,14 @@ export interface DomStyleCommitContext {
43
44
  export async function commitDomStyles(
44
45
  context: DomStyleCommitContext,
45
46
  selection: DomEditSelection,
46
- styles: Record<string, string>,
47
+ stylesInEitherCase: Record<string, string>,
47
48
  ): Promise<DomEditCommitOutcome> {
49
+ const styles = Object.fromEntries(
50
+ Object.entries(stylesInEitherCase).map(([property, value]) => [
51
+ cssPropertyName(property),
52
+ value,
53
+ ]),
54
+ );
48
55
  const entries = Object.entries(styles);
49
56
  if (entries.length === 0) return domEditCommitDeclined("no-selection");
50
57
  if (entries.some(([property]) => isManualGeometryStyleProperty(property)))
@@ -75,3 +75,13 @@ export function computeDraggedGsapPosition(
75
75
  baseGsapY,
76
76
  };
77
77
  }
78
+
79
+ /** Puts the drag's preview offset back once the written position renders instead. */
80
+ export function restoreDragOffset(element: HTMLElement): void {
81
+ const origin = (axis: "x" | "y") =>
82
+ Number.parseFloat(element.getAttribute(`data-hf-drag-initial-offset-${axis}`) ?? "") || 0;
83
+ element.style.setProperty("--hf-studio-offset-x", `${origin("x")}px`);
84
+ element.style.setProperty("--hf-studio-offset-y", `${origin("y")}px`);
85
+ element.removeAttribute("data-hf-drag-initial-offset-x");
86
+ element.removeAttribute("data-hf-drag-initial-offset-y");
87
+ }
@@ -1,3 +1,4 @@
1
+ import type { DomEditPersistOutcome } from "./domEditCommitTypes";
1
2
  import { getDomEditTargetKey, type DomEditSelection } from "../components/editor/domEditing";
2
3
  import {
3
4
  applyElementPositionOffset,
@@ -23,7 +24,7 @@ export interface ElementOffsetStagerDeps {
23
24
  selection: DomEditSelection,
24
25
  patches: PatchOperation[],
25
26
  options: { label: string; coalesceKey: string; coalesceMs?: number },
26
- ) => Promise<void>;
27
+ ) => Promise<DomEditPersistOutcome | undefined>;
27
28
  showToast: (message: string, tone?: "error" | "info") => void;
28
29
  readOnlyPreview?: boolean;
29
30
  }
@@ -59,7 +60,7 @@ function stagePlainTranslate(
59
60
  selection: DomEditSelection,
60
61
  next: { x: number; y: number },
61
62
  coalesceKey?: string,
62
- ): { save: () => Promise<void>; rollback: () => void } {
63
+ ): { save: () => Promise<DomEditPersistOutcome | undefined>; rollback: () => void } {
63
64
  const el = selection.element;
64
65
  const before = captureStudioPathOffset(el);
65
66
  const patches = writePlainMove(el, next);
@@ -85,9 +86,9 @@ export function stageElementOffset(
85
86
  next: { x: number; y: number },
86
87
  plainTranslate: boolean,
87
88
  coalesceKey?: string,
88
- ): { save: () => Promise<void>; rollback: () => void } {
89
+ ): { save: () => Promise<DomEditPersistOutcome | undefined>; rollback: () => void } {
89
90
  const el = selection.element;
90
- if (readOnlyPreview) return { save: () => Promise.resolve(), rollback: () => undefined };
91
+ if (readOnlyPreview) return { save: () => Promise.resolve(undefined), rollback: () => undefined };
91
92
  if (plainTranslate) {
92
93
  refuseGsapTakeover(el, showToast);
93
94
  return stagePlainTranslate(commitPositionPatchToHtml, selection, next, coalesceKey);