@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
@@ -30,6 +30,35 @@ export function ext(path: string): string {
30
30
  return dot > 0 ? name.slice(dot + 1).toUpperCase() : "";
31
31
  }
32
32
 
33
+ /**
34
+ * Truncate a string to at most `maxLen` chars, preserving the start and end.
35
+ * Middle characters are replaced with an ellipsis. If the string is short
36
+ * enough it is returned unchanged.
37
+ *
38
+ * @example truncateMiddle("2a37eabf-long-uuid-887d8.mp4", 20) → "2a37eabf-…887d8.mp4"
39
+ *
40
+ * Pure — unit-tested.
41
+ */
42
+ export function truncateMiddle(str: string, maxLen: number): string {
43
+ if (str.length <= maxLen) return str;
44
+ const keep = maxLen - 1; // 1 char for ellipsis
45
+ const tail = Math.floor(keep / 3);
46
+ const head = keep - tail;
47
+ return str.slice(0, head) + "…" + str.slice(str.length - tail);
48
+ }
49
+
50
+ /**
51
+ * Format a duration in seconds as MM:SS. Returns an empty string for
52
+ * non-positive, NaN, or Infinity values. Pure — unit-tested.
53
+ */
54
+ export function formatDuration(seconds: number): string {
55
+ if (!Number.isFinite(seconds) || seconds <= 0) return "";
56
+ const total = Math.round(seconds);
57
+ const m = Math.floor(total / 60);
58
+ const s = total % 60;
59
+ return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
60
+ }
61
+
33
62
  export const CATEGORY_LABELS: Record<MediaCategory, string> = {
34
63
  audio: "Audio",
35
64
  images: "Images",
@@ -98,6 +98,15 @@ export function useDomEditActionsContext(): DomEditActionsValue {
98
98
  return ctx;
99
99
  }
100
100
 
101
+ /**
102
+ * Optional access — returns null outside a provider. Lets the player-package
103
+ * <Timeline> (a public standalone export) reach the z-order persist path when
104
+ * embedded in the NLE without hard-requiring the provider in standalone/test mounts.
105
+ */
106
+ export function useDomEditActionsContextOptional(): DomEditActionsValue | null {
107
+ return useContext(DomEditActionsContext);
108
+ }
109
+
101
110
  export function useDomEditSelectionContext(): DomEditSelectionValue {
102
111
  const ctx = useContext(DomEditSelectionContext);
103
112
  if (!ctx) throw new Error("useDomEditSelectionContext must be used within DomEditProvider");
@@ -31,8 +31,6 @@ export interface StudioShellValue {
31
31
  compositionDimensions: CompositionDimensions | null;
32
32
  waitForPendingDomEditSaves: () => Promise<void>;
33
33
  handlePreviewIframeRef: (iframe: HTMLIFrameElement | null) => void;
34
- timelineVisible: boolean;
35
- toggleTimelineVisibility: () => void;
36
34
  }
37
35
 
38
36
  export interface StudioPlaybackValue {
@@ -56,6 +54,15 @@ export function useStudioShellContext(): StudioShellValue {
56
54
  return ctx;
57
55
  }
58
56
 
57
+ /**
58
+ * Optional access — returns null outside a provider. Lets the player-package
59
+ * <Timeline> (a public standalone export) read shell state when embedded in the
60
+ * NLE without hard-requiring the provider in standalone/test mounts.
61
+ */
62
+ export function useStudioShellContextOptional(): StudioShellValue | null {
63
+ return useContext(StudioShellContext);
64
+ }
65
+
59
66
  export function useStudioPlaybackContext(): StudioPlaybackValue {
60
67
  const ctx = useContext(StudioPlaybackContext);
61
68
  if (!ctx) throw new Error("useStudioPlaybackContext must be used within StudioPlaybackProvider");
@@ -90,8 +97,6 @@ export function StudioShellProvider({
90
97
  compositionDimensions,
91
98
  waitForPendingDomEditSaves,
92
99
  handlePreviewIframeRef,
93
- timelineVisible,
94
- toggleTimelineVisibility,
95
100
  } = value;
96
101
 
97
102
  const stable = useMemo<StudioShellValue>(
@@ -108,14 +113,11 @@ export function StudioShellProvider({
108
113
  compositionDimensions,
109
114
  waitForPendingDomEditSaves,
110
115
  handlePreviewIframeRef,
111
- timelineVisible,
112
- toggleTimelineVisibility,
113
116
  }),
114
117
  [
115
118
  projectId,
116
119
  activeCompPath,
117
120
  compositionDimensions,
118
- timelineVisible,
119
121
  editHistory,
120
122
  renderQueue,
121
123
  setActiveCompPath,
@@ -125,7 +127,6 @@ export function StudioShellProvider({
125
127
  handleRedo,
126
128
  waitForPendingDomEditSaves,
127
129
  handlePreviewIframeRef,
128
- toggleTimelineVisibility,
129
130
  ],
130
131
  );
131
132
  return <StudioShellContext value={stable}>{children}</StudioShellContext>;
@@ -10,7 +10,7 @@ export function useTimelineEditContext(): TimelineEditCallbacks {
10
10
  }
11
11
 
12
12
  /**
13
- * Optional access, returns an empty object when outside a provider.
13
+ * Optional access returns an empty object when outside a provider.
14
14
  * Useful in components that can render both inside and outside the NLE.
15
15
  */
16
16
  export function useTimelineEditContextOptional(): TimelineEditCallbacks {
@@ -26,18 +26,14 @@ export function TimelineEditProvider({
26
26
  }) {
27
27
  const memoized = useMemo(
28
28
  () => value,
29
- // Each callback is a stable reference from the parent, memoize the bag
29
+ // Each callback is a stable reference from the parent memoize the bag
30
30
  // so consumers don't re-render when unrelated parent state changes.
31
31
  // eslint-disable-next-line react-hooks/exhaustive-deps
32
32
  [
33
33
  value.onMoveElement,
34
- value.onResizeElement,
35
34
  value.onMoveElements,
36
- value.onResizeElements,
37
- value.onPreviewMoveElements,
38
- value.onPreviewResizeElements,
35
+ value.onResizeElement,
39
36
  value.onToggleTrackHidden,
40
- value.onToggleElementHidden,
41
37
  value.onBlockedEditAttempt,
42
38
  value.onSplitElement,
43
39
  value.onRazorSplit,
@@ -0,0 +1,35 @@
1
+ import { usePlayerStore } from "../player/store/playerStore";
2
+ import { selectedKeyframePercentagesForElement } from "../utils/keyframeSelection";
3
+ import type { CommitMutationOptions } from "./gsapScriptCommitTypes";
4
+
5
+ let deleteKeyframesCommitCounter = 0;
6
+
7
+ /**
8
+ * Remove the keyframes currently selected in the player store from the active
9
+ * element's GSAP animation. Reads selection lazily so it stays correct when
10
+ * invoked from a ref callback.
11
+ */
12
+ export function deleteSelectedKeyframes(session: {
13
+ selectedGsapAnimations: readonly { id: string; keyframes?: unknown }[];
14
+ handleGsapRemoveKeyframe: (
15
+ animId: string,
16
+ pct: number,
17
+ options?: Partial<CommitMutationOptions>,
18
+ ) => void;
19
+ }): void {
20
+ const { selectedKeyframes, selectedElementId } = usePlayerStore.getState();
21
+ const animation = session.selectedGsapAnimations.find((anim) => anim.keyframes);
22
+ if (!animation) return;
23
+ // Only the active element's keyframes; a stale cross-element selection must not delete here.
24
+ const percentages = selectedKeyframePercentagesForElement(selectedKeyframes, selectedElementId);
25
+ const coalesceOptions = {
26
+ coalesceKey: `delete-keyframes:${++deleteKeyframesCommitCounter}`,
27
+ coalesceMs: Number.POSITIVE_INFINITY,
28
+ };
29
+ for (const [index, pct] of percentages.entries()) {
30
+ session.handleGsapRemoveKeyframe(animation.id, pct, {
31
+ ...coalesceOptions,
32
+ ...(index === percentages.length - 1 ? { softReload: true } : { skipReload: true }),
33
+ });
34
+ }
35
+ }
@@ -1,5 +1,15 @@
1
1
  import type { DomEditSelection } from "../components/editor/domEditing";
2
- import type { PatchOperation } from "../utils/sourcePatcher";
2
+ import type { PatchOperation, PatchTarget } from "../utils/sourcePatcher";
3
+
4
+ export interface DomEditPatchBatch {
5
+ sourceFile: string;
6
+ patches: Array<{ target: PatchTarget; operations: PatchOperation[] }>;
7
+ }
8
+
9
+ export type CommitDomEditPatchBatches = (
10
+ batches: DomEditPatchBatch[],
11
+ options: { label: string; coalesceKey: string },
12
+ ) => Promise<void>;
3
13
 
4
14
  export type PersistDomEditOperations = (
5
15
  selection: DomEditSelection,
@@ -7,6 +17,7 @@ export type PersistDomEditOperations = (
7
17
  options?: {
8
18
  label?: string;
9
19
  coalesceKey?: string;
20
+ coalesceMs?: number;
10
21
  skipRefresh?: boolean;
11
22
  prepareContent?: (html: string, sourceFile: string) => string;
12
23
  shouldSave?: () => boolean;
@@ -1,5 +1,8 @@
1
1
  // Shared harness helpers for selection hook tests (useDomSelection,
2
2
  // usePreviewInteraction). Test-only module.
3
+ import type React from "react";
4
+ import { act } from "react";
5
+ import { createRoot, type Root } from "react-dom/client";
3
6
  import type { DomEditSelection } from "../components/editor/domEditing";
4
7
 
5
8
  export function installReactActEnvironment(): void {
@@ -9,6 +12,17 @@ export function installReactActEnvironment(): void {
9
12
  });
10
13
  }
11
14
 
15
+ /** Mount a React element into a fresh detached host and return its root. */
16
+ export function mountReactHarness(node: React.ReactElement): Root {
17
+ const host = document.createElement("div");
18
+ document.body.append(host);
19
+ const root = createRoot(host);
20
+ act(() => {
21
+ root.render(node);
22
+ });
23
+ return root;
24
+ }
25
+
12
26
  export function makeSelection(label: string, element: HTMLElement): DomEditSelection {
13
27
  return {
14
28
  element,
@@ -0,0 +1,311 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { beforeEach, describe, expect, it, vi } from "vitest";
4
+ import type { DomEditSelection } from "../components/editor/domEditingTypes";
5
+ import { trackStudioEvent } from "../utils/studioTelemetry";
6
+ import type { CommitMutation } from "./gsapScriptCommitTypes";
7
+ import { isGestureTransactionCommit, runGestureTransaction } from "./gestureTransaction";
8
+
9
+ vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
10
+
11
+ const trackStudioEventMock = vi.mocked(trackStudioEvent);
12
+
13
+ function rect(x: number, y: number, width: number, height: number): DOMRect {
14
+ return { x, y, width, height } as DOMRect;
15
+ }
16
+
17
+ function runTwoMutationTransaction(
18
+ underlying: CommitMutation,
19
+ firstSelection: DomEditSelection,
20
+ secondSelection = firstSelection,
21
+ ): Promise<void> {
22
+ return runGestureTransaction({
23
+ element: firstSelection.element,
24
+ label: "Resize layer",
25
+ settle: vi.fn(),
26
+ persist: async (commit) => {
27
+ const commitMutation = commit(underlying);
28
+ await commitMutation(firstSelection, { type: "first" }, { label: "First" });
29
+ await commitMutation(secondSelection, { type: "last" }, { label: "Last", softReload: true });
30
+ },
31
+ restore: vi.fn(),
32
+ skipPixelAssert: true,
33
+ });
34
+ }
35
+
36
+ describe("runGestureTransaction", () => {
37
+ beforeEach(() => {
38
+ trackStudioEventMock.mockReset();
39
+ });
40
+
41
+ it("settles synchronously before persist reaches its first await", async () => {
42
+ const order: string[] = [];
43
+ let release!: () => void;
44
+ const gate = new Promise<void>((resolve) => {
45
+ release = resolve;
46
+ });
47
+
48
+ const transaction = runGestureTransaction({
49
+ element: document.createElement("div"),
50
+ label: "Resize layer",
51
+ settle: () => order.push("settle"),
52
+ persist: async () => {
53
+ order.push("persist");
54
+ await gate;
55
+ order.push("persisted");
56
+ },
57
+ restore: vi.fn(),
58
+ skipPixelAssert: true,
59
+ });
60
+
61
+ expect(order).toEqual(["settle", "persist"]);
62
+ release();
63
+ await transaction;
64
+ expect(order).toEqual(["settle", "persist", "persisted"]);
65
+ });
66
+
67
+ it("injects one coalesce key and reloads only the final mutation", async () => {
68
+ const element = document.createElement("div");
69
+ const selection = { element, id: "clip" } as DomEditSelection;
70
+ const underlying = vi.fn<CommitMutation>().mockResolvedValue(undefined);
71
+ const batch = vi.fn<NonNullable<CommitMutation["batch"]>>().mockResolvedValue(undefined);
72
+ underlying.batch = batch;
73
+ const now = vi.spyOn(performance, "now").mockReturnValueOnce(100).mockReturnValueOnce(112.6);
74
+
75
+ await runTwoMutationTransaction(underlying, selection);
76
+
77
+ expect(underlying).not.toHaveBeenCalled();
78
+ expect(batch).toHaveBeenCalledTimes(1);
79
+ const [calls, mergedOptions] = batch.mock.calls[0]!;
80
+ const firstOptions = calls[0]!.options;
81
+ const lastOptions = calls[1]!.options;
82
+ expect(calls.map(({ mutation }) => mutation)).toEqual([{ type: "first" }, { type: "last" }]);
83
+ expect(firstOptions.coalesceKey).toBe(lastOptions.coalesceKey);
84
+ expect(firstOptions.coalesceKey).toMatch(/^tx:Resize layer:\d+$/);
85
+ expect(firstOptions.coalesceMs).toBe(Number.POSITIVE_INFINITY);
86
+ expect(lastOptions.coalesceMs).toBe(Number.POSITIVE_INFINITY);
87
+ expect(firstOptions).toMatchObject({ skipReload: true });
88
+ expect(firstOptions.softReload).toBeUndefined();
89
+ expect(lastOptions).toMatchObject({ softReload: true });
90
+ expect(lastOptions.skipReload).toBeUndefined();
91
+ // The transaction owns the undo label — the per-mutation "First"/"Last"
92
+ // labels are overridden so the coalesced entry reads "Resize layer".
93
+ expect(firstOptions.label).toBe("Resize layer");
94
+ expect(lastOptions.label).toBe("Resize layer");
95
+ expect(mergedOptions).toMatchObject({
96
+ label: "Resize layer",
97
+ coalesceKey: firstOptions.coalesceKey,
98
+ coalesceMs: Number.POSITIVE_INFINITY,
99
+ softReload: true,
100
+ });
101
+ expect(mergedOptions.skipReload).toBeUndefined();
102
+ expect(trackStudioEventMock).toHaveBeenCalledWith("commit_transaction", {
103
+ label: "Resize layer",
104
+ mutation_count: 2,
105
+ reload_count: 1,
106
+ duration_ms: 13,
107
+ pixel_asserted: false,
108
+ });
109
+ now.mockRestore();
110
+ });
111
+
112
+ it("reports one reload when a batch collapses two softReload commits", async () => {
113
+ const element = document.createElement("div");
114
+ const selection = { element, id: "clip", sourceFile: "index.html" } as DomEditSelection;
115
+ const underlying = vi.fn<CommitMutation>().mockResolvedValue(undefined);
116
+ underlying.batch = vi.fn<NonNullable<CommitMutation["batch"]>>().mockResolvedValue(undefined);
117
+
118
+ // Both a resize's size and offset persists request softReload; the batch is
119
+ // one write and one reload, so telemetry must report reload_count 1, not 2.
120
+ await runGestureTransaction({
121
+ element,
122
+ label: "Resize layer",
123
+ settle: vi.fn(),
124
+ persist: async (commit) => {
125
+ const commitMutation = commit(underlying);
126
+ await commitMutation(selection, { type: "size" }, { label: "Resize", softReload: true });
127
+ await commitMutation(selection, { type: "offset" }, { label: "Move", softReload: true });
128
+ },
129
+ restore: vi.fn(),
130
+ skipPixelAssert: true,
131
+ });
132
+
133
+ expect(underlying.batch).toHaveBeenCalledTimes(1);
134
+ expect(trackStudioEventMock).toHaveBeenCalledWith(
135
+ "commit_transaction",
136
+ expect.objectContaining({ mutation_count: 2, reload_count: 1 }),
137
+ );
138
+ });
139
+
140
+ it("falls back to sequential dispatch when the commit has no batch capability", async () => {
141
+ const element = document.createElement("div");
142
+ const selection = { element, id: "clip" } as DomEditSelection;
143
+ const underlying = vi.fn<CommitMutation>().mockResolvedValue(undefined);
144
+
145
+ await runTwoMutationTransaction(underlying, selection);
146
+
147
+ expect(underlying).toHaveBeenCalledTimes(2);
148
+ expect(underlying.mock.calls[0]![2]).toMatchObject({ skipReload: true });
149
+ expect(underlying.mock.calls[1]![2]).toMatchObject({ softReload: true });
150
+ });
151
+
152
+ it("keeps a single mutation on the original commit path", async () => {
153
+ const element = document.createElement("div");
154
+ const selection = { element, id: "clip" } as DomEditSelection;
155
+ const underlying = vi.fn<CommitMutation>().mockResolvedValue(undefined);
156
+ const batch = vi.fn<NonNullable<CommitMutation["batch"]>>().mockResolvedValue(undefined);
157
+ underlying.batch = batch;
158
+
159
+ await runGestureTransaction({
160
+ element,
161
+ label: "Move layer",
162
+ settle: vi.fn(),
163
+ persist: async (commit) => {
164
+ await commit(underlying)(
165
+ selection,
166
+ { type: "update-property" },
167
+ { label: "Move", softReload: true },
168
+ );
169
+ },
170
+ restore: vi.fn(),
171
+ skipPixelAssert: true,
172
+ });
173
+
174
+ expect(underlying).toHaveBeenCalledTimes(1);
175
+ expect(batch).not.toHaveBeenCalled();
176
+ });
177
+
178
+ it("does not batch mutations for different source files", async () => {
179
+ const element = document.createElement("div");
180
+ const firstSelection = { element, id: "a", sourceFile: "a.html" } as DomEditSelection;
181
+ const secondSelection = { element, id: "b", sourceFile: "b.html" } as DomEditSelection;
182
+ const underlying = vi.fn<CommitMutation>().mockResolvedValue(undefined);
183
+ const batch = vi.fn<NonNullable<CommitMutation["batch"]>>().mockResolvedValue(undefined);
184
+ underlying.batch = batch;
185
+
186
+ await runTwoMutationTransaction(underlying, firstSelection, secondSelection);
187
+
188
+ expect(batch).not.toHaveBeenCalled();
189
+ expect(underlying).toHaveBeenCalledTimes(2);
190
+ });
191
+
192
+ it("identifies transaction-owned commit wrappers without marking their underlying commit", async () => {
193
+ const underlying = vi.fn<CommitMutation>().mockResolvedValue(undefined);
194
+ let wrapped: CommitMutation | null = null;
195
+
196
+ await runGestureTransaction({
197
+ element: document.createElement("div"),
198
+ label: "Move layer",
199
+ settle: vi.fn(),
200
+ persist: async (commit) => {
201
+ wrapped = commit(underlying);
202
+ },
203
+ restore: vi.fn(),
204
+ skipPixelAssert: true,
205
+ });
206
+
207
+ expect(isGestureTransactionCommit(underlying)).toBe(false);
208
+ expect(isGestureTransactionCommit(wrapped!)).toBe(true);
209
+ });
210
+
211
+ it("restores exactly once and rethrows a persist failure", async () => {
212
+ const error = new Error("persist failed with secret selector #private-layer");
213
+ error.name = "PersistenceError";
214
+ const restore = vi.fn();
215
+ const element = document.createElement("div");
216
+ const selection = { element, id: "clip" } as DomEditSelection;
217
+ const underlying = vi.fn<CommitMutation>().mockResolvedValue(undefined);
218
+
219
+ await expect(
220
+ runGestureTransaction({
221
+ element,
222
+ label: "Resize layer",
223
+ settle: vi.fn(),
224
+ persist: async (commit) => {
225
+ await commit(underlying)(selection, { type: "position" }, { label: "Position" });
226
+ throw error;
227
+ },
228
+ restore,
229
+ skipPixelAssert: true,
230
+ }),
231
+ ).rejects.toBe(error);
232
+ expect(restore).toHaveBeenCalledTimes(1);
233
+ expect(trackStudioEventMock).toHaveBeenCalledWith("commit_transaction_failed", {
234
+ label: "Resize layer",
235
+ mutation_count: 1,
236
+ error_name: "PersistenceError",
237
+ restore_ran: true,
238
+ });
239
+ const failureProperties = trackStudioEventMock.mock.calls.find(
240
+ ([event]) => event === "commit_transaction_failed",
241
+ )?.[1];
242
+ expect(JSON.stringify(failureProperties)).not.toContain(error.message);
243
+ expect(JSON.stringify(failureProperties)).not.toContain("#private-layer");
244
+ });
245
+
246
+ it("reports when persistence changes pixels", async () => {
247
+ const element = document.createElement("div");
248
+ const getRect = vi
249
+ .spyOn(element, "getBoundingClientRect")
250
+ .mockReturnValueOnce(rect(10.04, 20.05, 100.05, 80.05))
251
+ .mockReturnValueOnce(rect(11.19, 17.89, 100.29, 78.99));
252
+ const error = vi.spyOn(console, "error").mockImplementation(() => undefined);
253
+ const now = vi.spyOn(performance, "now").mockReturnValueOnce(50).mockReturnValueOnce(58.44);
254
+
255
+ await runGestureTransaction({
256
+ element,
257
+ label: "Resize layer",
258
+ settle: vi.fn(),
259
+ persist: async () => undefined,
260
+ restore: vi.fn(),
261
+ });
262
+
263
+ expect(getRect).toHaveBeenCalledTimes(2);
264
+ expect(error).toHaveBeenCalledWith(
265
+ "[hf-commit] persist changed pixels",
266
+ expect.objectContaining({
267
+ label: "Resize layer",
268
+ delta: expect.objectContaining({ x: expect.any(Number) }),
269
+ }),
270
+ );
271
+ expect(trackStudioEventMock).toHaveBeenCalledWith("commit_invariant_violation", {
272
+ label: "Resize layer",
273
+ delta_x: 1.2,
274
+ delta_y: -2.2,
275
+ delta_w: 0.2,
276
+ delta_h: -1.1,
277
+ mutation_count: 0,
278
+ reload_count: 0,
279
+ duration_ms: 8,
280
+ });
281
+ expect(trackStudioEventMock).toHaveBeenCalledWith(
282
+ "commit_transaction",
283
+ expect.objectContaining({ pixel_asserted: true }),
284
+ );
285
+ now.mockRestore();
286
+ error.mockRestore();
287
+ });
288
+
289
+ it("skips the pixel assertion for live position tweens", async () => {
290
+ const element = document.createElement("div");
291
+ const getRect = vi.spyOn(element, "getBoundingClientRect");
292
+ const error = vi.spyOn(console, "error").mockImplementation(() => undefined);
293
+
294
+ await runGestureTransaction({
295
+ element,
296
+ label: "Resize layer",
297
+ settle: vi.fn(),
298
+ persist: async () => undefined,
299
+ restore: vi.fn(),
300
+ skipPixelAssert: true,
301
+ });
302
+
303
+ expect(getRect).not.toHaveBeenCalled();
304
+ expect(error).not.toHaveBeenCalledWith("[hf-commit] persist changed pixels", expect.anything());
305
+ expect(trackStudioEventMock).not.toHaveBeenCalledWith(
306
+ "commit_invariant_violation",
307
+ expect.anything(),
308
+ );
309
+ error.mockRestore();
310
+ });
311
+ });