@hyperframes/studio 0.8.138 → 0.8.140

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 (135) hide show
  1. package/dist/assets/{hyperframes-player-B30apksz.js → hyperframes-player-BLuyxnK7.js} +1 -1
  2. package/dist/assets/{index-DXVvWfQG.js → index-CWfuDCoA.js} +1 -1
  3. package/dist/assets/{index-DIJuIBwI.js → index-Dr3Bo9uH.js} +1 -1
  4. package/dist/assets/{index-BdjA0CLD.js → index-VAHDM_zL.js} +210 -210
  5. package/dist/{chunk-4S3AYCOG.js → chunk-H2DUOXTF.js} +3 -2
  6. package/dist/{chunk-TDWGXBUT.js → chunk-TFLU2MGI.js} +1 -6
  7. package/dist/chunk-TFLU2MGI.js.map +1 -0
  8. package/dist/{chunk-7UQWVN52.js → chunk-UYWON5AX.js} +2 -2
  9. package/dist/{domEditingLayers-IEVLO54Z.js → domEditingLayers-KBTPQKGA.js} +3 -3
  10. package/dist/index.d.ts +20 -3
  11. package/dist/index.html +1 -1
  12. package/dist/index.js +7327 -6265
  13. package/dist/index.js.map +1 -1
  14. package/dist/player.js +2 -2
  15. package/package.json +8 -8
  16. package/src/App.tsx +9 -4
  17. package/src/components/TimelineToolbar.test.tsx +79 -1
  18. package/src/components/TimelineToolbar.tsx +14 -29
  19. package/src/components/editor/DomEditOverlay.tsx +11 -7
  20. package/src/components/editor/PropertyPanel.test.tsx +600 -748
  21. package/src/components/editor/PropertyPanel.tsx +1 -0
  22. package/src/components/editor/PropertyPanelFlat.tsx +1 -0
  23. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +2 -0
  24. package/src/components/editor/domEditOverlayGeometry.ts +7 -7
  25. package/src/components/editor/domEditOverlayGestures.ts +18 -0
  26. package/src/components/editor/domEditOverlayStartGesture.blocked.test.ts +1 -0
  27. package/src/components/editor/domEditOverlayStartGesture.test.ts +3 -0
  28. package/src/components/editor/domEditOverlayStartGesture.ts +9 -1
  29. package/src/components/editor/dragUndoPaint.test.ts +2 -0
  30. package/src/components/editor/gestureHoldsPreviewReload.test.tsx +432 -15
  31. package/src/components/editor/groupDropKeepsSelection.test.ts +1 -0
  32. package/src/components/editor/manualEdits.ts +1 -1
  33. package/src/components/editor/manualEditsDom.ts +7 -7
  34. package/src/components/editor/manualEditsParsing.ts +14 -2
  35. package/src/components/editor/manualEditsTypes.ts +5 -0
  36. package/src/components/editor/manualOffsetDrag.test.ts +23 -0
  37. package/src/components/editor/manualOffsetDrag.ts +5 -0
  38. package/src/components/editor/previewGestureStarts.ts +284 -0
  39. package/src/components/editor/previewReadOnly.overlay.test.tsx +3 -2
  40. package/src/components/editor/propertyPanelFill.tsx +36 -13
  41. package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +4 -0
  42. package/src/components/editor/propertyPanelFlatStyleSections.tsx +13 -7
  43. package/src/components/editor/propertyPanelImageFill.test.tsx +164 -0
  44. package/src/components/editor/propertyPanelStyleSections.tsx +10 -7
  45. package/src/components/editor/rotateInlineSave.test.ts +1 -0
  46. package/src/components/editor/shiftDragAxisLock.test.tsx +1 -0
  47. package/src/components/editor/useDomEditOverlayGestures.ts +67 -71
  48. package/src/components/nle/NLEContext.test.ts +26 -0
  49. package/src/components/nle/NLEContext.tsx +8 -3
  50. package/src/components/nle/NLEPreview.test.ts +44 -1
  51. package/src/components/nle/NLEPreview.tsx +7 -2
  52. package/src/hooks/draggedGsapPosition.ts +8 -0
  53. package/src/hooks/editMoment.ts +13 -0
  54. package/src/hooks/externalFileChangeOwnership.test.ts +25 -4
  55. package/src/hooks/gsapDragCommit.ts +2 -1
  56. package/src/hooks/gsapDragPositionCommit.ts +19 -7
  57. package/src/hooks/gsapPositionDetection.ts +5 -3
  58. package/src/hooks/gsapResizeIntercept.ts +11 -4
  59. package/src/hooks/gsapRuntimeBridge.ts +31 -11
  60. package/src/hooks/gsapValueAtPlayhead.browser.test.ts +3 -22
  61. package/src/hooks/gsapValueAtPlayhead.test.ts +15 -0
  62. package/src/hooks/gsapValueAtPlayhead.ts +12 -5
  63. package/src/hooks/timelineEditingHelpers.test.ts +22 -0
  64. package/src/hooks/timelineEditingHelpers.ts +18 -2
  65. package/src/hooks/useDomSelection.test.ts +21 -2
  66. package/src/hooks/useEditHistoryActions.test.tsx +20 -0
  67. package/src/hooks/useEditHistoryActions.ts +2 -0
  68. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +536 -10
  69. package/src/hooks/useExternalFileChangeCoordinator.ts +72 -45
  70. package/src/hooks/useGsapAwareEditing.test.tsx +38 -0
  71. package/src/hooks/useGsapAwareEditing.ts +148 -136
  72. package/src/hooks/useGsapAwareGroupMove.ts +126 -113
  73. package/src/hooks/useGsapScriptCommits.ts +5 -4
  74. package/src/hooks/useMusicBeatAnalysis.ts +2 -1
  75. package/src/hooks/usePreviewPersistence.history.test.tsx +7 -2
  76. package/src/hooks/usePreviewPersistence.ts +1 -4
  77. package/src/hooks/useSdkSession.lifecycle.test.tsx +20 -0
  78. package/src/hooks/useSdkSession.ts +4 -1
  79. package/src/hooks/useThumbnailStripSize.test.tsx +65 -0
  80. package/src/hooks/useThumbnailStripSize.ts +23 -1
  81. package/src/hooks/useTimelineDeleteOps.test.tsx +10 -0
  82. package/src/hooks/useTimelineDeleteOps.ts +9 -10
  83. package/src/index.ts +2 -0
  84. package/src/player/components/AudioWaveform.test.tsx +153 -1
  85. package/src/player/components/AudioWaveform.tsx +93 -37
  86. package/src/player/components/Timeline.scrollRest.test.tsx +22 -0
  87. package/src/player/components/Timeline.test.ts +3 -1
  88. package/src/player/components/TimelineCanvas.tsx +4 -0
  89. package/src/player/components/TimelineClip.tsx +20 -6
  90. package/src/player/components/TimelineClipFades.test.tsx +11 -0
  91. package/src/player/components/TimelineClipFades.tsx +4 -4
  92. package/src/player/components/TimelineClipJoins.tsx +23 -18
  93. package/src/player/components/TimelineGestureOverlay.tsx +30 -27
  94. package/src/player/components/TimelineGroupRow.tsx +1 -0
  95. package/src/player/components/TimelineLaneClip.tsx +127 -0
  96. package/src/player/components/TimelineLanes.test.tsx +51 -6
  97. package/src/player/components/TimelineLanes.tsx +166 -178
  98. package/src/player/components/TimelineRuler.tsx +2 -0
  99. package/src/player/components/TimelineTimeLayer.tsx +25 -0
  100. package/src/player/components/VideoThumbnail.test.tsx +204 -12
  101. package/src/player/components/VideoThumbnail.tsx +37 -9
  102. package/src/player/components/thumbnailUtils.test.ts +17 -3
  103. package/src/player/components/thumbnailUtils.ts +17 -1
  104. package/src/player/components/timelineLayout.test.ts +3 -3
  105. package/src/player/components/timelineMotion.test.ts +27 -0
  106. package/src/player/components/timelineMotion.ts +62 -0
  107. package/src/player/components/timelineZoomInput.test.ts +438 -0
  108. package/src/player/components/timelineZoomInput.ts +456 -0
  109. package/src/player/components/useTimelineGeometry.ts +0 -3
  110. package/src/player/components/useTimelinePlayhead.test.tsx +97 -30
  111. package/src/player/components/useTimelinePlayhead.ts +53 -59
  112. package/src/player/components/useTimelineProviderState.tsx +16 -21
  113. package/src/player/components/useTimelineZoom.ts +7 -12
  114. package/src/player/hooks/useShadowPreviewReload.ts +6 -6
  115. package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +14 -0
  116. package/src/player/hooks/useTimelinePlayer.ts +8 -5
  117. package/src/player/lib/thumbnailScheduler.test.ts +20 -1
  118. package/src/player/lib/thumbnailScheduler.ts +1 -1
  119. package/src/player/lib/thumbnailVideoDecoder.test.ts +230 -30
  120. package/src/player/lib/thumbnailVideoDecoder.ts +197 -54
  121. package/src/player/lib/timelineElementHelpers.ts +0 -11
  122. package/src/player/lib/timelineViewportBudgets.test.ts +1 -1
  123. package/src/player/lib/timelineViewportBudgets.ts +1 -1
  124. package/src/player/previewReloading.test.ts +48 -0
  125. package/src/player/previewReloading.ts +48 -0
  126. package/src/utils/gsapUndoRestore.browser.test.ts +4 -17
  127. package/src/utils/rootDuration.ts +5 -4
  128. package/src/utils/studioPendingEdits.test.ts +27 -0
  129. package/src/utils/studioPendingEdits.ts +9 -0
  130. package/src/utils/timelineAssetDrop.test.ts +13 -4
  131. package/src/utils/timelineAssetDrop.ts +6 -2
  132. package/dist/chunk-TDWGXBUT.js.map +0 -1
  133. /package/dist/{chunk-4S3AYCOG.js.map → chunk-H2DUOXTF.js.map} +0 -0
  134. /package/dist/{chunk-7UQWVN52.js.map → chunk-UYWON5AX.js.map} +0 -0
  135. /package/dist/{domEditingLayers-IEVLO54Z.js.map → domEditingLayers-KBTPQKGA.js.map} +0 -0
