@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
@@ -1,42 +1,26 @@
1
1
  import { useCallback } from "react";
2
2
  import { usePlayerStore } from "../player";
3
- import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
3
+ import {
4
+ readProjectFileContent,
5
+ saveProjectFilesWithHistory,
6
+ type DomEditCommitBaseParams,
7
+ } from "../utils/studioFileHistory";
4
8
  import { createStudioSaveHttpError } from "../utils/studioSaveDiagnostics";
5
9
  import {
6
10
  buildDomEditPatchTarget,
7
11
  readHfId,
8
12
  type DomEditSelection,
9
13
  } from "../components/editor/domEditing";
10
- import type { PatchOperation } from "../utils/sourcePatcher";
11
- import type { EditHistoryKind } from "../utils/editHistory";
12
-
13
- interface RecordEditInput {
14
- label: string;
15
- kind: EditHistoryKind;
16
- coalesceKey?: string;
17
- files: Record<string, { before: string; after: string }>;
18
- }
14
+ import type { CommitDomEditPatchBatches, DomEditPatchBatch } from "./domEditCommitTypes";
19
15
 
20
- interface UseElementLifecycleOpsParams {
21
- activeCompPath: string | null;
22
- showToast: (message: string, tone?: "error" | "info") => void;
23
- writeProjectFile: (path: string, content: string) => Promise<void>;
24
- domEditSaveTimestampRef: React.MutableRefObject<number>;
25
- editHistory: { recordEdit: (entry: RecordEditInput) => Promise<void> };
26
- projectIdRef: React.MutableRefObject<string | null>;
27
- reloadPreview: () => void;
28
- clearDomSelection: () => void;
16
+ interface UseElementLifecycleOpsParams extends DomEditCommitBaseParams {
29
17
  /** Route delete through SDK when session resolves the hf-id; returns true if handled. */
30
18
  onTrySdkDelete?: (hfId: string, originalContent: string, targetPath: string) => Promise<boolean>;
31
19
  /** Resolver-shadow tripwire for the reordered targets (telemetry-only, decoupled from cutover). */
32
20
  onReorderShadow?: (targets: string[]) => void;
33
21
  /** Resync the SDK session after a server-fallback delete. */
34
22
  forceReloadSdkSession?: () => void;
35
- commitPositionPatchToHtml: (
36
- selection: DomEditSelection,
37
- patches: PatchOperation[],
38
- options: { label: string; coalesceKey: string; skipRefresh?: boolean },
39
- ) => Promise<void>;
23
+ commitDomEditPatchBatches: CommitDomEditPatchBatches;
40
24
  /** Stage 7 Step 3b: called after a successful server-side element delete (shadow). */
41
25
  onElementDeleted?: (selection: DomEditSelection) => void;
42
26
  }
@@ -53,7 +37,7 @@ export function useElementLifecycleOps({
53
37
  onTrySdkDelete,
54
38
  onReorderShadow,
55
39
  forceReloadSdkSession,
56
- commitPositionPatchToHtml,
40
+ commitDomEditPatchBatches,
57
41
  onElementDeleted,
58
42
  }: UseElementLifecycleOpsParams) {
59
43
  // fallow-ignore-next-line complexity
@@ -66,17 +50,7 @@ export function useElementLifecycleOps({
66
50
 
67
51
  const targetPath = selection.sourceFile || activeCompPath || "index.html";
68
52
  try {
69
- const response = await fetch(
70
- `/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
71
- );
72
- if (!response.ok) {
73
- throw await createStudioSaveHttpError(response, `Failed to read ${targetPath}`);
74
- }
75
-
76
- const data = (await response.json()) as { content?: string };
77
- const originalContent = data.content;
78
- if (typeof originalContent !== "string")
79
- throw new Error(`Missing file contents for ${targetPath}`);
53
+ const originalContent = await readProjectFileContent(pid, targetPath);
80
54
 
81
55
  const patchTarget = buildDomEditPatchTarget(selection);
82
56
  if (!patchTarget.id && !patchTarget.selector && !patchTarget.hfId) {
@@ -156,8 +130,8 @@ export function useElementLifecycleOps({
156
130
  ],
157
131
  );
158
132
 
159
- // ponytail: z-index reorder writes inline-style patches via commitPositionPatchToHtml
160
- // persistDomEditOperations onTrySdkPersist, so it is already SDK-cut-over as setStyle.
133
+ // ponytail: z-index reorder folds every element patch for a source file in memory and sends
134
+ // one patch-elements-batch request, so each file is persisted with one atomic disk write.
161
135
  // No SDK reorder/reparent op exists; DOM sibling order stays server-authoritative if ever needed.
162
136
  const handleDomZIndexReorderCommit = useCallback(
163
137
  // fallow-ignore-next-line complexity
@@ -171,6 +145,7 @@ export function useElementLifecycleOps({
171
145
  sourceFile: string;
172
146
  key?: string;
173
147
  }>,
148
+ gestureCoalesceKey?: string,
174
149
  ) => {
175
150
  if (entries.length === 0) return Promise.resolve();
176
151
  // Resolver shadow (telemetry-only, decoupled from cutover): record whether
@@ -178,11 +153,12 @@ export function useElementLifecycleOps({
178
153
  onReorderShadow?.(
179
154
  entries.map((e) => readHfId(e.element)).filter((id): id is string => id != null),
180
155
  );
181
- const coalesceKey = `z-reorder:${entries.map((e) => e.id ?? e.selector ?? e.element.getAttribute("data-hf-id") ?? "el").join(":")}`;
182
- const saves: Array<Promise<void>> = [];
156
+ const coalesceKey =
157
+ gestureCoalesceKey ??
158
+ `z-reorder:${entries.map((e) => e.id ?? e.selector ?? e.element.getAttribute("data-hf-id") ?? "el").join(":")}`;
159
+ const patchesBySourceFile = new Map<string, DomEditPatchBatch["patches"]>();
183
160
  const rollbacks: Array<() => void> = [];
184
- for (let i = 0; i < entries.length; i++) {
185
- const entry = entries[i];
161
+ for (const entry of entries) {
186
162
  const priorZIndex = entry.element.style.zIndex;
187
163
  const priorPosition = entry.element.style.position;
188
164
  const priorStoreEntry = entry.key
@@ -218,39 +194,32 @@ export function useElementLifecycleOps({
218
194
  });
219
195
  }
220
196
  });
221
- saves.push(
222
- commitPositionPatchToHtml(
223
- {
224
- element: entry.element,
225
- id: entry.id ?? null,
226
- hfId: readHfId(entry.element),
227
- selector: entry.selector,
228
- selectorIndex: entry.selectorIndex,
229
- sourceFile: entry.sourceFile,
230
- } as unknown as DomEditSelection,
231
- patches,
232
- {
233
- label: "Reorder layers",
234
- coalesceKey,
235
- skipRefresh: i < entries.length - 1,
236
- },
237
- ),
238
- );
197
+ const filePatches = patchesBySourceFile.get(entry.sourceFile) ?? [];
198
+ filePatches.push({
199
+ target: buildDomEditPatchTarget({
200
+ id: entry.id,
201
+ hfId: readHfId(entry.element),
202
+ selector: entry.selector,
203
+ selectorIndex: entry.selectorIndex,
204
+ }),
205
+ operations: patches,
206
+ });
207
+ patchesBySourceFile.set(entry.sourceFile, filePatches);
239
208
  }
240
- // Resolves once every z-index patch is persisted so a same-file timing write
209
+ const batches = [...patchesBySourceFile].map(([sourceFile, patches]) => ({
210
+ sourceFile,
211
+ patches,
212
+ }));
213
+ // Resolves once every source-file batch is persisted so a same-file timing write
241
214
  // can be ordered after it (see applyTimelineStackingReorder callers).
242
- return Promise.allSettled(saves).then((settled) => {
243
- const rejected = settled.find(
244
- (result): result is PromiseRejectedResult => result.status === "rejected",
245
- );
246
- if (rejected) {
215
+ return commitDomEditPatchBatches(batches, { label: "Reorder layers", coalesceKey }).catch(
216
+ (error) => {
247
217
  for (const rollback of rollbacks) rollback();
248
- return Promise.reject(rejected.reason);
249
- }
250
- return undefined;
251
- });
218
+ throw error;
219
+ },
220
+ );
252
221
  },
253
- [commitPositionPatchToHtml, onReorderShadow],
222
+ [commitDomEditPatchBatches, onReorderShadow],
254
223
  );
255
224
 
256
225
  return {
@@ -1,4 +1,7 @@
1
- import { describe, expect, it } from "vitest";
1
+ // @vitest-environment happy-dom
2
+ import { act, createElement, useRef } from "react";
3
+ import { createRoot } from "react-dom/client";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
5
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
3
6
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
4
7
  import {
@@ -7,8 +10,20 @@ import {
7
10
  isPlayheadWithinTween,
8
11
  promoteSetToKeyframes,
9
12
  resolveNewTweenRange,
13
+ useEnableKeyframes,
10
14
  type EnableKeyframesSession,
11
15
  } from "./useEnableKeyframes";
16
+ import { usePlayerStore } from "../player/store/playerStore";
17
+
18
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
19
+
20
+ let cleanup: (() => void) | null = null;
21
+ afterEach(() => {
22
+ cleanup?.();
23
+ cleanup = null;
24
+ vi.unstubAllGlobals();
25
+ window.location.hash = "";
26
+ });
12
27
 
13
28
  function anim(overrides: Partial<GsapAnimation>): GsapAnimation {
14
29
  return {
@@ -203,3 +218,129 @@ describe("promoteSetToKeyframes — auto endpoint", () => {
203
218
  expect(kfs[0].percentage).toBe(0);
204
219
  });
205
220
  });
221
+
222
+ function renderEnableKeyframes(session: EnableKeyframesSession): () => Promise<void> {
223
+ let enable: (() => Promise<void>) | null = null;
224
+ function Probe() {
225
+ const sessionRef = useRef<EnableKeyframesSession | undefined>(session);
226
+ enable = useEnableKeyframes(sessionRef);
227
+ return null;
228
+ }
229
+ const container = document.createElement("div");
230
+ const root = createRoot(container);
231
+ act(() => root.render(createElement(Probe)));
232
+ cleanup = () => act(() => root.unmount());
233
+ if (!enable) throw new Error("hook did not initialize");
234
+ return enable;
235
+ }
236
+
237
+ function flatTweenResponses(flat: GsapAnimation, converted: GsapAnimation) {
238
+ const responses = [{ animations: [flat] }, { animations: [converted] }];
239
+ vi.stubGlobal(
240
+ "fetch",
241
+ vi.fn(async () => ({
242
+ ok: true,
243
+ json: async () => responses.shift() ?? { animations: [] },
244
+ })),
245
+ );
246
+ }
247
+
248
+ function makeElementSelection(): DomEditSelection {
249
+ const element = document.body.appendChild(document.createElement("div"));
250
+ element.id = "el";
251
+ return {
252
+ id: "el",
253
+ selector: "#el",
254
+ sourceFile: "index.html",
255
+ element,
256
+ } as DomEditSelection;
257
+ }
258
+
259
+ function stubFlatTweenConversion(id: string): {
260
+ flat: GsapAnimation;
261
+ converted: GsapAnimation;
262
+ } {
263
+ const flat = anim({ id, position: 1, duration: 1, properties: { x: 10 } });
264
+ const converted = anim({
265
+ id,
266
+ position: 1,
267
+ duration: 1,
268
+ keyframes: {
269
+ format: "object-array",
270
+ keyframes: [
271
+ { percentage: 0, properties: { x: 0 } },
272
+ { percentage: 100, properties: { x: 10 } },
273
+ ],
274
+ },
275
+ });
276
+ flatTweenResponses(flat, converted);
277
+ return { flat, converted };
278
+ }
279
+
280
+ describe("useEnableKeyframes — flat tween transaction", () => {
281
+ it("skips the convert reload and coalesces an outside-range terminal soft reload", async () => {
282
+ window.location.hash = "#/project/test-project";
283
+ usePlayerStore.setState({ currentTime: 3 });
284
+ const selection = makeElementSelection();
285
+ const { flat } = stubFlatTweenConversion("flat-1");
286
+ const handleConvert = vi.fn(async () => undefined);
287
+ const commitMutation = vi.fn(async () => undefined);
288
+ const enable = renderEnableKeyframes({
289
+ domEditSelection: selection,
290
+ selectedGsapAnimations: [flat],
291
+ previewIframeRef: {
292
+ current: {
293
+ contentWindow: { gsap: { getProperty: () => 10 } },
294
+ } as unknown as HTMLIFrameElement,
295
+ },
296
+ handleGsapAddAnimation: vi.fn(),
297
+ handleGsapConvertToKeyframes: handleConvert,
298
+ handleGsapRemoveKeyframe: vi.fn(),
299
+ commitMutation,
300
+ });
301
+
302
+ await act(async () => enable());
303
+
304
+ const convertOptions = handleConvert.mock.calls[0]?.[3];
305
+ const terminalOptions = commitMutation.mock.calls[0]?.[1];
306
+ expect(convertOptions).toMatchObject({ skipReload: true, coalesceMs: Infinity });
307
+ expect(terminalOptions).toMatchObject({
308
+ softReload: true,
309
+ coalesceKey: convertOptions?.coalesceKey,
310
+ });
311
+ expect(convertOptions?.coalesceKey).toEqual(expect.any(String));
312
+ });
313
+
314
+ it("passes the convert coalesce key to an inside-range batch edit", async () => {
315
+ window.location.hash = "#/project/test-project";
316
+ usePlayerStore.setState({ currentTime: 1.5 });
317
+ const selection = makeElementSelection();
318
+ const { flat } = stubFlatTweenConversion("flat-2");
319
+ const handleConvert = vi.fn(async () => undefined);
320
+ const addKeyframeBatch = vi.fn(async () => undefined);
321
+ const enable = renderEnableKeyframes({
322
+ domEditSelection: selection,
323
+ selectedGsapAnimations: [flat],
324
+ previewIframeRef: {
325
+ current: {
326
+ contentWindow: { gsap: { getProperty: () => 5 } },
327
+ } as unknown as HTMLIFrameElement,
328
+ },
329
+ handleGsapAddAnimation: vi.fn(),
330
+ handleGsapConvertToKeyframes: handleConvert,
331
+ handleGsapRemoveKeyframe: vi.fn(),
332
+ handleGsapAddKeyframeBatch: addKeyframeBatch,
333
+ });
334
+
335
+ await act(async () => enable());
336
+
337
+ const convertOptions = handleConvert.mock.calls[0]?.[3];
338
+ expect(addKeyframeBatch.mock.calls[0]?.[3]).toEqual({
339
+ softReload: true,
340
+ coalesceKey: convertOptions?.coalesceKey,
341
+ // Must carry the convert phase's infinite window so the inside-range
342
+ // apply folds into one undo entry regardless of round-trip latency.
343
+ coalesceMs: Infinity,
344
+ });
345
+ });
346
+ });
@@ -12,7 +12,7 @@ import type { GsapAnimation, GsapPercentageKeyframe } from "@hyperframes/core/gs
12
12
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
13
13
  import { usePlayerStore } from "../player/store/playerStore";
14
14
  import { fetchParsedAnimations, getAnimationsForElement } from "./useGsapTweenCache";
15
- import { selectorFromSelection, computeElementPercentage } from "./gsapShared";
15
+ import { selectorFromSelection, computeElementPercentage, isInstantHold } from "./gsapShared";
16
16
  import {
17
17
  resolveTweenStart,
18
18
  resolveTweenDuration,
@@ -21,6 +21,9 @@ import {
21
21
  import { POSITION_PROPS } from "./gsapRuntimeReaders";
22
22
  import { roundTo3 } from "../utils/rounding";
23
23
  import { nearestPointOnPath } from "../components/editor/motionPathGeometry";
24
+ import type { CommitMutationOptions } from "./gsapScriptCommitTypes";
25
+
26
+ let enableKeyframesTransactionCounter = 0;
24
27
 
25
28
  export interface EnableKeyframesSession {
26
29
  domEditSelection: DomEditSelection | null;
@@ -30,16 +33,19 @@ export interface EnableKeyframesSession {
30
33
  handleGsapConvertToKeyframes: (
31
34
  animId: string,
32
35
  resolvedFromValues?: Record<string, number | string>,
36
+ duration?: number,
37
+ commitOverrides?: Partial<CommitMutationOptions>,
33
38
  ) => void | Promise<void>;
34
39
  handleGsapRemoveKeyframe: (animId: string, pct: number) => void;
35
40
  handleGsapAddKeyframeBatch?: (
36
41
  animId: string,
37
42
  pct: number,
38
43
  properties: Record<string, number | string>,
44
+ commitOverrides?: Partial<CommitMutationOptions>,
39
45
  ) => Promise<void>;
40
46
  commitMutation?: (
41
47
  mutation: Record<string, unknown>,
42
- options: { label: string; softReload?: boolean },
48
+ options: CommitMutationOptions,
43
49
  ) => Promise<void>;
44
50
  }
45
51
 
@@ -107,6 +113,36 @@ export function buildExtendedKeyframes(
107
113
  return { position: roundTo3(newStart), duration: newDuration, keyframes };
108
114
  }
109
115
 
116
+ async function replaceSetWithSingleKeyframe(
117
+ session: EnableKeyframesSession,
118
+ sel: DomEditSelection,
119
+ setAnim: GsapAnimation,
120
+ t: number,
121
+ iframe: HTMLIFrameElement | null,
122
+ selector: string,
123
+ ): Promise<void> {
124
+ const position = readElementPosition(iframe, sel, setAnim);
125
+ if (Object.keys(position).length === 0) {
126
+ for (const [key, held] of Object.entries(setAnim.properties ?? {})) {
127
+ if (typeof held === "number") position[key] = held;
128
+ }
129
+ }
130
+ if (Object.keys(position).length === 0 || !session.commitMutation) return;
131
+ const range = resolveNewTweenRange(sel.dataAttributes?.start, sel.dataAttributes?.duration, t);
132
+ await session.commitMutation(
133
+ {
134
+ type: "replace-with-keyframes",
135
+ animationId: setAnim.id,
136
+ targetSelector: selector,
137
+ position: roundTo3(range.start),
138
+ duration: roundTo3(range.duration),
139
+ keyframes: [{ percentage: 0, properties: position }],
140
+ ease: setAnim.ease,
141
+ },
142
+ { label: "Enable keyframes", softReload: true },
143
+ );
144
+ }
145
+
110
146
  // fallow-ignore-next-line complexity
111
147
  function readElementPosition(
112
148
  iframe: HTMLIFrameElement | null,
@@ -199,6 +235,7 @@ async function applyKeyframeAtPlayhead(
199
235
  kfAnim: GsapAnimation,
200
236
  t: number,
201
237
  iframe: HTMLIFrameElement | null,
238
+ commitOverrides?: Partial<CommitMutationOptions>,
202
239
  ): Promise<void> {
203
240
  if (!isPlayheadWithinTween(kfAnim, t)) {
204
241
  const position = readElementPosition(iframe, sel, kfAnim);
@@ -215,7 +252,11 @@ async function applyKeyframeAtPlayhead(
215
252
  keyframes: extended.keyframes,
216
253
  ease: kfAnim.ease,
217
254
  },
218
- { label: "Add keyframe", softReload: true },
255
+ {
256
+ label: "Add keyframe",
257
+ softReload: true,
258
+ ...commitOverrides,
259
+ },
219
260
  );
220
261
  }
221
262
  return;
@@ -229,7 +270,7 @@ async function applyKeyframeAtPlayhead(
229
270
  if (session.handleGsapAddKeyframeBatch) {
230
271
  const position = readElementPosition(iframe, sel, kfAnim);
231
272
  if (Object.keys(position).length > 0) {
232
- await session.handleGsapAddKeyframeBatch(kfAnim.id, pct, position);
273
+ await session.handleGsapAddKeyframeBatch(kfAnim.id, pct, position, commitOverrides);
233
274
  }
234
275
  }
235
276
  }
@@ -259,27 +300,7 @@ export async function promoteSetToKeyframes(
259
300
  // the set with a single keyframe at the playhead holding its value, matching the
260
301
  // no-animation branch: one diamond the user can build motion from.
261
302
  if (t <= setStart) {
262
- const position = readElementPosition(iframe, sel, setAnim);
263
- if (Object.keys(position).length === 0) {
264
- for (const key of Object.keys(setAnim.properties ?? {})) {
265
- const held = setAnim.properties?.[key];
266
- if (typeof held === "number") position[key] = held;
267
- }
268
- }
269
- if (Object.keys(position).length === 0) return;
270
- const range = resolveNewTweenRange(sel.dataAttributes?.start, sel.dataAttributes?.duration, t);
271
- await session.commitMutation(
272
- {
273
- type: "replace-with-keyframes",
274
- animationId: setAnim.id,
275
- targetSelector: selector,
276
- position: roundTo3(range.start),
277
- duration: roundTo3(range.duration),
278
- keyframes: [{ percentage: 0, properties: position }],
279
- ease: setAnim.ease,
280
- },
281
- { label: "Enable keyframes", softReload: true },
282
- );
303
+ await replaceSetWithSingleKeyframe(session, sel, setAnim, t, iframe, selector);
283
304
  return;
284
305
  }
285
306
  const endPosition = readElementPosition(iframe, sel, setAnim);
@@ -395,8 +416,8 @@ export function useEnableKeyframes(
395
416
  // the curve.
396
417
  const arcAnim = anims.find((a) => a.arcPath);
397
418
  const kfAnim = anims.find((a) => a.keyframes && !a.arcPath);
398
- const setAnim = anims.find((a) => a.method === "set" && !a.keyframes && !a.arcPath);
399
- const flatAnim = anims.find((a) => !a.keyframes && !a.arcPath && a.method !== "set");
419
+ const setAnim = anims.find((a) => isInstantHold(a) && !a.keyframes && !a.arcPath);
420
+ const flatAnim = anims.find((a) => !a.keyframes && !a.arcPath && !isInstantHold(a));
400
421
 
401
422
  if (arcAnim) {
402
423
  await applyArcWaypointAtPlayhead(session, sel, arcAnim, t, iframe);
@@ -409,9 +430,32 @@ export function useEnableKeyframes(
409
430
  // resolvedFromValues, so the 0%/100% stops keep the real start→end motion
410
431
  // (passing the playhead value would flatten it). Then apply uniformly so an
411
432
  // out-of-range playhead extends the range just like a keyframe tween.
412
- await session.handleGsapConvertToKeyframes(flatAnim.id);
433
+ enableKeyframesTransactionCounter += 1;
434
+ const coalesceKey = `enable-keyframes:${flatAnim.id}:${enableKeyframesTransactionCounter}`;
435
+ const convertCommitOverrides: Partial<CommitMutationOptions> = {
436
+ skipReload: true,
437
+ coalesceKey,
438
+ coalesceMs: Number.POSITIVE_INFINITY,
439
+ };
440
+ await session.handleGsapConvertToKeyframes(
441
+ flatAnim.id,
442
+ undefined,
443
+ undefined,
444
+ convertCommitOverrides,
445
+ );
413
446
  const converted = (await fetchAnimationsForElement(sel)).find((a) => a.keyframes);
414
- if (converted) await applyKeyframeAtPlayhead(session, sel, converted, t, iframe);
447
+ if (converted) {
448
+ const applyCommitOverrides: Partial<CommitMutationOptions> = {
449
+ softReload: true,
450
+ coalesceKey,
451
+ // Must match the convert phase's window: editHistory keys coalescing
452
+ // off the incoming entry, so without Infinity here the apply (landing
453
+ // after two POSTs + a fetch) falls back to the 300ms default and the
454
+ // conversion splits into two undo entries under real latency.
455
+ coalesceMs: Number.POSITIVE_INFINITY,
456
+ };
457
+ await applyKeyframeAtPlayhead(session, sel, converted, t, iframe, applyCommitOverrides);
458
+ }
415
459
  } else {
416
460
  const position = readElementPosition(iframe, sel, null);
417
461
  const { start: elStart, duration: elDuration } = resolveNewTweenRange(
@@ -0,0 +1,130 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from "react";
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+ import type { DomEditSelection } from "../components/editor/domEditing";
5
+ import { usePlayerStore } from "../player";
6
+ import { mountReactHarness } from "./domSelectionTestHarness";
7
+ import type { CommitMutationOptions } from "./gsapScriptCommitTypes";
8
+ import { useGestureCommit } from "./useGestureCommit";
9
+
10
+ const gestureRecording = vi.hoisted(() => ({
11
+ startRecording: vi.fn(),
12
+ stopRecording: vi.fn(() => [
13
+ { time: 0, properties: { x: 0, y: 0, opacity: 1 } },
14
+ { time: 0.5, properties: { x: 50, y: 25, opacity: 0.5 } },
15
+ { time: 1, properties: { x: 100, y: 50, opacity: 0 } },
16
+ ]),
17
+ clearSamples: vi.fn(),
18
+ cancelRecording: vi.fn(),
19
+ isRecording: false,
20
+ recordingDuration: 0,
21
+ samplesRef: { current: [] },
22
+ trailRef: { current: [] },
23
+ }));
24
+
25
+ vi.mock("./useGestureRecording", () => ({
26
+ useGestureRecording: () => gestureRecording,
27
+ }));
28
+
29
+ vi.mock("../utils/rdpSimplify", () => ({
30
+ simplifyGestureSamples: () =>
31
+ new Map([
32
+ [0, { x: 0, y: 0, opacity: 1 }],
33
+ [50, { x: 50, y: 25, opacity: 0.5 }],
34
+ [100, { x: 100, y: 50, opacity: 0 }],
35
+ ]),
36
+ }));
37
+
38
+ vi.mock("../utils/gestureSmoother", () => ({
39
+ smoothGestureKeyframes: (keyframes: unknown) => keyframes,
40
+ }));
41
+
42
+ vi.mock("../utils/velocityEaseFitter", () => ({
43
+ fitEasesFromVelocity: (keyframes: unknown) => keyframes,
44
+ }));
45
+
46
+ Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
47
+
48
+ let cleanup: (() => void) | null = null;
49
+
50
+ afterEach(() => {
51
+ cleanup?.();
52
+ cleanup = null;
53
+ usePlayerStore.getState().reset();
54
+ document.body.replaceChildren();
55
+ vi.clearAllMocks();
56
+ });
57
+
58
+ function makeSelection(element: HTMLElement): DomEditSelection {
59
+ return {
60
+ id: element.id,
61
+ element,
62
+ label: "Card",
63
+ tagName: "div",
64
+ sourceFile: "index.html",
65
+ compositionPath: "index.html",
66
+ isCompositionHost: false,
67
+ isInsideLockedComposition: false,
68
+ boundingBox: { x: 0, y: 0, width: 100, height: 100 },
69
+ textContent: null,
70
+ dataAttributes: { start: "0", duration: "2" },
71
+ inlineStyles: {},
72
+ computedStyles: {},
73
+ textFields: [],
74
+ capabilities: {
75
+ canSelect: true,
76
+ canEditStyles: true,
77
+ canCrop: true,
78
+ canMove: true,
79
+ canResize: true,
80
+ canApplyManualOffset: true,
81
+ canApplyManualSize: true,
82
+ canApplyManualRotation: true,
83
+ },
84
+ };
85
+ }
86
+
87
+ describe("useGestureCommit", () => {
88
+ it("coalesces property-group commits and reloads only the terminal group", async () => {
89
+ const iframe = document.createElement("iframe");
90
+ document.body.append(iframe);
91
+ const element = document.createElement("div");
92
+ element.id = "card";
93
+ const commitMutation = vi.fn<
94
+ (mutation: Record<string, unknown>, options: CommitMutationOptions) => Promise<void>
95
+ >(async () => {});
96
+ const sessionRef = {
97
+ current: {
98
+ domEditSelection: makeSelection(element),
99
+ selectedGsapAnimations: [],
100
+ commitMutation,
101
+ },
102
+ };
103
+ const captured: { hook: ReturnType<typeof useGestureCommit> | null } = { hook: null };
104
+ function Probe() {
105
+ captured.hook = useGestureCommit({
106
+ domEditSessionRef: sessionRef,
107
+ previewIframeRef: { current: iframe },
108
+ showToast: vi.fn(),
109
+ isGestureRecordingRef: { current: false },
110
+ });
111
+ return null;
112
+ }
113
+ const root = mountReactHarness(<Probe />);
114
+ cleanup = () => act(() => root.unmount());
115
+ if (!captured.hook) throw new Error("hook did not initialize");
116
+
117
+ act(() => captured.hook?.handleToggleRecording());
118
+ act(() => captured.hook?.handleToggleRecording());
119
+ await act(async () => {
120
+ await vi.waitFor(() => expect(commitMutation).toHaveBeenCalledTimes(2));
121
+ });
122
+
123
+ const options = commitMutation.mock.calls.map((call) => call[1]);
124
+ expect(new Set(options.map((entry) => entry.coalesceKey)).size).toBe(1);
125
+ expect(options[0]).toEqual(expect.objectContaining({ coalesceMs: Infinity, skipReload: true }));
126
+ expect(options[0]).not.toHaveProperty("softReload");
127
+ expect(options[1]).toEqual(expect.objectContaining({ coalesceMs: Infinity, softReload: true }));
128
+ expect(options[1]).not.toHaveProperty("skipReload");
129
+ });
130
+ });