@hyperframes/studio 0.8.114 → 0.8.115

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 (155) hide show
  1. package/dist/assets/{hyperframes-player-aXNJKiIg.js → hyperframes-player-C-9N8nk0.js} +1 -1
  2. package/dist/assets/{index-Bn2RJNTJ.js → index-BoUM6Xqb.js} +1 -1
  3. package/dist/assets/{index-Dz7doz9l.js → index-CAvZ0AA-.js} +201 -201
  4. package/dist/assets/{index-C9N-DGjC.js → index-CVuorIMv.js} +1 -1
  5. package/dist/assets/{index-BYnsJnJV.css → index-t1LH6H5H.css} +1 -1
  6. package/dist/{chunk-K47E2ZJA.js → chunk-5EKWJZLM.js} +504 -17
  7. package/dist/chunk-5EKWJZLM.js.map +1 -0
  8. package/dist/{chunk-VWT7STY3.js → chunk-J6Q572FA.js} +2 -2
  9. package/dist/{chunk-BIUXP3PG.js → chunk-TXZMMOMA.js} +43 -276
  10. package/dist/chunk-TXZMMOMA.js.map +1 -0
  11. package/dist/{domEditingLayers-V67MS4WP.js → domEditingLayers-D7VYFJJD.js} +3 -5
  12. package/dist/index.d.ts +43 -19
  13. package/dist/index.html +2 -2
  14. package/dist/index.js +2939 -2596
  15. package/dist/index.js.map +1 -1
  16. package/dist/player.js +2 -2
  17. package/dist/styles.css +1 -1
  18. package/package.json +9 -8
  19. package/src/App.tsx +2 -2
  20. package/src/components/LinkedSelectionToggle.tsx +3 -2
  21. package/src/components/dock/Dock.tsx +5 -7
  22. package/src/components/editor/CanvasContextMenu.tsx +3 -2
  23. package/src/components/editor/DomEditCropHandles.test.tsx +27 -0
  24. package/src/components/editor/DomEditCropHandles.tsx +7 -4
  25. package/src/components/editor/DomEditOverlay.tsx +5 -1
  26. package/src/components/editor/FileTreeNodes.tsx +13 -10
  27. package/src/components/editor/MotionPathOverlay.tsx +7 -2
  28. package/src/components/editor/PropertyPanelEmptyState.tsx +12 -2
  29. package/src/components/editor/SnapToolbar.test.tsx +100 -1
  30. package/src/components/editor/SnapToolbar.tsx +43 -5
  31. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +48 -1
  32. package/src/components/editor/audioFxTelemetry.ts +2 -3
  33. package/src/components/editor/commitPreflightHandles.test.tsx +2 -2
  34. package/src/components/editor/cropResize.ts +4 -3
  35. package/src/components/editor/domEditingDom.ts +4 -0
  36. package/src/components/editor/domEditingLayers.ts +1 -5
  37. package/src/components/editor/manualEdits.ts +1 -0
  38. package/src/components/editor/manualEditsParsing.ts +7 -0
  39. package/src/components/editor/marqueeCommit.test.tsx +106 -37
  40. package/src/components/editor/marqueeCommit.ts +24 -8
  41. package/src/components/editor/motionPathCommit.test.ts +29 -1
  42. package/src/components/editor/motionPathCommit.ts +21 -9
  43. package/src/components/editor/probeSourceElement.test.ts +45 -0
  44. package/src/components/editor/probeSourceElement.ts +39 -15
  45. package/src/components/editor/propertyPanelFlatTextSection.tsx +10 -7
  46. package/src/components/editor/propertyPanelFxAddMenu.tsx +11 -2
  47. package/src/components/editor/propertyPanelFxCarveModule.tsx +15 -5
  48. package/src/components/editor/propertyPanelFxNodeOpenBody.tsx +3 -0
  49. package/src/components/editor/propertyPanelFxNodeParams.tsx +15 -2
  50. package/src/components/editor/propertyPanelFxNodeRow.tsx +3 -0
  51. package/src/components/editor/propertyPanelFxPresetRun.tsx +10 -2
  52. package/src/components/editor/propertyPanelFxSection.test.tsx +56 -0
  53. package/src/components/editor/propertyPanelFxSection.tsx +30 -28
  54. package/src/components/editor/propertyPanelTypes.ts +6 -3
  55. package/src/components/editor/useDomEditNudge.test.tsx +37 -0
  56. package/src/components/editor/useDomEditNudge.ts +2 -0
  57. package/src/components/editor/useDomEditOverlayGestures.ts +5 -0
  58. package/src/components/editor/useDomEditZOrder.ts +12 -2
  59. package/src/components/nle/useTimelineEditCallbacks.test.tsx +1 -0
  60. package/src/components/nle/useTimelineEditCallbacks.ts +7 -1
  61. package/src/components/sidebar/AssetContextMenu.tsx +4 -4
  62. package/src/components/ui/menuStyle.test.ts +61 -0
  63. package/src/components/ui/menuStyle.ts +10 -0
  64. package/src/contexts/DomEditContext.tsx +1 -1
  65. package/src/contexts/TimelineEditContext.tsx +1 -0
  66. package/src/domEditExports.test.tsx +9 -1
  67. package/src/hooks/domEditCommitRunner.ts +1 -1
  68. package/src/hooks/elementOffsetStager.ts +5 -4
  69. package/src/hooks/gsapDragCommit.ts +2 -20
  70. package/src/hooks/gsapDragPositionCommit.ts +18 -3
  71. package/src/hooks/gsapGestureOutcome.test.ts +161 -0
  72. package/src/hooks/gsapGestureOutcome.ts +64 -0
  73. package/src/hooks/gsapKeyframeCommit.ts +3 -12
  74. package/src/hooks/gsapResizeIntercept.test.ts +33 -0
  75. package/src/hooks/gsapResizeIntercept.ts +1 -0
  76. package/src/hooks/gsapScriptCommitTypes.ts +5 -0
  77. package/src/hooks/gsapShared.writeTarget.test.ts +20 -0
  78. package/src/hooks/keyframeCacheAstLoad.ts +6 -5
  79. package/src/hooks/plainRotation.ts +3 -2
  80. package/src/hooks/previewPromotionSelection.test.tsx +1 -1
  81. package/src/hooks/refreshTimelineRowText.test.ts +65 -0
  82. package/src/hooks/refreshTimelineRowText.ts +29 -0
  83. package/src/hooks/useAnimatedPropertyCommit.test.tsx +46 -5
  84. package/src/hooks/useAnimatedPropertyCommit.ts +2 -2
  85. package/src/hooks/useBlockedTimelineEditToast.ts +12 -3
  86. package/src/hooks/useDomEditPositionPatchCommit.ts +18 -23
  87. package/src/hooks/useDomEditTextCommits.test.tsx +18 -0
  88. package/src/hooks/useDomEditTextCommits.ts +5 -1
  89. package/src/hooks/useDomEditWiring.ts +4 -2
  90. package/src/hooks/useDomGeometryCommit.test.tsx +25 -6
  91. package/src/hooks/useDomGeometryCommit.ts +7 -4
  92. package/src/hooks/useDomGeometryCommits.test.tsx +1 -0
  93. package/src/hooks/useDomGeometryCommits.ts +3 -2
  94. package/src/hooks/useDomSelection.test.ts +26 -0
  95. package/src/hooks/useDomSelection.ts +10 -3
  96. package/src/hooks/useDomSelectionTypes.ts +6 -1
  97. package/src/hooks/useEnableKeyframes.test.ts +2 -2
  98. package/src/hooks/useEnableKeyframes.ts +5 -3
  99. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +102 -0
  100. package/src/hooks/useExternalFileChangeCoordinator.ts +31 -6
  101. package/src/hooks/useGestureCommit.test.tsx +225 -55
  102. package/src/hooks/useGestureCommit.ts +86 -55
  103. package/src/hooks/useGsapAwareEditing.test.tsx +11 -6
  104. package/src/hooks/useGsapAwareEditing.ts +96 -195
  105. package/src/hooks/useGsapAwareGroupMove.ts +179 -0
  106. package/src/hooks/useGsapKeyframeOps.sdkUsage.test.tsx +114 -0
  107. package/src/hooks/useGsapKeyframeOps.ts +36 -10
  108. package/src/hooks/useGsapScriptCommits.test.tsx +57 -0
  109. package/src/hooks/useGsapScriptCommits.ts +3 -0
  110. package/src/hooks/useGsapSelectionHandlers.test.tsx +1 -1
  111. package/src/hooks/useGsapSelectionHandlers.ts +24 -18
  112. package/src/hooks/useGsapTweenCache.ts +18 -12
  113. package/src/hooks/usePopulateKeyframeCacheForFile.test.tsx +76 -2
  114. package/src/hooks/usePreviewInteraction.test.ts +21 -0
  115. package/src/hooks/usePreviewInteraction.ts +2 -0
  116. package/src/hooks/useRenderClipContent.test.ts +18 -0
  117. package/src/hooks/useRenderClipContent.ts +4 -5
  118. package/src/hooks/useTimelineDeleteOps.test.tsx +30 -22
  119. package/src/hooks/useTimelineDeleteOps.ts +21 -27
  120. package/src/hooks/useTimelineEditing.test.tsx +1 -1
  121. package/src/player/components/AutomationSelectionMenu.tsx +3 -2
  122. package/src/player/components/ClipContextMenu.test.tsx +73 -0
  123. package/src/player/components/ClipContextMenu.tsx +33 -31
  124. package/src/player/components/KeyframeDiamondContextMenu.tsx +8 -9
  125. package/src/player/components/OutOfSyncBadge.tsx +5 -5
  126. package/src/player/components/TextClipContent.test.ts +19 -0
  127. package/src/player/components/TextClipContent.tsx +27 -0
  128. package/src/player/components/TrackGapContextMenu.tsx +3 -6
  129. package/src/player/components/clipMenuToolItems.test.tsx +23 -0
  130. package/src/player/components/clipMenuToolItems.tsx +13 -9
  131. package/src/player/components/timelineCallbacks.ts +1 -0
  132. package/src/player/components/timelineClipDragGestureLifecycle.ts +1 -1
  133. package/src/player/components/timelineClipGestureHandlers.test.ts +94 -0
  134. package/src/player/components/timelineClipGestureHandlers.ts +20 -2
  135. package/src/player/components/timelineEditing.ts +3 -1
  136. package/src/player/components/timelineLinkSelection.ts +35 -1
  137. package/src/player/components/useTimelineClipDrag.keyboard.test.tsx +14 -0
  138. package/src/player/components/useTimelineClipDrag.ts +6 -7
  139. package/src/player/hooks/timelinePlayerSync.test.ts +6 -0
  140. package/src/player/hooks/timelinePlayerSync.ts +6 -1
  141. package/src/player/lib/timelineElementHelpers.test.ts +87 -1
  142. package/src/player/lib/timelineElementHelpers.ts +6 -15
  143. package/src/player/lib/timelineText.ts +40 -0
  144. package/src/player/store/timelineElement.ts +12 -0
  145. package/src/styles/theme.css +3 -0
  146. package/src/utils/keyframeUsage.ts +79 -0
  147. package/src/utils/previewFeatureUsage.test.ts +32 -0
  148. package/src/utils/previewFeatureUsage.ts +46 -0
  149. package/src/utils/studioTelemetry.test.ts +107 -0
  150. package/src/utils/studioTelemetry.ts +49 -4
  151. package/src/webmcp/StudioAgentTools.tsx +2 -2
  152. package/dist/chunk-BIUXP3PG.js.map +0 -1
  153. package/dist/chunk-K47E2ZJA.js.map +0 -1
  154. /package/dist/{chunk-VWT7STY3.js.map → chunk-J6Q572FA.js.map} +0 -0
  155. /package/dist/{domEditingLayers-V67MS4WP.js.map → domEditingLayers-D7VYFJJD.js.map} +0 -0