@@ -17,6 +17,7 @@ import type { DomEditSelection } from "../components/editor/domEditingTypes";
17
17
  import { tryGsapDragIntercept, tryGsapRotationIntercept } from "./gsapRuntimeBridge";
18
18
  import { tryGsapResizeIntercept } from "./gsapResizeIntercept";
19
19
  import { computeDraggedGsapPosition, freezeDragStamp } from "./draggedGsapPosition";
20
+ import { whileScriptWrites } from "../player/previewReloading";
20
21
  import { readGsapPositionFromIframe } from "./gsapPositionDetection";
21
22
  import { selectorFromSelection } from "./gsapShared";
22
23
  import { useAnimatedPropertyCommit } from "./useAnimatedPropertyCommit";
@@ -153,7 +154,10 @@ export function useGsapAwareEditing({
153
154
  const result = await stageElementPositionOffset(selection, next, true).save();
154
155
  return writes.finish(result?.changed === true);
155
156
  }
156
- if (writes.commit) {
157
+ const writer = writes.commit;
158
+ if (!writer) return;
159
+ return whileScriptWrites(async () => {
160
+ const stamp = freezeDragStamp(selection.element);
157
161
  try {
158
162
  const ownedAnimations = getGsapAnimationsForSelection(selection);
159
163
  const targetAnimations = Array.isArray(ownedAnimations)
@@ -164,9 +168,9 @@ export function useGsapAwareEditing({
164
168
  next,
165
169
  targetAnimations,
166
170
  previewIframeRef.current,
167
- writes.commit,
171
+ writer,
168
172
  makeFetchFallback(selection),
169
- modifiers,
173
+ { ...modifiers, stamp },
170
174
  );
171
175
  await saveMove(outcome, async () => {
172
176
  const staged = writes.drawKeepingUndone(() =>
@@ -179,7 +183,7 @@ export function useGsapAwareEditing({
179
183
  trackGsapInteractionFailure(error, selection, "drag", "Move animated layer");
180
184
  throw error;
181
185
  }
182
- }
186
+ });
183
187
  },
184
188
  [
185
189
  gsapCommitMutation,
@@ -209,149 +213,153 @@ export function useGsapAwareEditing({
209
213
  restore: () => void = () => undefined,
210
214
  route?: { plainTranslate: boolean },
211
215
  ) => {
212
- const stamp = freezeDragStamp(selection.element);
213
216
  const writes = observeGsapGesture(gsapCommitMutation);
214
217
  if (route?.plainTranslate ?? editsPlainCss(selection.element, "resize")) {
215
218
  const result = await handleDomBoxSizeCommit(selection, next, offset, restore);
216
219
  return writes.finish(result?.changed === true);
217
220
  }
218
- let targetAnimations: GsapAnimation[];
219
- try {
220
- const ownedAnimations = getGsapAnimationsForSelection(selection);
221
- targetAnimations = Array.isArray(ownedAnimations) ? ownedAnimations : await ownedAnimations;
222
- } catch (error) {
223
- restore();
224
- trackGsapInteractionFailure(error, selection, "resize", "Resize animated layer");
225
- throw error;
226
- }
227
- const scaleRoute = targetAnimations.some((anim) => anim.propertyGroup === "scale");
228
- const selector = selectorFromSelection(selection);
229
- const hasLivePositionTween = selector
230
- ? hasNonHoldTweenForElement(
231
- previewIframeRef.current,
232
- selector,
233
- undefined,
234
- POSITION_CHANNELS,
235
- )
236
- : false;
237
- logResize("commit-route", {
238
- next,
239
- offset: offset ?? null,
240
- scaleRoute,
241
- animCount: targetAnimations.length,
242
- animGroups: targetAnimations.map((a) => `${a.propertyGroup}:${a.method}`),
243
- });
244
- let anchorMove: ReturnType<typeof stageElementPositionOffset> | null = null;
245
- const stageCrop = prepareCropResize(selection.element);
246
- let cropUndoKey: string | null = null;
247
- const saveResizeAnchor = async (
248
- ownsDragOffset: boolean,
249
- commitMutation: CommitMutation,
250
- coalesceKey: string,
251
- ) => {
252
- if (offset && !ownsDragOffset) {
253
- const dragOutcome = await tryGsapDragIntercept(
254
- selection,
255
- offset,
256
- targetAnimations,
257
- previewIframeRef.current,
258
- commitMutation,
259
- makeFetchFallback(selection),
260
- { stamp },
261
- );
262
- // Saved after the size, under its undo key, so the two are one step.
263
- await saveMove(dragOutcome, async () => {
264
- const plainAfterSettle = editsPlainCss(selection.element, "move");
265
- anchorMove = writes.drawKeepingUndone(() =>
266
- stageElementPositionOffset(selection, offset, plainAfterSettle, coalesceKey),
267
- );
268
- });
221
+ return whileScriptWrites(async () => {
222
+ const stamp = freezeDragStamp(selection.element);
223
+ let targetAnimations: GsapAnimation[];
224
+ try {
225
+ const ownedAnimations = getGsapAnimationsForSelection(selection);
226
+ targetAnimations = Array.isArray(ownedAnimations)
227
+ ? ownedAnimations
228
+ : await ownedAnimations;
229
+ } catch (error) {
230
+ restore();
231
+ trackGsapInteractionFailure(error, selection, "resize", "Resize animated layer");
232
+ throw error;
269
233
  }
270
- };
271
- await runGestureTransaction({
272
- element: selection.element,
273
- label: "Resize layer",
274
- draw: writes.drawKeepingUndone,
275
- settle: () => {
276
- // Scale resize settles its center-scale residual after the scale commit
277
- // renders. Width/height can settle its anchored position immediately.
278
- if (!offset || scaleRoute || !selector) return;
279
- writes.drawKeepingUndone(() => {
280
- const gsapPos = readGsapPositionFromIframe(previewIframeRef.current, selector) ?? {
281
- x: 0,
282
- y: 0,
283
- };
284
- const { newX, newY } = computeDraggedGsapPosition(
285
- selection.element,
234
+ const scaleRoute = targetAnimations.some((anim) => anim.propertyGroup === "scale");
235
+ const selector = selectorFromSelection(selection);
236
+ const hasLivePositionTween = selector
237
+ ? hasNonHoldTweenForElement(
238
+ previewIframeRef.current,
239
+ selector,
240
+ undefined,
241
+ POSITION_CHANNELS,
242
+ )
243
+ : false;
244
+ logResize("commit-route", {
245
+ next,
246
+ offset: offset ?? null,
247
+ scaleRoute,
248
+ animCount: targetAnimations.length,
249
+ animGroups: targetAnimations.map((a) => `${a.propertyGroup}:${a.method}`),
250
+ });
251
+ let anchorMove: ReturnType<typeof stageElementPositionOffset> | null = null;
252
+ const stageCrop = prepareCropResize(selection.element);
253
+ let cropUndoKey: string | null = null;
254
+ const saveResizeAnchor = async (
255
+ ownsDragOffset: boolean,
256
+ commitMutation: CommitMutation,
257
+ coalesceKey: string,
258
+ ) => {
259
+ if (offset && !ownsDragOffset) {
260
+ const dragOutcome = await tryGsapDragIntercept(
261
+ selection,
286
262
  offset,
287
- gsapPos,
288
- stamp,
263
+ targetAnimations,
264
+ previewIframeRef.current,
265
+ commitMutation,
266
+ makeFetchFallback(selection),
267
+ { stamp },
289
268
  );
290
- logResize("sync-settle", { gsapPos, offset, newX, newY });
291
- setElementGsapPosition(selection.element, newX, newY);
292
- });
293
- },
294
- persist: async (commit, coalesceKey) => {
295
- if (writes.commit) {
296
- const commitMutation = commit(writes.commit);
297
- try {
298
- const outcome = await tryGsapResizeIntercept(
299
- selection,
300
- next,
301
- targetAnimations,
302
- previewIframeRef.current,
303
- commitMutation,
304
- makeFetchFallback(selection),
269
+ // Saved after the size, under its undo key, so the two are one step.
270
+ await saveMove(dragOutcome, async () => {
271
+ const plainAfterSettle = editsPlainCss(selection.element, "move");
272
+ anchorMove = writes.drawKeepingUndone(() =>
273
+ stageElementPositionOffset(selection, offset, plainAfterSettle, coalesceKey),
274
+ );
275
+ });
276
+ }
277
+ };
278
+ await runGestureTransaction({
279
+ element: selection.element,
280
+ label: "Resize layer",
281
+ draw: writes.drawKeepingUndone,
282
+ settle: () => {
283
+ // Scale resize settles its center-scale residual after the scale commit
284
+ // renders. Width/height can settle its anchored position immediately.
285
+ if (!offset || scaleRoute || !selector) return;
286
+ writes.drawKeepingUndone(() => {
287
+ const gsapPos = readGsapPositionFromIframe(previewIframeRef.current, selector) ?? {
288
+ x: 0,
289
+ y: 0,
290
+ };
291
+ const { newX, newY } = computeDraggedGsapPosition(
292
+ selection.element,
305
293
  offset,
306
- writes.drawKeepingUndone,
294
+ gsapPos,
307
295
  stamp,
308
296
  );
309
- assertGsapEditPersisted(outcome);
310
- // Saved before the buffered GSAP writes, so their reload stays the gesture's last render.
311
- if (outcome.status === "element-size") {
312
- const result = await writes.drawKeepingUndone(() =>
313
- handleDomBoxSizeCommit(selection, next, undefined, undefined, coalesceKey),
297
+ logResize("sync-settle", { gsapPos, offset, newX, newY });
298
+ setElementGsapPosition(selection.element, newX, newY);
299
+ });
300
+ },
301
+ persist: async (commit, coalesceKey) => {
302
+ if (writes.commit) {
303
+ const commitMutation = commit(writes.commit);
304
+ try {
305
+ const outcome = await tryGsapResizeIntercept(
306
+ selection,
307
+ next,
308
+ targetAnimations,
309
+ previewIframeRef.current,
310
+ commitMutation,
311
+ makeFetchFallback(selection),
312
+ offset,
313
+ writes.drawKeepingUndone,
314
+ stamp,
314
315
  );
315
- writes.recordDomResult(result);
316
- } else cropUndoKey = coalesceKey;
317
- // What the resize did, not what its tweens suggest: a scale hold still commits a size.
318
- const ownsDragOffset =
319
- outcome.status === "persisted" && outcome.ownsDragOffset === true;
320
- logResize("intercept-handled", {
321
- scaleRoute,
322
- ownsDragOffset,
323
- willForwardOffset: !!(offset && !ownsDragOffset),
324
- });
325
- // A resize that moved the element itself has already written
326
- // where it landed. Everything else leaves the anchor to the drag.
327
- await saveResizeAnchor(ownsDragOffset, commitMutation, coalesceKey);
328
- logResizeSettle(selection.element, ownsDragOffset ? "gsap-scale" : "gsap-size");
329
- return;
330
- } catch (error) {
331
- trackGsapInteractionFailure(error, selection, "resize", "Resize animated layer");
332
- throw error;
316
+ assertGsapEditPersisted(outcome);
317
+ // Saved before the buffered GSAP writes, so their reload stays the gesture's last render.
318
+ if (outcome.status === "element-size") {
319
+ const result = await writes.drawKeepingUndone(() =>
320
+ handleDomBoxSizeCommit(selection, next, undefined, undefined, coalesceKey),
321
+ );
322
+ writes.recordDomResult(result);
323
+ } else cropUndoKey = coalesceKey;
324
+ // What the resize did, not what its tweens suggest: a scale hold still commits a size.
325
+ const ownsDragOffset =
326
+ outcome.status === "persisted" && outcome.ownsDragOffset === true;
327
+ logResize("intercept-handled", {
328
+ scaleRoute,
329
+ ownsDragOffset,
330
+ willForwardOffset: !!(offset && !ownsDragOffset),
331
+ });
332
+ // A resize that moved the element itself has already written
333
+ // where it landed. Everything else leaves the anchor to the drag.
334
+ await saveResizeAnchor(ownsDragOffset, commitMutation, coalesceKey);
335
+ logResizeSettle(selection.element, ownsDragOffset ? "gsap-scale" : "gsap-size");
336
+ return;
337
+ } catch (error) {
338
+ trackGsapInteractionFailure(error, selection, "resize", "Resize animated layer");
339
+ throw error;
340
+ }
333
341
  }
334
- }
335
- throw new Error("Resize of a GSAP-owned box has no GSAP writer");
336
- },
337
- afterBufferedCommitsSaved: async () => {
338
- const anchorResult = await anchorMove?.save();
339
- writes.recordDomResult(anchorResult);
340
- // Only now is the size live for every caller, drag or not.
341
- if (cropUndoKey) {
342
- const cropResult = await writes.drawKeepingUndone(() =>
343
- saveCropResize(stageCrop, selection, commitPositionPatchToHtml, cropUndoKey!),
344
- );
345
- writes.recordDomResult(cropResult);
346
- }
347
- },
348
- restore: () => {
349
- anchorMove?.rollback();
350
- restore();
351
- },
352
- skipPixelAssert: hasLivePositionTween,
342
+ throw new Error("Resize of a GSAP-owned box has no GSAP writer");
343
+ },
344
+ afterBufferedCommitsSaved: async () => {
345
+ const anchorResult = await anchorMove?.save();
346
+ writes.recordDomResult(anchorResult);
347
+ // Only now is the size live for every caller, drag or not.
348
+ if (cropUndoKey) {
349
+ const cropResult = await writes.drawKeepingUndone(() =>
350
+ saveCropResize(stageCrop, selection, commitPositionPatchToHtml, cropUndoKey!),
351
+ );
352
+ writes.recordDomResult(cropResult);
353
+ }
354
+ },
355
+ restore: () => {
356
+ anchorMove?.rollback();
357
+ restore();
358
+ },
359
+ skipPixelAssert: hasLivePositionTween,
360
+ });
361
+ return writes.finish();
353
362
  });
354
- return writes.finish();
355
363
  },
356
364
  [
357
365
  handleDomBoxSizeCommit,
@@ -372,7 +380,10 @@ export function useGsapAwareEditing({
372
380
  const result = await handleDomRotationCommit(selection, next);
373
381
  return writes.finish(result?.changed === true);
374
382
  }
375
- if (writes.commit) {
383
+ const writer = writes.commit;
384
+ if (!writer) return;
385
+ return whileScriptWrites(async () => {
386
+ const stamp = freezeDragStamp(selection.element);
376
387
  try {
377
388
  const targetAnimations = await getGsapAnimationsForSelection(selection);
378
389
  // A keyframe or a tl.set; a computed source rejects, so the gesture restores its draft.
@@ -381,8 +392,9 @@ export function useGsapAwareEditing({
381
392
  next.angle,
382
393
  targetAnimations,
383
394
  previewIframeRef.current,
384
- writes.commit,
395
+ writer,
385
396
  makeFetchFallback(selection),
397
+ stamp,
386
398
  );
387
399
  assertGsapEditPersisted(outcome);
388
400
  return writes.finish();
@@ -390,7 +402,7 @@ export function useGsapAwareEditing({
390
402
  trackGsapInteractionFailure(error, selection, "rotation", "Rotate animated layer");
391
403
  throw error;
392
404
  }
393
- }
405
+ });
394
406
  },
395
407
  [
396
408
  gsapCommitMutation,
@@ -8,8 +8,11 @@ import { observeGsapGesture } from "./gsapGestureOutcome";
8
8
  import { editsPlainCss } from "./gsapRuntimeKeyframes";
9
9
  import { refuseGsapTakeover } from "./elementOffsetStager";
10
10
  import { tryGsapDragIntercept } from "./gsapRuntimeBridge";
11
+ import { freezeDragStamp } from "./draggedGsapPosition";
11
12
  import { assertGsapEditPersisted } from "./gsapEditOutcome";
12
13
  import { firstPreflightFailure } from "./gsapGroupPreflight";
14
+ import { whileScriptWrites } from "../player/previewReloading";
15
+ import type { GeometryCommitResult } from "../utils/previewFeatureUsage";
13
16
 
14
17
  // A distinct key keeps consecutive group drags in separate undo entries.
15
18
  let groupDragCommitCounter = 0;
@@ -36,131 +39,141 @@ export function useGsapAwareGroupMove({
36
39
  async (
37
40
  updates: DomEditGroupPathOffsetCommit[],
38
41
  options: { refusalToast?: boolean } = {},
39
- ): Promise<import("../utils/previewFeatureUsage").GeometryCommitResult> => {
42
+ ): Promise<GeometryCommitResult> => {
40
43
  const writes = observeGsapGesture(gsapCommitMutation);
41
44
  const writer = writes.commit;
42
45
  if (!writer) return { ok: true, changed: false };
43
- const toastRefusal = options.refusalToast !== false;
44
- // One coalesce key across slow writes keeps the group drag in one undo entry.
45
- const coalesceKey = `group-drag:${++groupDragCommitCounter}`;
46
- // Hold the render until every member is saved, or unwritten members snap back.
47
- let renderOnCommit = false;
48
- const previewFallbackLatch = { pending: false };
49
- const withGroupOptions = (options: CommitMutationOptions): CommitMutationOptions => ({
50
- ...options,
51
- coalesceKey,
52
- coalesceMs: Number.POSITIVE_INFINITY,
53
- deferPreviewSync: !renderOnCommit,
54
- previewFallbackLatch,
55
- });
56
- // Batch members in the same file to avoid repeating its read, parse, write and preview patch.
57
- const queued: CommitMutationCall[] = [];
58
- const flushQueued = async () => {
59
- if (queued.length === 0) return;
60
- const calls = queued.splice(0, queued.length);
61
- if (!writer.batch) {
62
- for (const call of calls) {
63
- await writer(call.selection, call.mutation, call.options);
64
- }
65
- return;
66
- }
67
- await writer.batch(calls, {
68
- ...(calls.at(-1)?.options ?? { label: "Move animated layer (group)" }),
69
- label: "Move animated layer (group)",
46
+ const scripted = updates.some(
47
+ ({ selection, plainTranslate }) =>
48
+ !(plainTranslate ?? editsPlainCss(selection.element, "move")),
49
+ );
50
+ const commitGroup = async (): Promise<GeometryCommitResult> => {
51
+ const stamps = new Map(
52
+ updates.map((u) => [u.selection, freezeDragStamp(u.selection.element)]),
53
+ );
54
+ const toastRefusal = options.refusalToast !== false;
55
+ // One coalesce key across slow writes keeps the group drag in one undo entry.
56
+ const coalesceKey = `group-drag:${++groupDragCommitCounter}`;
57
+ // Hold the render until every member is saved, or unwritten members snap back.
58
+ let renderOnCommit = false;
59
+ const previewFallbackLatch = { pending: false };
60
+ const withGroupOptions = (options: CommitMutationOptions): CommitMutationOptions => ({
61
+ ...options,
62
+ coalesceKey,
63
+ coalesceMs: Number.POSITIVE_INFINITY,
64
+ deferPreviewSync: !renderOnCommit,
65
+ previewFallbackLatch,
70
66
  });
71
- };
72
- const coalescedCommit: typeof gsapCommitMutation = (selection, mutation, options) => {
73
- queued.push({ selection, mutation, options: withGroupOptions(options) });
74
- return Promise.resolve();
75
- };
76
- const preflightAnimations = new Map<DomEditSelection, GsapAnimation[]>();
77
- // Members saved on themselves, each with its route: true for its CSS translate.
78
- const offsetMembers = new Map<DomEditSelection, boolean>();
79
- // Editability is user-atomic: prove every member can be written before the first source
80
- // mutation, so a blocked member never leaves earlier siblings partially moved. Preflights
81
- // write nothing and share one in-flight parse per file, so they run together.
82
- const preflightResults = await Promise.allSettled(
83
- updates.map(async ({ selection, next, plainTranslate }) => {
84
- if (plainTranslate ?? editsPlainCss(selection.element, "move")) {
85
- refuseGsapTakeover(selection.element, toastRefusal ? showToast : () => {});
86
- return void offsetMembers.set(selection, true);
67
+ // Batch members in the same file to avoid repeating its read, parse, write and preview patch.
68
+ const queued: CommitMutationCall[] = [];
69
+ const flushQueued = async () => {
70
+ if (queued.length === 0) return;
71
+ const calls = queued.splice(0, queued.length);
72
+ if (!writer.batch) {
73
+ for (const call of calls) {
74
+ await writer(call.selection, call.mutation, call.options);
75
+ }
76
+ return;
87
77
  }
88
- const animations = await makeFetchFallback(selection, { failOnFetchError: true })();
89
- preflightAnimations.set(selection, animations);
90
- const outcome = await tryGsapDragIntercept(
91
- selection,
92
- next,
93
- animations,
94
- previewIframeRef.current,
95
- coalescedCommit,
96
- undefined,
97
- { preflightOnly: true, group: true },
98
- );
99
- if (outcome.status === "element-offset") offsetMembers.set(selection, false);
100
- assertGsapEditPersisted(outcome);
101
- }),
102
- );
103
- const preflightFailure = firstPreflightFailure(
104
- preflightResults,
105
- updates,
106
- offsetMembers,
107
- activeCompPath,
108
- );
109
- if (preflightFailure) {
110
- trackGsapInteractionFailure(
111
- preflightFailure.error,
112
- preflightFailure.selection,
113
- "drag",
114
- "Move animated layer (group)",
115
- toastRefusal,
78
+ await writer.batch(calls, {
79
+ ...(calls.at(-1)?.options ?? { label: "Move animated layer (group)" }),
80
+ label: "Move animated layer (group)",
81
+ });
82
+ };
83
+ const coalescedCommit: typeof gsapCommitMutation = (selection, mutation, options) => {
84
+ queued.push({ selection, mutation, options: withGroupOptions(options) });
85
+ return Promise.resolve();
86
+ };
87
+ const preflightAnimations = new Map<DomEditSelection, GsapAnimation[]>();
88
+ // Members saved on themselves, each with its route: true for its CSS translate.
89
+ const offsetMembers = new Map<DomEditSelection, boolean>();
90
+ // Editability is user-atomic: prove every member can be written before the first source
91
+ // mutation, so a blocked member never leaves earlier siblings partially moved. Preflights
92
+ // write nothing and share one in-flight parse per file, so they run together.
93
+ const preflightResults = await Promise.allSettled(
94
+ updates.map(async ({ selection, next, plainTranslate }) => {
95
+ if (plainTranslate ?? editsPlainCss(selection.element, "move")) {
96
+ refuseGsapTakeover(selection.element, toastRefusal ? showToast : () => {});
97
+ return void offsetMembers.set(selection, true);
98
+ }
99
+ const animations = await makeFetchFallback(selection, { failOnFetchError: true })();
100
+ preflightAnimations.set(selection, animations);
101
+ const outcome = await tryGsapDragIntercept(
102
+ selection,
103
+ next,
104
+ animations,
105
+ previewIframeRef.current,
106
+ coalescedCommit,
107
+ undefined,
108
+ { preflightOnly: true, group: true },
109
+ );
110
+ if (outcome.status === "element-offset") offsetMembers.set(selection, false);
111
+ assertGsapEditPersisted(outcome);
112
+ }),
116
113
  );
117
- throw preflightFailure.error;
118
- }
119
- const lastScriptWrite = updates.findLastIndex(
120
- ({ selection }) => !offsetMembers.has(selection),
121
- );
122
- for (const [index, { selection, next }] of updates.entries()) {
123
- renderOnCommit = index === lastScriptWrite;
124
- const plain = offsetMembers.get(selection);
125
- if (plain !== undefined) {
126
- const staged = writes.drawKeepingUndone(() =>
127
- stageElementPositionOffset(selection, next, plain, coalesceKey),
114
+ const preflightFailure = firstPreflightFailure(
115
+ preflightResults,
116
+ updates,
117
+ offsetMembers,
118
+ activeCompPath,
119
+ );
120
+ if (preflightFailure) {
121
+ trackGsapInteractionFailure(
122
+ preflightFailure.error,
123
+ preflightFailure.selection,
124
+ "drag",
125
+ "Move animated layer (group)",
126
+ toastRefusal,
128
127
  );
129
- const result = await staged.save();
130
- writes.recordDomResult(result);
131
- continue;
128
+ throw preflightFailure.error;
129
+ }
130
+ const lastScriptWrite = updates.findLastIndex(
131
+ ({ selection }) => !offsetMembers.has(selection),
132
+ );
133
+ for (const [index, { selection, next }] of updates.entries()) {
134
+ renderOnCommit = index === lastScriptWrite;
135
+ const plain = offsetMembers.get(selection);
136
+ if (plain !== undefined) {
137
+ const staged = writes.drawKeepingUndone(() =>
138
+ stageElementPositionOffset(selection, next, plain, coalesceKey),
139
+ );
140
+ const result = await staged.save();
141
+ writes.recordDomResult(result);
142
+ continue;
143
+ }
144
+ try {
145
+ const outcome = await tryGsapDragIntercept(
146
+ selection,
147
+ next,
148
+ preflightAnimations.get(selection) ?? [],
149
+ previewIframeRef.current,
150
+ coalescedCommit,
151
+ // The intercept re-reads the file to resolve a stale or shared tween.
152
+ // Anything already queued has to be on disk before that read, or it
153
+ // resolves against a file missing writes it is about to build on.
154
+ async () => {
155
+ await flushQueued();
156
+ return makeFetchFallback(selection, { fresh: true })();
157
+ },
158
+ { preflightPassed: true, stamp: stamps.get(selection) },
159
+ );
160
+ assertGsapEditPersisted(outcome);
161
+ } catch (error) {
162
+ trackGsapInteractionFailure(error, selection, "drag", "Move animated layer (group)");
163
+ throw error;
164
+ }
132
165
  }
133
166
  try {
134
- const outcome = await tryGsapDragIntercept(
135
- selection,
136
- next,
137
- preflightAnimations.get(selection) ?? [],
138
- previewIframeRef.current,
139
- coalescedCommit,
140
- // The intercept re-reads the file to resolve a stale or shared tween.
141
- // Anything already queued has to be on disk before that read, or it
142
- // resolves against a file missing writes it is about to build on.
143
- async () => {
144
- await flushQueued();
145
- return makeFetchFallback(selection, { fresh: true })();
146
- },
147
- { preflightPassed: true },
148
- );
149
- assertGsapEditPersisted(outcome);
167
+ await flushQueued();
168
+ return writes.finish();
150
169
  } catch (error) {
151
- trackGsapInteractionFailure(error, selection, "drag", "Move animated layer (group)");
170
+ // The aggregate write has no uniquely failing member; do not misattribute
171
+ // its telemetry to whichever member happened to be last in the array.
172
+ trackGsapInteractionFailure(error, null, "drag", "Move animated layer (group)");
152
173
  throw error;
153
174
  }
154
- }
155
- try {
156
- await flushQueued();
157
- return writes.finish();
158
- } catch (error) {
159
- // The aggregate write has no uniquely failing member; do not misattribute
160
- // its telemetry to whichever member happened to be last in the array.
161
- trackGsapInteractionFailure(error, null, "drag", "Move animated layer (group)");
162
- throw error;
163
- }
175
+ };
176
+ return scripted ? whileScriptWrites(commitGroup) : commitGroup();
164
177
  },
165
178
  [
166
179
  gsapCommitMutation,