@hyperframes/studio 0.7.58 → 0.7.59

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 (156) hide show
  1. package/dist/assets/{hyperframes-player-CtTDO63S.js → hyperframes-player-Bm07FLkl.js} +1 -1
  2. package/dist/assets/{index-C47jAC3Q.js → index-B995FG46.js} +1 -1
  3. package/dist/assets/index-CrkAdJkb.js +426 -0
  4. package/dist/assets/index-Dj5p8U_A.css +1 -0
  5. package/dist/assets/{index-DeQPzqwH.js → index-FvzmPhfG.js} +1 -1
  6. package/dist/index.d.ts +12 -3
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +13514 -8528
  9. package/dist/index.js.map +1 -1
  10. package/dist/styles/tailwind-preset.d.ts +5 -0
  11. package/dist/styles/tailwind-preset.js +8 -1
  12. package/dist/styles/tailwind-preset.js.map +1 -1
  13. package/package.json +7 -7
  14. package/src/App.tsx +3 -3
  15. package/src/components/DesignPanelPromoteProvider.tsx +15 -2
  16. package/src/components/StudioRightPanel.tsx +29 -51
  17. package/src/components/editor/AnimationCard.test.tsx +134 -0
  18. package/src/components/editor/AnimationCard.tsx +19 -4
  19. package/src/components/editor/ArcPathControls.tsx +1 -0
  20. package/src/components/editor/GestureRecordControl.tsx +7 -1
  21. package/src/components/editor/GsapAnimationSection.tsx +109 -14
  22. package/src/components/editor/InspectorHeaderActions.tsx +42 -4
  23. package/src/components/editor/PropertyPanel.test.tsx +948 -0
  24. package/src/components/editor/PropertyPanel.tsx +144 -148
  25. package/src/components/editor/PropertyPanelEmptyState.test.tsx +74 -0
  26. package/src/components/editor/PropertyPanelEmptyState.tsx +179 -1
  27. package/src/components/editor/PropertyPanelFlat.tsx +580 -0
  28. package/src/components/editor/PropertyPanelFlatFooter.test.tsx +83 -0
  29. package/src/components/editor/PropertyPanelFlatFooter.tsx +73 -0
  30. package/src/components/editor/PropertyPanelFlatHeader.test.tsx +79 -0
  31. package/src/components/editor/PropertyPanelFlatHeader.tsx +112 -0
  32. package/src/components/editor/gsapAnimationCallbacks.ts +27 -0
  33. package/src/components/editor/gsapLivePreview.ts +34 -0
  34. package/src/components/editor/manualEditingAvailability.test.ts +10 -0
  35. package/src/components/editor/manualEditingAvailability.ts +9 -0
  36. package/src/components/editor/propertyPanel3dTransform.tsx +5 -0
  37. package/src/components/editor/propertyPanelColor.test.tsx +28 -0
  38. package/src/components/editor/propertyPanelColor.tsx +31 -1
  39. package/src/components/editor/propertyPanelColorGradingControls.tsx +11 -2
  40. package/src/components/editor/propertyPanelColorGradingSection.tsx +45 -365
  41. package/src/components/editor/propertyPanelColorGradingSlider.tsx +19 -5
  42. package/src/components/editor/propertyPanelFill.tsx +22 -5
  43. package/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx +622 -0
  44. package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +570 -0
  45. package/src/components/editor/propertyPanelFlatLayoutSection.test.tsx +293 -0
  46. package/src/components/editor/propertyPanelFlatLayoutSection.tsx +383 -0
  47. package/src/components/editor/propertyPanelFlatMaskInsetRows.tsx +102 -0
  48. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +436 -0
  49. package/src/components/editor/propertyPanelFlatMediaSection.tsx +286 -0
  50. package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +272 -0
  51. package/src/components/editor/propertyPanelFlatMotionSection.tsx +315 -0
  52. package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +1174 -0
  53. package/src/components/editor/propertyPanelFlatPrimitives.tsx +563 -0
  54. package/src/components/editor/propertyPanelFlatSelectRow.tsx +101 -0
  55. package/src/components/editor/propertyPanelFlatStyleHelpers.test.ts +23 -0
  56. package/src/components/editor/propertyPanelFlatStyleHelpers.ts +27 -0
  57. package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +598 -0
  58. package/src/components/editor/propertyPanelFlatStyleSections.tsx +516 -0
  59. package/src/components/editor/propertyPanelFlatTextSection.test.tsx +465 -0
  60. package/src/components/editor/propertyPanelFlatTextSection.tsx +407 -0
  61. package/src/components/editor/propertyPanelFlatTimingDerivation.test.ts +71 -0
  62. package/src/components/editor/propertyPanelFlatTimingDerivation.ts +59 -0
  63. package/src/components/editor/propertyPanelFlatToggle.test.tsx +63 -0
  64. package/src/components/editor/propertyPanelFlatToggle.tsx +53 -0
  65. package/src/components/editor/propertyPanelFont.test.tsx +30 -0
  66. package/src/components/editor/propertyPanelFont.tsx +136 -95
  67. package/src/components/editor/propertyPanelHelpers.ts +73 -0
  68. package/src/components/editor/propertyPanelInputCoverage.test.tsx +587 -0
  69. package/src/components/editor/propertyPanelMediaSection.tsx +10 -0
  70. package/src/components/editor/propertyPanelPrimitives.tsx +60 -29
  71. package/src/components/editor/propertyPanelSections.test.tsx +161 -0
  72. package/src/components/editor/propertyPanelSections.tsx +154 -90
  73. package/src/components/editor/propertyPanelStyleSections.tsx +12 -1
  74. package/src/components/editor/propertyPanelTimingSection.tsx +1 -1
  75. package/src/components/editor/propertyPanelTypes.ts +14 -1
  76. package/src/components/editor/propertyPanelValueTier.test.ts +32 -0
  77. package/src/components/editor/propertyPanelValueTier.ts +28 -0
  78. package/src/components/editor/useColorGradingController.test.ts +418 -0
  79. package/src/components/editor/useColorGradingController.ts +596 -0
  80. package/src/components/panels/VariablesPanel.tsx +4 -0
  81. package/src/components/sidebar/CompositionsTab.test.ts +28 -2
  82. package/src/components/sidebar/CompositionsTab.tsx +3 -1
  83. package/src/components/storyboard/FramePoster.tsx +24 -4
  84. package/src/components/storyboard/StoryboardFrameFocus.tsx +4 -0
  85. package/src/components/storyboard/StoryboardFrameTile.tsx +30 -1
  86. package/src/components/storyboard/StoryboardGrid.tsx +23 -1
  87. package/src/components/storyboard/StoryboardLoaded.tsx +58 -2
  88. package/src/components/storyboard/StoryboardView.tsx +9 -2
  89. package/src/components/storyboard/frameComments.test.ts +95 -0
  90. package/src/components/storyboard/frameComments.ts +115 -0
  91. package/src/components/storyboard/useFrameComments.ts +82 -0
  92. package/src/contexts/DesignPanelInputContext.test.tsx +81 -0
  93. package/src/contexts/DesignPanelInputContext.tsx +48 -0
  94. package/src/contexts/DomEditContext.tsx +4 -0
  95. package/src/contexts/VariablePromoteContext.test.tsx +67 -0
  96. package/src/contexts/VariablePromoteContext.tsx +10 -5
  97. package/src/hooks/domEditCommitRunner.ts +11 -0
  98. package/src/hooks/gsapScriptCommitTypes.ts +6 -4
  99. package/src/hooks/timelineTrackVisibility.test.ts +52 -0
  100. package/src/hooks/timelineTrackVisibility.ts +27 -10
  101. package/src/hooks/useAppHotkeys.ts +9 -0
  102. package/src/hooks/useDomEditAttributeCommits.ts +123 -1
  103. package/src/hooks/useDomEditCommits.ts +29 -175
  104. package/src/hooks/useDomEditCommitsHelpers.ts +158 -0
  105. package/src/hooks/useDomEditSession.ts +17 -5
  106. package/src/hooks/useDomEditTextCommits.ts +14 -9
  107. package/src/hooks/useDomEditWiring.ts +10 -9
  108. package/src/hooks/useElementLifecycleOps.ts +8 -3
  109. package/src/hooks/useFileManager.projectOwnership.test.tsx +98 -0
  110. package/src/hooks/useFileManager.ts +82 -60
  111. package/src/hooks/useGsapAnimationOps.ts +7 -6
  112. package/src/hooks/useGsapKeyframeOps.ts +6 -5
  113. package/src/hooks/useGsapPropertyDebounce.ts +43 -28
  114. package/src/hooks/useGsapPropertyDebounceFlush.test.ts +35 -0
  115. package/src/hooks/useGsapScriptCommits.test.tsx +48 -2
  116. package/src/hooks/useGsapScriptCommits.ts +150 -50
  117. package/src/hooks/useGsapSelectionHandlers.test.tsx +116 -0
  118. package/src/hooks/useGsapSelectionHandlers.ts +94 -31
  119. package/src/hooks/useInspectorSplitResize.ts +51 -0
  120. package/src/hooks/usePersistentEditHistory.projectOwnership.test.tsx +60 -0
  121. package/src/hooks/usePersistentEditHistory.test.ts +49 -1
  122. package/src/hooks/usePersistentEditHistory.ts +71 -31
  123. package/src/hooks/useProjectCompositionVariables.ts +10 -10
  124. package/src/hooks/useProjectSignaturePoll.test.tsx +135 -0
  125. package/src/hooks/useProjectSignaturePoll.ts +68 -0
  126. package/src/hooks/useSdkSession.lifecycle.test.tsx +137 -0
  127. package/src/hooks/useSdkSession.ts +155 -14
  128. package/src/hooks/useSlideshowPersist.ts +7 -1
  129. package/src/hooks/useStoryboard.ts +23 -4
  130. package/src/hooks/useTimelineEditing.test.tsx +4 -1
  131. package/src/hooks/useTimelineEditing.ts +16 -10
  132. package/src/hooks/useTimelineEditingTypes.ts +3 -0
  133. package/src/hooks/useTimelineGroupEditing.ts +16 -2
  134. package/src/hooks/useVariablesPersist.ts +9 -7
  135. package/src/icons/SystemIcons.tsx +2 -0
  136. package/src/styles/studio.css +22 -0
  137. package/src/styles/tailwind-preset.shared.js +7 -0
  138. package/src/utils/designInputTracking.test.ts +97 -0
  139. package/src/utils/designInputTracking.ts +80 -0
  140. package/src/utils/sdkCutover.gate.test.ts +7 -7
  141. package/src/utils/sdkCutover.test.ts +490 -57
  142. package/src/utils/sdkCutover.ts +181 -219
  143. package/src/utils/sdkEditTransaction.ts +292 -0
  144. package/src/utils/sdkResolverAttempts.ts +94 -0
  145. package/src/utils/sdkResolverShadow.test.ts +148 -0
  146. package/src/utils/sdkResolverShadow.ts +139 -106
  147. package/src/utils/setSlideshowManifest.test.ts +0 -9
  148. package/src/utils/setSlideshowManifest.ts +20 -27
  149. package/src/utils/studioFileHistory.test.ts +41 -0
  150. package/src/utils/studioFileHistory.ts +30 -27
  151. package/src/utils/studioFileMutationCoordinator.ts +45 -0
  152. package/src/utils/studioHelpers.test.ts +45 -0
  153. package/src/utils/studioHelpers.ts +45 -14
  154. package/src/utils/studioSaveDiagnostics.ts +17 -0
  155. package/dist/assets/index-B_UvTX3E.js +0 -423
  156. package/dist/assets/index-uahwWkgw.css +0 -1