@@ -1,3 +1,6 @@
1
+ import { useGsapAwareGroupMove } from "./useGsapAwareGroupMove";
2
+ import { observeGsapGesture } from "./gsapGestureOutcome";
3
+ import type { DomEditPersistOutcome } from "./domEditCommitTypes";
1
4
  /**
2
5
  * GSAP-aware move/resize/rotation wrappers that intercept geometry commits
3
6
  * for animated elements and route them through script mutation instead of
@@ -21,14 +24,9 @@ import {
21
24
  useGsapSaveFailureTelemetry,
22
25
  useSafeGsapCommitMutation,
23
26
  } from "./useSafeGsapCommitMutation";
24
- import type {
25
- CommitMutation,
26
- CommitMutationCall,
27
- CommitMutationOptions,
28
- } from "./gsapScriptCommitTypes";
27
+ import type { CommitMutation, CommitMutationOptions } from "./gsapScriptCommitTypes";
29
28
  import { setElementGsapPosition } from "../utils/elementGsap";
30
29
  import { logResize, logResizeSettle } from "../utils/resizeDebug";
31
- import type { DomEditGroupPathOffsetCommit } from "../components/editor/DomEditOverlay";
32
30
  import type { MoveCommitOptions } from "../components/editor/domEditOverlayGestures";
33
31
  import { runGestureTransaction } from "./gestureTransaction";
34
32
  import {
@@ -40,29 +38,13 @@ import {
40
38
  } from "./gsapRuntimeKeyframes";
41
39
  import { assertGsapEditPersisted, saveMove } from "./gsapEditOutcome";
42
40
  import type { GsapAnimationFetchOptions } from "./useGsapAnimationFetchFallback";
43
- import { refuseGsapTakeover, type ElementOffsetStagerDeps } from "./elementOffsetStager";
41
+ import { type ElementOffsetStagerDeps } from "./elementOffsetStager";
44
42
  import {
45
43
  prepareCropResize,
46
44
  saveCropResize,
47
45
  writeSizeWithCrop,
48
46
  } from "../components/editor/cropResize";
49
47
 
50
- // Distinct coalesceKey per group drag so consecutive group drags don't fold
51
- // into one another's undo entry (module-local counter, not Date.now()).
52
- let groupDragCommitCounter = 0;
53
-
54
- function firstPreflightFailure(
55
- results: PromiseSettledResult<void>[],
56
- updates: DomEditGroupPathOffsetCommit[],
57
- ): { error: unknown; selection: DomEditSelection } | null {
58
- for (const [index, result] of results.entries()) {
59
- if (result.status !== "rejected") continue;
60
- const selection = updates[index]?.selection;
61
- if (selection) return { error: result.reason, selection };
62
- }
63
- return null;
64
- }
65
-
66
48
  export interface UseGsapAwareEditingParams {
67
49
  domEditSelection: DomEditSelection | null;
68
50
  selectedGsapAnimations: GsapAnimation[];
@@ -86,15 +68,18 @@ export interface UseGsapAwareEditingParams {
86
68
  next: { x: number; y: number },
87
69
  plainTranslate: boolean,
88
70
  coalesceKey?: string,
89
- ) => { save: () => Promise<void>; rollback: () => void };
90
- handleDomRotationCommit: (selection: DomEditSelection, next: RotationCommit) => Promise<void>;
71
+ ) => { save: () => Promise<DomEditPersistOutcome | undefined>; rollback: () => void };
72
+ handleDomRotationCommit: (
73
+ selection: DomEditSelection,
74
+ next: RotationCommit,
75
+ ) => Promise<DomEditPersistOutcome | undefined>;
91
76
  handleDomBoxSizeCommit: (
92
77
  selection: DomEditSelection,
93
78
  next: { width: number; height: number },
94
79
  offset?: { x: number; y: number },
95
80
  restore?: () => void,
96
81
  undoKey?: string,
97
- ) => Promise<void>;
82
+ ) => Promise<DomEditPersistOutcome | undefined>;
98
83
  commitPositionPatchToHtml: ElementOffsetStagerDeps["commitPositionPatchToHtml"];
99
84
  // GSAP script commit ops (from useGsapScriptCommits)
100
85
  addGsapAnimation: (
@@ -162,10 +147,12 @@ export function useGsapAwareEditing({
162
147
  next: { x: number; y: number },
163
148
  modifiers?: MoveCommitOptions,
164
149
  ) => {
165
- // A gesture and the panel carry their route; webmcp's moveTo decides here.
166
- if (modifiers?.plainTranslate ?? !gsapWritesPosition(selection.element))
167
- return stageElementPositionOffset(selection, next, true).save();
168
- if (gsapCommitMutation) {
150
+ const writes = observeGsapGesture(gsapCommitMutation);
151
+ if (modifiers?.plainTranslate ?? !gsapWritesPosition(selection.element)) {
152
+ const result = await stageElementPositionOffset(selection, next, true).save();
153
+ return writes.finish(result?.changed === true);
154
+ }
155
+ if (writes.commit) {
169
156
  try {
170
157
  const ownedAnimations = getGsapAnimationsForSelection(selection);
171
158
  const targetAnimations = Array.isArray(ownedAnimations)
@@ -176,11 +163,15 @@ export function useGsapAwareEditing({
176
163
  next,
177
164
  targetAnimations,
178
165
  previewIframeRef.current,
179
- gsapCommitMutation,
166
+ writes.commit,
180
167
  makeFetchFallback(selection),
181
168
  modifiers,
182
169
  );
183
- await saveMove(outcome, () => stageElementPositionOffset(selection, next, false).save());
170
+ await saveMove(outcome, async () => {
171
+ const result = await stageElementPositionOffset(selection, next, false).save();
172
+ writes.recordDomResult(result);
173
+ });
174
+ return writes.finish();
184
175
  } catch (error) {
185
176
  trackGsapInteractionFailure(error, selection, "drag", "Move animated layer");
186
177
  throw error;
@@ -197,137 +188,14 @@ export function useGsapAwareEditing({
197
188
  ],
198
189
  );
199
190
 
200
- // Multi-select (group) drag: each member takes the single drag's writer, so a member GSAP
201
- // does not position is saved on itself and the rest go through the GSAP intercept.
202
- const handleGsapAwareGroupPathOffsetCommit = useCallback(
203
- async (updates: DomEditGroupPathOffsetCommit[]) => {
204
- if (!gsapCommitMutation || updates.length === 0) return;
205
- // A group drag is ONE user action: fold every member's position write into
206
- // a single undo entry by forcing a shared coalesceKey (infinite window, so
207
- // it survives the N sequential server round-trips) onto each commit —
208
- // otherwise each member records its own entry and it takes N presses to undo.
209
- const coalesceKey = `group-drag:${++groupDragCommitCounter}`;
210
- // Members are written one at a time, and a re-render re-runs the script with the OLD
211
- // position of every member not yet written, so they snap back until their own write
212
- // lands. The drafts are already on screen: hold the render until the last member.
213
- let renderOnCommit = false;
214
- const previewFallbackLatch = { pending: false };
215
- const withGroupOptions = (options: CommitMutationOptions): CommitMutationOptions => ({
216
- ...options,
217
- coalesceKey,
218
- coalesceMs: Number.POSITIVE_INFINITY,
219
- deferPreviewSync: !renderOnCommit,
220
- previewFallbackLatch,
221
- });
222
- // Every member writes the same file. Queue their mutations and send them as
223
- // ONE request instead of one round trip per member: the server reads, parses
224
- // and writes the composition once, and the preview patches once.
225
- const queued: CommitMutationCall[] = [];
226
- const flushQueued = async () => {
227
- if (queued.length === 0) return;
228
- const calls = queued.splice(0, queued.length);
229
- if (!gsapCommitMutation.batch) {
230
- for (const call of calls) {
231
- await gsapCommitMutation(call.selection, call.mutation, call.options);
232
- }
233
- return;
234
- }
235
- await gsapCommitMutation.batch(calls, {
236
- ...(calls.at(-1)?.options ?? { label: "Move animated layer (group)" }),
237
- label: "Move animated layer (group)",
238
- });
239
- };
240
- const coalescedCommit: typeof gsapCommitMutation = (selection, mutation, options) => {
241
- queued.push({ selection, mutation, options: withGroupOptions(options) });
242
- return Promise.resolve();
243
- };
244
- const preflightAnimations = new Map<DomEditSelection, GsapAnimation[]>();
245
- // Members saved on themselves, each with its route: true for its CSS translate.
246
- const offsetMembers = new Map<DomEditSelection, boolean>();
247
- // Editability is user-atomic: prove every member can be written before the first source
248
- // mutation, so a blocked member never leaves earlier siblings partially moved. Preflights
249
- // write nothing and share one in-flight parse per file, so they run together.
250
- const preflightResults = await Promise.allSettled(
251
- updates.map(async ({ selection, plainTranslate }) => {
252
- if (plainTranslate ?? !gsapWritesPosition(selection.element)) {
253
- refuseGsapTakeover(selection.element, showToast);
254
- return void offsetMembers.set(selection, true);
255
- }
256
- const animations = await makeFetchFallback(selection, { failOnFetchError: true })();
257
- preflightAnimations.set(selection, animations);
258
- const outcome = await tryGsapDragIntercept(
259
- selection,
260
- { x: 0, y: 0 },
261
- animations,
262
- previewIframeRef.current,
263
- coalescedCommit,
264
- undefined,
265
- { preflightOnly: true, group: true },
266
- );
267
- if (outcome.status === "element-offset") offsetMembers.set(selection, false);
268
- assertGsapEditPersisted(outcome);
269
- }),
270
- );
271
- const preflightFailure = firstPreflightFailure(preflightResults, updates);
272
- if (preflightFailure) {
273
- trackGsapInteractionFailure(
274
- preflightFailure.error,
275
- preflightFailure.selection,
276
- "drag",
277
- "Move animated layer (group)",
278
- );
279
- throw preflightFailure.error;
280
- }
281
- const lastScriptWrite = updates.findLastIndex(
282
- ({ selection }) => !offsetMembers.has(selection),
283
- );
284
- for (const [index, { selection, next }] of updates.entries()) {
285
- renderOnCommit = index === lastScriptWrite;
286
- const plain = offsetMembers.get(selection);
287
- if (plain !== undefined) {
288
- await stageElementPositionOffset(selection, next, plain, coalesceKey).save();
289
- continue;
290
- }
291
- try {
292
- const outcome = await tryGsapDragIntercept(
293
- selection,
294
- next,
295
- preflightAnimations.get(selection) ?? [],
296
- previewIframeRef.current,
297
- coalescedCommit,
298
- // The intercept re-reads the file to resolve a stale or shared tween.
299
- // Anything already queued has to be on disk before that read, or it
300
- // resolves against a file missing writes it is about to build on.
301
- async () => {
302
- await flushQueued();
303
- return makeFetchFallback(selection, { fresh: true })();
304
- },
305
- { preflightPassed: true },
306
- );
307
- assertGsapEditPersisted(outcome);
308
- } catch (error) {
309
- trackGsapInteractionFailure(error, selection, "drag", "Move animated layer (group)");
310
- throw error;
311
- }
312
- }
313
- try {
314
- await flushQueued();
315
- } catch (error) {
316
- // The aggregate write has no uniquely failing member; do not misattribute
317
- // its telemetry to whichever member happened to be last in the array.
318
- trackGsapInteractionFailure(error, null, "drag", "Move animated layer (group)");
319
- throw error;
320
- }
321
- },
322
- [
323
- gsapCommitMutation,
324
- previewIframeRef,
325
- makeFetchFallback,
326
- trackGsapInteractionFailure,
327
- stageElementPositionOffset,
328
- showToast,
329
- ],
330
- );
191
+ const handleGsapAwareGroupPathOffsetCommit = useGsapAwareGroupMove({
192
+ gsapCommitMutation,
193
+ previewIframeRef,
194
+ makeFetchFallback,
195
+ trackGsapInteractionFailure,
196
+ stageElementPositionOffset,
197
+ showToast,
198
+ });
331
199
 
332
200
  const handleGsapAwareBoxSizeCommit = useCallback(
333
201
  async (
@@ -336,8 +204,11 @@ export function useGsapAwareEditing({
336
204
  offset?: { x: number; y: number },
337
205
  restore: () => void = () => undefined,
338
206
  ) => {
339
- if (!gsapWritesBox(selection.element))
340
- return handleDomBoxSizeCommit(selection, next, offset, restore);
207
+ const writes = observeGsapGesture(gsapCommitMutation);
208
+ if (!gsapWritesBox(selection.element)) {
209
+ const result = await handleDomBoxSizeCommit(selection, next, offset, restore);
210
+ return writes.finish(result?.changed === true);
211
+ }
341
212
  let targetAnimations: GsapAnimation[];
342
213
  try {
343
214
  const ownedAnimations = getGsapAnimationsForSelection(selection);
@@ -367,7 +238,28 @@ export function useGsapAwareEditing({
367
238
  let anchorMove: ReturnType<typeof stageElementPositionOffset> | null = null;
368
239
  const stageCrop = prepareCropResize(selection.element);
369
240
  let cropUndoKey: string | null = null;
370
- return runGestureTransaction({
241
+ const saveResizeAnchor = async (
242
+ ownsDragOffset: boolean,
243
+ commitMutation: CommitMutation,
244
+ coalesceKey: string,
245
+ ) => {
246
+ if (offset && !ownsDragOffset) {
247
+ const dragOutcome = await tryGsapDragIntercept(
248
+ selection,
249
+ offset,
250
+ targetAnimations,
251
+ previewIframeRef.current,
252
+ commitMutation,
253
+ makeFetchFallback(selection),
254
+ );
255
+ // Saved after the size, under its undo key, so the two are one step.
256
+ await saveMove(dragOutcome, async () => {
257
+ const plain = !gsapWritesPosition(selection.element);
258
+ anchorMove = stageElementPositionOffset(selection, offset, plain, coalesceKey);
259
+ });
260
+ }
261
+ };
262
+ await runGestureTransaction({
371
263
  element: selection.element,
372
264
  label: "Resize layer",
373
265
  settle: () => {
@@ -383,8 +275,8 @@ export function useGsapAwareEditing({
383
275
  setElementGsapPosition(selection.element, newX, newY);
384
276
  },
385
277
  persist: async (commit, coalesceKey) => {
386
- if (gsapCommitMutation) {
387
- const commitMutation = commit(gsapCommitMutation);
278
+ if (writes.commit) {
279
+ const commitMutation = commit(writes.commit);
388
280
  try {
389
281
  const outcome = await tryGsapResizeIntercept(
390
282
  selection,
@@ -396,9 +288,16 @@ export function useGsapAwareEditing({
396
288
  );
397
289
  assertGsapEditPersisted(outcome);
398
290
  // Saved before the buffered GSAP writes, so their reload stays the gesture's last render.
399
- if (outcome.status === "element-size")
400
- await handleDomBoxSizeCommit(selection, next, undefined, undefined, coalesceKey);
401
- else cropUndoKey = coalesceKey;
291
+ if (outcome.status === "element-size") {
292
+ const result = await handleDomBoxSizeCommit(
293
+ selection,
294
+ next,
295
+ undefined,
296
+ undefined,
297
+ coalesceKey,
298
+ );
299
+ writes.recordDomResult(result);
300
+ } else cropUndoKey = coalesceKey;
402
301
  // What the resize did, not what its tweens suggest: a scale hold still commits a size.
403
302
  const ownsDragOffset =
404
303
  outcome.status === "persisted" && outcome.ownsDragOffset === true;
@@ -409,21 +308,7 @@ export function useGsapAwareEditing({
409
308
  });
410
309
  // A resize that moved the element itself has already written
411
310
  // where it landed. Everything else leaves the anchor to the drag.
412
- if (offset && !ownsDragOffset) {
413
- const dragOutcome = await tryGsapDragIntercept(
414
- selection,
415
- offset,
416
- targetAnimations,
417
- previewIframeRef.current,
418
- commitMutation,
419
- makeFetchFallback(selection),
420
- );
421
- // Saved after the size, under its undo key, so the two are one step.
422
- await saveMove(dragOutcome, async () => {
423
- const plain = !gsapWritesPosition(selection.element);
424
- anchorMove = stageElementPositionOffset(selection, offset, plain, coalesceKey);
425
- });
426
- }
311
+ await saveResizeAnchor(ownsDragOffset, commitMutation, coalesceKey);
427
312
  logResizeSettle(selection.element, ownsDragOffset ? "gsap-scale" : "gsap-size");
428
313
  return;
429
314
  } catch (error) {
@@ -434,10 +319,17 @@ export function useGsapAwareEditing({
434
319
  throw new Error("Resize of a GSAP-owned box has no GSAP writer");
435
320
  },
436
321
  afterBufferedCommitsSaved: async () => {
437
- await anchorMove?.save();
322
+ const anchorResult = await anchorMove?.save();
323
+ writes.recordDomResult(anchorResult);
438
324
  // Only now is the size live for every caller, drag or not.
439
325
  if (cropUndoKey) {
440
- await saveCropResize(stageCrop, selection, commitPositionPatchToHtml, cropUndoKey);
326
+ const cropResult = await saveCropResize(
327
+ stageCrop,
328
+ selection,
329
+ commitPositionPatchToHtml,
330
+ cropUndoKey,
331
+ );
332
+ writes.recordDomResult(cropResult);
441
333
  }
442
334
  },
443
335
  restore: () => {
@@ -446,6 +338,7 @@ export function useGsapAwareEditing({
446
338
  },
447
339
  skipPixelAssert: hasLivePositionTween,
448
340
  });
341
+ return writes.finish();
449
342
  },
450
343
  [
451
344
  handleDomBoxSizeCommit,
@@ -461,9 +354,12 @@ export function useGsapAwareEditing({
461
354
 
462
355
  const handleGsapAwareRotationCommit = useCallback(
463
356
  async (selection: DomEditSelection, next: RotationCommit) => {
464
- if (next.plain || !gsapWritesRotation(selection.element))
465
- return handleDomRotationCommit(selection, next);
466
- if (gsapCommitMutation) {
357
+ const writes = observeGsapGesture(gsapCommitMutation);
358
+ if (next.plain || !gsapWritesRotation(selection.element)) {
359
+ const result = await handleDomRotationCommit(selection, next);
360
+ return writes.finish(result?.changed === true);
361
+ }
362
+ if (writes.commit) {
467
363
  try {
468
364
  const targetAnimations = await getGsapAnimationsForSelection(selection);
469
365
  // A keyframe or a tl.set; a computed source rejects, so the gesture restores its draft.
@@ -472,10 +368,11 @@ export function useGsapAwareEditing({
472
368
  next.angle,
473
369
  targetAnimations,
474
370
  previewIframeRef.current,
475
- gsapCommitMutation,
371
+ writes.commit,
476
372
  makeFetchFallback(selection),
477
373
  );
478
374
  assertGsapEditPersisted(outcome);
375
+ return writes.finish();
479
376
  } catch (error) {
480
377
  trackGsapInteractionFailure(error, selection, "rotation", "Rotate animated layer");
481
378
  throw error;
@@ -564,12 +461,16 @@ export function useGsapAwareEditing({
564
461
  );
565
462
 
566
463
  const commitMutation = useCallback(
567
- async (mutation: Record<string, unknown>, options: { label: string; softReload?: boolean }) => {
568
- if (!domEditSelection) return;
464
+ async (
465
+ mutation: Record<string, unknown>,
466
+ options: CommitMutationOptions,
467
+ selection = domEditSelection,
468
+ ) => {
469
+ if (!selection) return;
569
470
  // Return (await) the safe-commit chain so consumers that `await
570
471
  // session.commitMutation(...)` (gesture recording, enable-keyframes) run
571
472
  // their post-actions only after the server save has settled.
572
- await safeGsapCommit(domEditSelection, mutation, options);
473
+ await safeGsapCommit(selection, mutation, options);
573
474
  },
574
475
  [domEditSelection, safeGsapCommit],
575
476
  );
@@ -0,0 +1,179 @@
1
+ import { useCallback } from "react";
2
+ import type { DomEditSelection } from "../components/editor/domEditingTypes";
3
+ import type { DomEditGroupPathOffsetCommit } from "../components/editor/DomEditOverlay";
4
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
5
+ import type { CommitMutationCall, CommitMutationOptions } from "./gsapScriptCommitTypes";
6
+ import type { UseGsapAwareEditingParams } from "./useGsapAwareEditing";
7
+ import { observeGsapGesture } from "./gsapGestureOutcome";
8
+ import { gsapWritesPosition } from "./gsapRuntimeKeyframes";
9
+ import { refuseGsapTakeover } from "./elementOffsetStager";
10
+ import { tryGsapDragIntercept } from "./gsapRuntimeBridge";
11
+ import { assertGsapEditPersisted } from "./gsapEditOutcome";
12
+
13
+ // A distinct key keeps consecutive group drags in separate undo entries.
14
+ let groupDragCommitCounter = 0;
15
+
16
+ function firstPreflightFailure(
17
+ results: PromiseSettledResult<void>[],
18
+ updates: DomEditGroupPathOffsetCommit[],
19
+ ): { error: unknown; selection: DomEditSelection } | null {
20
+ for (const [index, result] of results.entries()) {
21
+ if (result.status !== "rejected") continue;
22
+ const selection = updates[index]?.selection;
23
+ if (selection) return { error: result.reason, selection };
24
+ }
25
+ return null;
26
+ }
27
+
28
+ export function useGsapAwareGroupMove({
29
+ gsapCommitMutation,
30
+ previewIframeRef,
31
+ makeFetchFallback,
32
+ trackGsapInteractionFailure,
33
+ stageElementPositionOffset,
34
+ showToast,
35
+ }: Pick<
36
+ UseGsapAwareEditingParams,
37
+ | "gsapCommitMutation"
38
+ | "previewIframeRef"
39
+ | "makeFetchFallback"
40
+ | "trackGsapInteractionFailure"
41
+ | "stageElementPositionOffset"
42
+ | "showToast"
43
+ >) {
44
+ const handleGsapAwareGroupPathOffsetCommit = useCallback(
45
+ async (
46
+ updates: DomEditGroupPathOffsetCommit[],
47
+ ): Promise<import("../utils/previewFeatureUsage").GeometryCommitResult> => {
48
+ const writes = observeGsapGesture(gsapCommitMutation);
49
+ const writer = writes.commit;
50
+ if (!writer) return { ok: true, changed: false };
51
+ // One coalesce key across slow writes keeps the group drag in one undo entry.
52
+ const coalesceKey = `group-drag:${++groupDragCommitCounter}`;
53
+ // Hold the render until every member is saved, or unwritten members snap back.
54
+ let renderOnCommit = false;
55
+ const previewFallbackLatch = { pending: false };
56
+ const withGroupOptions = (options: CommitMutationOptions): CommitMutationOptions => ({
57
+ ...options,
58
+ coalesceKey,
59
+ coalesceMs: Number.POSITIVE_INFINITY,
60
+ deferPreviewSync: !renderOnCommit,
61
+ previewFallbackLatch,
62
+ });
63
+ // Batch members in the same file to avoid repeating its read, parse, write and preview patch.
64
+ const queued: CommitMutationCall[] = [];
65
+ const flushQueued = async () => {
66
+ if (queued.length === 0) return;
67
+ const calls = queued.splice(0, queued.length);
68
+ if (!writer.batch) {
69
+ for (const call of calls) {
70
+ await writer(call.selection, call.mutation, call.options);
71
+ }
72
+ return;
73
+ }
74
+ await writer.batch(calls, {
75
+ ...(calls.at(-1)?.options ?? { label: "Move animated layer (group)" }),
76
+ label: "Move animated layer (group)",
77
+ });
78
+ };
79
+ const coalescedCommit: typeof gsapCommitMutation = (selection, mutation, options) => {
80
+ queued.push({ selection, mutation, options: withGroupOptions(options) });
81
+ return Promise.resolve();
82
+ };
83
+ const preflightAnimations = new Map<DomEditSelection, GsapAnimation[]>();
84
+ // Members saved on themselves, each with its route: true for its CSS translate.
85
+ const offsetMembers = new Map<DomEditSelection, boolean>();
86
+ // Editability is user-atomic: prove every member can be written before the first source
87
+ // mutation, so a blocked member never leaves earlier siblings partially moved. Preflights
88
+ // write nothing and share one in-flight parse per file, so they run together.
89
+ const preflightResults = await Promise.allSettled(
90
+ updates.map(async ({ selection, plainTranslate }) => {
91
+ if (plainTranslate ?? !gsapWritesPosition(selection.element)) {
92
+ refuseGsapTakeover(selection.element, showToast);
93
+ return void offsetMembers.set(selection, true);
94
+ }
95
+ const animations = await makeFetchFallback(selection, { failOnFetchError: true })();
96
+ preflightAnimations.set(selection, animations);
97
+ const outcome = await tryGsapDragIntercept(
98
+ selection,
99
+ { x: 0, y: 0 },
100
+ animations,
101
+ previewIframeRef.current,
102
+ coalescedCommit,
103
+ undefined,
104
+ { preflightOnly: true, group: true },
105
+ );
106
+ if (outcome.status === "element-offset") offsetMembers.set(selection, false);
107
+ assertGsapEditPersisted(outcome);
108
+ }),
109
+ );
110
+ const preflightFailure = firstPreflightFailure(preflightResults, updates);
111
+ if (preflightFailure) {
112
+ trackGsapInteractionFailure(
113
+ preflightFailure.error,
114
+ preflightFailure.selection,
115
+ "drag",
116
+ "Move animated layer (group)",
117
+ );
118
+ throw preflightFailure.error;
119
+ }
120
+ const lastScriptWrite = updates.findLastIndex(
121
+ ({ selection }) => !offsetMembers.has(selection),
122
+ );
123
+ for (const [index, { selection, next }] of updates.entries()) {
124
+ renderOnCommit = index === lastScriptWrite;
125
+ const plain = offsetMembers.get(selection);
126
+ if (plain !== undefined) {
127
+ const result = await stageElementPositionOffset(
128
+ selection,
129
+ next,
130
+ plain,
131
+ coalesceKey,
132
+ ).save();
133
+ writes.recordDomResult(result);
134
+ continue;
135
+ }
136
+ try {
137
+ const outcome = await tryGsapDragIntercept(
138
+ selection,
139
+ next,
140
+ preflightAnimations.get(selection) ?? [],
141
+ previewIframeRef.current,
142
+ coalescedCommit,
143
+ // The intercept re-reads the file to resolve a stale or shared tween.
144
+ // Anything already queued has to be on disk before that read, or it
145
+ // resolves against a file missing writes it is about to build on.
146
+ async () => {
147
+ await flushQueued();
148
+ return makeFetchFallback(selection, { fresh: true })();
149
+ },
150
+ { preflightPassed: true },
151
+ );
152
+ assertGsapEditPersisted(outcome);
153
+ } catch (error) {
154
+ trackGsapInteractionFailure(error, selection, "drag", "Move animated layer (group)");
155
+ throw error;
156
+ }
157
+ }
158
+ try {
159
+ await flushQueued();
160
+ return writes.finish();
161
+ } catch (error) {
162
+ // The aggregate write has no uniquely failing member; do not misattribute
163
+ // its telemetry to whichever member happened to be last in the array.
164
+ trackGsapInteractionFailure(error, null, "drag", "Move animated layer (group)");
165
+ throw error;
166
+ }
167
+ },
168
+ [
169
+ gsapCommitMutation,
170
+ previewIframeRef,
171
+ makeFetchFallback,
172
+ trackGsapInteractionFailure,
173
+ stageElementPositionOffset,
174
+ showToast,
175
+ ],
176
+ );
177
+
178
+ return handleGsapAwareGroupPathOffsetCommit;
179
+ }