@@ -3,7 +3,11 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
3
3
  import type { DomEditSelection } from "../components/editor/domEditing";
4
4
  import { usePlayerStore } from "../player";
5
5
  import { computeCurrentPercentage } from "./gsapDragCommit";
6
- import { trackStudioSaveFailure } from "../utils/studioSaveDiagnostics";
6
+ import {
7
+ getStudioSaveErrorMessage,
8
+ isStudioSaveErrorAlreadyToasted,
9
+ trackStudioSaveFailure,
10
+ } from "../utils/studioSaveDiagnostics";
7
11
  import { trackStudioEvent } from "../utils/studioTelemetry";
8
12
  import type { CommitMutationOptions } from "./gsapScriptCommitTypes";
9
13
 
@@ -34,6 +38,7 @@ export function useGsapSelectionHandlers({
34
38
  removeAllKeyframes,
35
39
  handleDomManualEditsReset,
36
40
  selectedGsapAnimations,
41
+ showToast,
37
42
  }: {
38
43
  domEditSelection: DomEditSelection | null;
39
44
  updateGsapProperty: (
@@ -46,24 +51,24 @@ export function useGsapSelectionHandlers({
46
51
  sel: DomEditSelection,
47
52
  animId: string,
48
53
  updates: { duration?: number; ease?: string; position?: number },
49
- ) => void;
50
- deleteGsapAnimation: (sel: DomEditSelection, animId: string) => void;
51
- deleteAllForSelector: (sel: DomEditSelection, targetSelector: string) => void;
54
+ ) => Promise<void>;
55
+ deleteGsapAnimation: (sel: DomEditSelection, animId: string) => Promise<void>;
56
+ deleteAllForSelector: (sel: DomEditSelection, targetSelector: string) => Promise<void>;
52
57
  addGsapAnimation: (
53
58
  sel: DomEditSelection,
54
59
  method: "to" | "from" | "set" | "fromTo",
55
60
  time: number,
56
61
  ) => Promise<void>;
57
- addGsapProperty: (sel: DomEditSelection, animId: string, prop: string) => void;
58
- removeGsapProperty: (sel: DomEditSelection, animId: string, prop: string) => void;
62
+ addGsapProperty: (sel: DomEditSelection, animId: string, prop: string) => Promise<void>;
63
+ removeGsapProperty: (sel: DomEditSelection, animId: string, prop: string) => Promise<void>;
59
64
  updateGsapFromProperty: (
60
65
  sel: DomEditSelection,
61
66
  animId: string,
62
67
  prop: string,
63
68
  value: number | string,
64
- ) => void;
65
- addGsapFromProperty: (sel: DomEditSelection, animId: string, prop: string) => void;
66
- removeGsapFromProperty: (sel: DomEditSelection, animId: string, prop: string) => void;
69
+ ) => Promise<void>;
70
+ addGsapFromProperty: (sel: DomEditSelection, animId: string, prop: string) => Promise<void>;
71
+ removeGsapFromProperty: (sel: DomEditSelection, animId: string, prop: string) => Promise<void>;
67
72
  addKeyframe: (
68
73
  sel: DomEditSelection,
69
74
  animId: string,
@@ -104,10 +109,11 @@ export function useGsapSelectionHandlers({
104
109
  duration?: number,
105
110
  commitOverrides?: Partial<CommitMutationOptions>,
106
111
  ) => Promise<void>;
107
- removeAllKeyframes: (sel: DomEditSelection, animId: string) => void;
112
+ removeAllKeyframes: (sel: DomEditSelection, animId: string) => Promise<void>;
108
113
 
109
114
  handleDomManualEditsReset: (sel: DomEditSelection) => void;
110
115
  selectedGsapAnimations: GsapAnimation[];
116
+ showToast: (message: string, tone?: "error" | "info") => void;
111
117
  }) {
112
118
  const lastSelectionRef = useRef<DomEditSelection | null>(null);
113
119
  if (domEditSelection) lastSelectionRef.current = domEditSelection;
@@ -124,8 +130,20 @@ export function useGsapSelectionHandlers({
124
130
  targetSelector: selection.selector,
125
131
  targetSourceFile: selection.sourceFile,
126
132
  });
133
+ if (!isStudioSaveErrorAlreadyToasted(error)) {
134
+ showToast(`Couldn't save animation: ${getStudioSaveErrorMessage(error)}`, "error");
135
+ }
136
+ },
137
+ [showToast],
138
+ );
139
+
140
+ const observeGsapMutation = useCallback(
141
+ (mutation: Promise<void>, selection: DomEditSelection, mutationType: string, label: string) => {
142
+ void mutation.catch((error) => {
143
+ trackGsapHandlerFailure(error, selection, mutationType, label);
144
+ });
127
145
  },
128
- [],
146
+ [trackGsapHandlerFailure],
129
147
  );
130
148
 
131
149
  const handleGsapUpdateProperty = useCallback(
@@ -144,18 +162,23 @@ export function useGsapSelectionHandlers({
144
162
  ) => {
145
163
  const sel = selectionOverride ?? domEditSelection ?? lastSelectionRef.current;
146
164
  if (!sel) return;
147
- updateGsapMeta(sel, animId, updates);
165
+ observeGsapMutation(
166
+ updateGsapMeta(sel, animId, updates),
167
+ sel,
168
+ "update-meta",
169
+ "Edit GSAP animation",
170
+ );
148
171
  },
149
- [domEditSelection, updateGsapMeta],
172
+ [domEditSelection, observeGsapMutation, updateGsapMeta],
150
173
  );
151
174
 
152
175
  const handleGsapDeleteAnimation = useCallback(
153
176
  (animId: string) => {
154
177
  const sel = domEditSelection ?? lastSelectionRef.current;
155
178
  if (!sel) return;
156
- deleteGsapAnimation(sel, animId);
179
+ observeGsapMutation(deleteGsapAnimation(sel, animId), sel, "delete", "Delete GSAP animation");
157
180
  },
158
- [domEditSelection, deleteGsapAnimation],
181
+ [domEditSelection, deleteGsapAnimation, observeGsapMutation],
159
182
  );
160
183
 
161
184
  const handleGsapDeleteAllForElement = useCallback(
@@ -163,9 +186,14 @@ export function useGsapSelectionHandlers({
163
186
  const sel = domEditSelection ?? lastSelectionRef.current;
164
187
  if (!sel) return;
165
188
  trackStudioEvent("keyframe", { action: "delete_all" });
166
- deleteAllForSelector(sel, targetSelector);
189
+ observeGsapMutation(
190
+ deleteAllForSelector(sel, targetSelector),
191
+ sel,
192
+ "delete-all-for-selector",
193
+ "Delete all animations for element",
194
+ );
167
195
  },
168
- [domEditSelection, deleteAllForSelector],
196
+ [domEditSelection, deleteAllForSelector, observeGsapMutation],
169
197
  );
170
198
 
171
199
  const handleGsapAddAnimation = useCallback(
@@ -186,41 +214,66 @@ export function useGsapSelectionHandlers({
186
214
  const handleGsapAddProperty = useCallback(
187
215
  (animId: string, prop: string) => {
188
216
  if (!domEditSelection) return;
189
- addGsapProperty(domEditSelection, animId, prop);
217
+ observeGsapMutation(
218
+ addGsapProperty(domEditSelection, animId, prop),
219
+ domEditSelection,
220
+ "add-property",
221
+ `Add GSAP ${prop}`,
222
+ );
190
223
  },
191
- [domEditSelection, addGsapProperty],
224
+ [domEditSelection, addGsapProperty, observeGsapMutation],
192
225
  );
193
226
 
194
227
  const handleGsapRemoveProperty = useCallback(
195
228
  (animId: string, prop: string) => {
196
229
  if (!domEditSelection) return;
197
- removeGsapProperty(domEditSelection, animId, prop);
230
+ observeGsapMutation(
231
+ removeGsapProperty(domEditSelection, animId, prop),
232
+ domEditSelection,
233
+ "remove-property",
234
+ `Remove GSAP ${prop}`,
235
+ );
198
236
  },
199
- [domEditSelection, removeGsapProperty],
237
+ [domEditSelection, observeGsapMutation, removeGsapProperty],
200
238
  );
201
239
 
202
240
  const handleGsapUpdateFromProperty = useCallback(
203
241
  (animId: string, prop: string, value: number | string) => {
204
242
  if (!domEditSelection) return;
205
- updateGsapFromProperty(domEditSelection, animId, prop, value);
243
+ observeGsapMutation(
244
+ updateGsapFromProperty(domEditSelection, animId, prop, value),
245
+ domEditSelection,
246
+ "update-from-property",
247
+ `Edit GSAP from-${prop}`,
248
+ );
206
249
  },
207
- [domEditSelection, updateGsapFromProperty],
250
+ [domEditSelection, observeGsapMutation, updateGsapFromProperty],
208
251
  );
209
252
 
210
253
  const handleGsapAddFromProperty = useCallback(
211
254
  (animId: string, prop: string) => {
212
255
  if (!domEditSelection) return;
213
- addGsapFromProperty(domEditSelection, animId, prop);
256
+ observeGsapMutation(
257
+ addGsapFromProperty(domEditSelection, animId, prop),
258
+ domEditSelection,
259
+ "add-from-property",
260
+ `Add GSAP from-${prop}`,
261
+ );
214
262
  },
215
- [domEditSelection, addGsapFromProperty],
263
+ [domEditSelection, addGsapFromProperty, observeGsapMutation],
216
264
  );
217
265
 
218
266
  const handleGsapRemoveFromProperty = useCallback(
219
267
  (animId: string, prop: string) => {
220
268
  if (!domEditSelection) return;
221
- removeGsapFromProperty(domEditSelection, animId, prop);
269
+ observeGsapMutation(
270
+ removeGsapFromProperty(domEditSelection, animId, prop),
271
+ domEditSelection,
272
+ "remove-from-property",
273
+ `Remove GSAP from-${prop}`,
274
+ );
222
275
  },
223
- [domEditSelection, removeGsapFromProperty],
276
+ [domEditSelection, observeGsapMutation, removeGsapFromProperty],
224
277
  );
225
278
 
226
279
  const handleGsapAddKeyframe = useCallback(
@@ -354,18 +407,28 @@ export function useGsapSelectionHandlers({
354
407
  const handleGsapRemoveAllKeyframes = useCallback(
355
408
  (animId: string) => {
356
409
  if (!domEditSelection) return;
357
- removeAllKeyframes(domEditSelection, animId);
410
+ observeGsapMutation(
411
+ removeAllKeyframes(domEditSelection, animId),
412
+ domEditSelection,
413
+ "remove-all-keyframes",
414
+ "Remove all keyframes",
415
+ );
358
416
  },
359
- [domEditSelection, removeAllKeyframes],
417
+ [domEditSelection, observeGsapMutation, removeAllKeyframes],
360
418
  );
361
419
 
362
420
  const handleResetSelectedElementKeyframes = useCallback((): boolean => {
363
421
  if (!domEditSelection) return false;
364
422
  const withKeyframes = selectedGsapAnimations.find((a) => a.keyframes);
365
423
  if (!withKeyframes) return false;
366
- removeAllKeyframes(domEditSelection, withKeyframes.id);
424
+ observeGsapMutation(
425
+ removeAllKeyframes(domEditSelection, withKeyframes.id),
426
+ domEditSelection,
427
+ "remove-all-keyframes",
428
+ "Remove all keyframes",
429
+ );
367
430
  return true;
368
- }, [domEditSelection, selectedGsapAnimations, removeAllKeyframes]);
431
+ }, [domEditSelection, observeGsapMutation, removeAllKeyframes, selectedGsapAnimations]);
369
432
 
370
433
  return {
371
434
  handleGsapUpdateProperty,
@@ -0,0 +1,51 @@
1
+ import { useCallback, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
2
+
3
+ const MIN_INSPECTOR_SPLIT_PERCENT = 20;
4
+ const MAX_INSPECTOR_SPLIT_PERCENT = 75;
5
+
6
+ export function useInspectorSplitResize() {
7
+ const [layersPanePercent, setLayersPanePercent] = useState(40);
8
+ const splitContainerRef = useRef<HTMLDivElement>(null);
9
+ const splitDragRef = useRef<{
10
+ startY: number;
11
+ startPercent: number;
12
+ height: number;
13
+ } | null>(null);
14
+
15
+ const handleInspectorSplitResizeStart = useCallback(
16
+ (event: ReactPointerEvent<HTMLDivElement>) => {
17
+ event.preventDefault();
18
+ event.currentTarget.setPointerCapture(event.pointerId);
19
+ const height = splitContainerRef.current?.getBoundingClientRect().height ?? 0;
20
+ splitDragRef.current = {
21
+ startY: event.clientY,
22
+ startPercent: layersPanePercent,
23
+ height,
24
+ };
25
+ },
26
+ [layersPanePercent],
27
+ );
28
+
29
+ const handleInspectorSplitResizeMove = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
30
+ const drag = splitDragRef.current;
31
+ if (!drag || drag.height <= 0) return;
32
+ const deltaPercent = ((event.clientY - drag.startY) / drag.height) * 100;
33
+ const next = Math.min(
34
+ MAX_INSPECTOR_SPLIT_PERCENT,
35
+ Math.max(MIN_INSPECTOR_SPLIT_PERCENT, drag.startPercent + deltaPercent),
36
+ );
37
+ setLayersPanePercent(next);
38
+ }, []);
39
+
40
+ const handleInspectorSplitResizeEnd = useCallback(() => {
41
+ splitDragRef.current = null;
42
+ }, []);
43
+
44
+ return {
45
+ layersPanePercent,
46
+ splitContainerRef,
47
+ handleInspectorSplitResizeStart,
48
+ handleInspectorSplitResizeMove,
49
+ handleInspectorSplitResizeEnd,
50
+ };
51
+ }
@@ -0,0 +1,60 @@
1
+ // @vitest-environment happy-dom
2
+ import { act } from "react";
3
+ import { createRoot } from "react-dom/client";
4
+ import { describe, expect, it } from "vitest";
5
+ import { createMemoryEditHistoryStorage } from "../utils/editHistoryStorage";
6
+ import { usePersistentEditHistory } from "./usePersistentEditHistory";
7
+
8
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
9
+
10
+ async function flushAsyncEffects(): Promise<void> {
11
+ await act(async () => {
12
+ await new Promise((resolve) => setTimeout(resolve, 0));
13
+ });
14
+ }
15
+
16
+ describe("usePersistentEditHistory project ownership", () => {
17
+ it("rejects a delayed project A recorder after project B becomes active", async () => {
18
+ const storage = createMemoryEditHistoryStorage();
19
+ const now = () => 100;
20
+ const captured: { history: ReturnType<typeof usePersistentEditHistory> | null } = {
21
+ history: null,
22
+ };
23
+
24
+ function Probe({ projectId }: { projectId: string }) {
25
+ captured.history = usePersistentEditHistory({ projectId, storage, now });
26
+ return null;
27
+ }
28
+
29
+ const root = createRoot(document.createElement("div"));
30
+ await act(async () => root.render(<Probe projectId="project-a" />));
31
+ await flushAsyncEffects();
32
+ const recordProjectA = captured.history?.recordEdit;
33
+ if (!recordProjectA) throw new Error("project A history did not load");
34
+
35
+ await act(async () => root.render(<Probe projectId="project-b" />));
36
+ await expect(
37
+ recordProjectA({
38
+ label: "Delayed A edit",
39
+ kind: "manual",
40
+ files: { "index.html": { before: "A", after: "A2" } },
41
+ }),
42
+ ).rejects.toThrow("inactive project project-a");
43
+ await flushAsyncEffects();
44
+
45
+ const recordProjectB = captured.history?.recordEdit;
46
+ if (!recordProjectB) throw new Error("project B history did not load");
47
+ await act(async () => {
48
+ await recordProjectB({
49
+ label: "B edit",
50
+ kind: "manual",
51
+ files: { "index.html": { before: "B", after: "B2" } },
52
+ });
53
+ });
54
+
55
+ expect(await storage.get("project-a")).toBeNull();
56
+ expect((await storage.get("project-b"))?.undo.map((entry) => entry.label)).toEqual(["B edit"]);
57
+
58
+ await act(async () => root.unmount());
59
+ });
60
+ });
@@ -1,7 +1,11 @@
1
- import { describe, expect, it } from "vitest";
1
+ import { describe, expect, it, vi } from "vitest";
2
2
  import { createEmptyEditHistory } from "../utils/editHistory";
3
3
  import type { EditHistoryStorageAdapter } from "../utils/editHistoryStorage";
4
4
  import { createMemoryEditHistoryStorage } from "../utils/editHistoryStorage";
5
+ import {
6
+ serializeStudioFileMutation,
7
+ serializeStudioFileMutations,
8
+ } from "../utils/studioFileMutationCoordinator";
5
9
  import {
6
10
  createPersistentEditHistoryController,
7
11
  createPersistentEditHistoryStore,
@@ -213,6 +217,50 @@ describe("createPersistentEditHistoryController", () => {
213
217
  expect(store.snapshot().canRedo).toBe(true);
214
218
  });
215
219
 
220
+ it("waits for same-file mutations before checking an undo hash", async () => {
221
+ const storage = createMemoryEditHistoryStorage();
222
+ const store = createPersistentEditHistoryStore({
223
+ projectId: "project-1",
224
+ storage,
225
+ initialState: createEmptyEditHistory(),
226
+ now: () => 100,
227
+ onChange: () => {},
228
+ });
229
+ await store.recordEdit({
230
+ label: "Edit source",
231
+ kind: "source",
232
+ files: { "index.html": { before: "before", after: "after" } },
233
+ });
234
+
235
+ let disk = "after";
236
+ let release!: () => void;
237
+ const blocked = new Promise<void>((resolve) => {
238
+ release = resolve;
239
+ });
240
+ const writeFile = vi.fn(async (_path: string, content: string) => {
241
+ disk = content;
242
+ });
243
+ const priorMutation = serializeStudioFileMutation(writeFile, "index.html", async () => {
244
+ await blocked;
245
+ disk = "newer-edit";
246
+ });
247
+ const readFile = vi.fn(async () => disk);
248
+ const undo = store.undo({
249
+ readFile,
250
+ writeFile,
251
+ serialize: (paths, task) => serializeStudioFileMutations(writeFile, paths, task),
252
+ });
253
+
254
+ await Promise.resolve();
255
+ expect(readFile).not.toHaveBeenCalled();
256
+ release();
257
+ await priorMutation;
258
+ await expect(undo).resolves.toMatchObject({ ok: false, reason: "content-mismatch" });
259
+ expect(disk).toBe("newer-edit");
260
+ expect(writeFile).not.toHaveBeenCalled();
261
+ expect(store.snapshot().canUndo).toBe(true);
262
+ });
263
+
216
264
  it("returns per-file restored/previous content so the preview can soft-apply", async () => {
217
265
  const storage = createMemoryEditHistoryStorage();
218
266
  const store = createPersistentEditHistoryStore({
@@ -30,6 +30,7 @@ interface RecordEditInput {
30
30
  interface ApplyCallbacks {
31
31
  readFile: (path: string) => Promise<string>;
32
32
  writeFile: (path: string, content: string) => Promise<void>;
33
+ serialize?: <T>(paths: readonly string[], task: () => Promise<T>) => Promise<T>;
33
34
  }
34
35
 
35
36
  interface UsePersistentEditHistoryOptions {
@@ -167,31 +168,32 @@ async function applyHistoryStep(
167
168
  if (!entry) {
168
169
  return { state: currentState, result: { ok: false, reason: "empty" } };
169
170
  }
170
- const { currentFiles, currentHashes } = await readCurrentFileHashes(
171
- Object.keys(entry.files),
172
- callbacks.readFile,
173
- );
174
- const result = transition(currentState, currentHashes, now());
175
- if (!result.ok) {
171
+ const paths = Object.keys(entry.files);
172
+ const apply = async (): Promise<{ state: EditHistoryState; result: ApplyResult }> => {
173
+ const { currentFiles, currentHashes } = await readCurrentFileHashes(paths, callbacks.readFile);
174
+ const result = transition(currentState, currentHashes, now());
175
+ if (!result.ok) {
176
+ return {
177
+ state: currentState,
178
+ result: { ok: false, reason: result.reason },
179
+ };
180
+ }
181
+ await writeFilesWithRollback({
182
+ files: result.filesToWrite,
183
+ rollbackFiles: currentFiles,
184
+ writeFile: callbacks.writeFile,
185
+ });
176
186
  return {
177
- state: currentState,
178
- result: { ok: false, reason: result.reason },
187
+ state: result.state,
188
+ result: {
189
+ ok: true,
190
+ label: result.entry.label,
191
+ paths: Object.keys(result.entry.files),
192
+ files: restoredFilesMap(result.filesToWrite, currentFiles),
193
+ },
179
194
  };
180
- }
181
- await writeFilesWithRollback({
182
- files: result.filesToWrite,
183
- rollbackFiles: currentFiles,
184
- writeFile: callbacks.writeFile,
185
- });
186
- return {
187
- state: result.state,
188
- result: {
189
- ok: true,
190
- label: result.entry.label,
191
- paths: Object.keys(result.entry.files),
192
- files: restoredFilesMap(result.filesToWrite, currentFiles),
193
- },
194
195
  };
196
+ return callbacks.serialize ? callbacks.serialize(paths, apply) : apply();
195
197
  }
196
198
 
197
199
  export function createPersistentEditHistoryStore({
@@ -305,11 +307,15 @@ export function usePersistentEditHistory(options: UsePersistentEditHistoryOption
305
307
  const [loaded, setLoaded] = useState(false);
306
308
  const projectId = options.projectId;
307
309
  const storeRef = useRef<ReturnType<typeof createPersistentEditHistoryStore> | null>(null);
310
+ const storeProjectIdRef = useRef<string | null>(null);
311
+ const activeProjectIdRef = useRef(projectId);
312
+ activeProjectIdRef.current = projectId;
308
313
 
309
314
  useEffect(() => {
310
315
  let cancelled = false;
311
316
  const emptyState = createEmptyEditHistory();
312
317
  storeRef.current = null;
318
+ storeProjectIdRef.current = null;
313
319
  setState(emptyState);
314
320
  setLoaded(false);
315
321
  if (!projectId) {
@@ -327,6 +333,7 @@ export function usePersistentEditHistory(options: UsePersistentEditHistoryOption
327
333
  now,
328
334
  onChange: setState,
329
335
  });
336
+ storeProjectIdRef.current = projectId;
330
337
  setState(loadedState);
331
338
  })
332
339
  .catch(() => {
@@ -338,6 +345,7 @@ export function usePersistentEditHistory(options: UsePersistentEditHistoryOption
338
345
  now,
339
346
  onChange: setState,
340
347
  });
348
+ storeProjectIdRef.current = projectId;
341
349
  setState(emptyState);
342
350
  })
343
351
  .finally(() => {
@@ -349,17 +357,49 @@ export function usePersistentEditHistory(options: UsePersistentEditHistoryOption
349
357
  };
350
358
  }, [now, projectId, storage]);
351
359
 
352
- const recordEdit = useCallback(async (input: RecordEditInput) => {
353
- await storeRef.current?.recordEdit(input);
354
- }, []);
360
+ const recordEdit = useCallback(
361
+ async (input: RecordEditInput) => {
362
+ if (!projectId) return;
363
+ if (activeProjectIdRef.current !== projectId) {
364
+ throw new Error(`Cannot record an edit for inactive project ${projectId}`);
365
+ }
366
+ const store = storeRef.current;
367
+ if (!store) return;
368
+ if (storeProjectIdRef.current !== projectId) {
369
+ throw new Error(`Edit history store does not belong to project ${projectId}`);
370
+ }
371
+ await store.recordEdit(input);
372
+ },
373
+ [projectId],
374
+ );
355
375
 
356
- const undo = useCallback(async (callbacks: ApplyCallbacks): Promise<ApplyResult> => {
357
- return storeRef.current?.undo(callbacks) ?? { ok: false, reason: "empty" };
358
- }, []);
376
+ const undo = useCallback(
377
+ async (callbacks: ApplyCallbacks): Promise<ApplyResult> => {
378
+ if (
379
+ !projectId ||
380
+ activeProjectIdRef.current !== projectId ||
381
+ storeProjectIdRef.current !== projectId
382
+ ) {
383
+ return { ok: false, reason: "empty" };
384
+ }
385
+ return storeRef.current?.undo(callbacks) ?? { ok: false, reason: "empty" };
386
+ },
387
+ [projectId],
388
+ );
359
389
 
360
- const redo = useCallback(async (callbacks: ApplyCallbacks): Promise<ApplyResult> => {
361
- return storeRef.current?.redo(callbacks) ?? { ok: false, reason: "empty" };
362
- }, []);
390
+ const redo = useCallback(
391
+ async (callbacks: ApplyCallbacks): Promise<ApplyResult> => {
392
+ if (
393
+ !projectId ||
394
+ activeProjectIdRef.current !== projectId ||
395
+ storeProjectIdRef.current !== projectId
396
+ ) {
397
+ return { ok: false, reason: "empty" };
398
+ }
399
+ return storeRef.current?.redo(callbacks) ?? { ok: false, reason: "empty" };
400
+ },
401
+ [projectId],
402
+ );
363
403
 
364
404
  return {
365
405
  loaded,
@@ -103,17 +103,16 @@ export function useEditVariablesInFile(deps: EditVariablesDeps) {
103
103
  return useCallback(
104
104
  async (path: string, label: string, mutate: (session: Composition) => void): Promise<void> => {
105
105
  const originalContent = await readProjectFile(path);
106
- const comp = await openComposition(originalContent, { history: false });
107
- let after: string;
108
- try {
109
- mutate(comp);
110
- after = comp.serialize();
111
- } finally {
112
- comp.dispose();
113
- }
114
- if (after === originalContent) return;
115
106
  await persistSdkSerialize(
116
- after,
107
+ async (onDiskBefore) => {
108
+ const comp = await openComposition(onDiskBefore, { history: false });
109
+ try {
110
+ mutate(comp);
111
+ return comp.serialize();
112
+ } finally {
113
+ comp.dispose();
114
+ }
115
+ },
117
116
  path,
118
117
  originalContent,
119
118
  {
@@ -122,6 +121,7 @@ export function useEditVariablesInFile(deps: EditVariablesDeps) {
122
121
  reloadPreview,
123
122
  domEditSaveTimestampRef,
124
123
  compositionPath: path,
124
+ readProjectFile,
125
125
  },
126
126
  { label },
127
127
  );