@hyperframes/studio 0.8.137 → 0.8.139

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 (85) hide show
  1. package/dist/assets/{hyperframes-player-D_Mh6hbx.js → hyperframes-player-CckOoCJ8.js} +1 -1
  2. package/dist/assets/{index-CHEctGzc.js → index-BmIPrb8I.js} +1 -1
  3. package/dist/assets/{index-B3lJsA-e.js → index-Br1Whc0f.js} +189 -189
  4. package/dist/assets/{index-CC_E-Cbl.css → index-CVd4O6eH.css} +1 -1
  5. package/dist/assets/{index-rtccfg9b.js → index-DsEkE5_G.js} +1 -1
  6. package/dist/{chunk-4S3AYCOG.js → chunk-E422JDF5.js} +2 -2
  7. package/dist/{chunk-TDWGXBUT.js → chunk-TFLU2MGI.js} +1 -6
  8. package/dist/chunk-TFLU2MGI.js.map +1 -0
  9. package/dist/{chunk-7UQWVN52.js → chunk-UYWON5AX.js} +2 -2
  10. package/dist/{domEditingLayers-IEVLO54Z.js → domEditingLayers-KBTPQKGA.js} +3 -3
  11. package/dist/index.d.ts +2 -1
  12. package/dist/index.html +2 -2
  13. package/dist/index.js +867 -499
  14. package/dist/index.js.map +1 -1
  15. package/dist/player.js +2 -2
  16. package/dist/styles.css +1 -1
  17. package/package.json +9 -8
  18. package/src/App.tsx +11 -11
  19. package/src/components/editor/DomEditOverlay.tsx +9 -5
  20. package/src/components/editor/LayersPanel.test.ts +25 -0
  21. package/src/components/editor/LayersPanel.tsx +1 -1
  22. package/src/components/editor/PropertyPanel.test.tsx +600 -748
  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/manualEditsDom.ts +7 -7
  33. package/src/components/editor/manualEditsTypes.ts +5 -0
  34. package/src/components/editor/manualOffsetDrag.test.ts +23 -0
  35. package/src/components/editor/manualOffsetDrag.ts +5 -0
  36. package/src/components/editor/previewGestureStarts.ts +284 -0
  37. package/src/components/editor/rotateInlineSave.test.ts +1 -0
  38. package/src/components/editor/shiftDragAxisLock.test.tsx +1 -0
  39. package/src/components/editor/useDomEditOverlayGestures.ts +67 -71
  40. package/src/components/nle/NLEContext.test.ts +26 -0
  41. package/src/components/nle/NLEContext.tsx +8 -3
  42. package/src/hooks/draggedGsapPosition.ts +8 -0
  43. package/src/hooks/editMoment.ts +13 -0
  44. package/src/hooks/gsapDragCommit.ts +2 -1
  45. package/src/hooks/gsapDragPositionCommit.ts +19 -7
  46. package/src/hooks/gsapPositionDetection.ts +5 -3
  47. package/src/hooks/gsapResizeIntercept.ts +11 -4
  48. package/src/hooks/gsapRuntimeBridge.ts +31 -11
  49. package/src/hooks/gsapValueAtPlayhead.test.ts +15 -0
  50. package/src/hooks/gsapValueAtPlayhead.ts +12 -5
  51. package/src/hooks/timelineEditingHelpers.test.ts +22 -0
  52. package/src/hooks/timelineEditingHelpers.ts +18 -2
  53. package/src/hooks/useConsoleErrorCapture.test.tsx +87 -0
  54. package/src/hooks/useConsoleErrorCapture.ts +18 -22
  55. package/src/hooks/useDomSelection.test.ts +21 -2
  56. package/src/hooks/useEditHistoryActions.test.tsx +20 -0
  57. package/src/hooks/useEditHistoryActions.ts +2 -0
  58. package/src/hooks/useGsapAwareEditing.test.tsx +38 -0
  59. package/src/hooks/useGsapAwareEditing.ts +148 -136
  60. package/src/hooks/useGsapAwareGroupMove.ts +126 -113
  61. package/src/hooks/useGsapScriptCommits.ts +5 -4
  62. package/src/hooks/usePreviewPersistence.history.test.tsx +7 -2
  63. package/src/hooks/usePreviewPersistence.ts +1 -4
  64. package/src/hooks/useTimelineDeleteOps.test.tsx +10 -0
  65. package/src/hooks/useTimelineDeleteOps.ts +9 -10
  66. package/src/index.ts +1 -0
  67. package/src/player/hooks/useShadowPreviewReload.ts +6 -6
  68. package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +14 -0
  69. package/src/player/hooks/useTimelinePlayer.ts +8 -5
  70. package/src/player/lib/thumbnailScheduler.test.ts +20 -1
  71. package/src/player/lib/thumbnailScheduler.ts +1 -1
  72. package/src/player/lib/timelineElementHelpers.ts +0 -11
  73. package/src/player/previewReloading.test.ts +48 -0
  74. package/src/player/previewReloading.ts +48 -0
  75. package/src/styles/studio.css +9 -0
  76. package/src/utils/gsapUndoRestore.browser.test.ts +21 -12
  77. package/src/utils/rootDuration.ts +5 -4
  78. package/src/utils/studioPendingEdits.test.ts +27 -0
  79. package/src/utils/studioPendingEdits.ts +9 -0
  80. package/src/utils/timelineAssetDrop.test.ts +13 -4
  81. package/src/utils/timelineAssetDrop.ts +6 -2
  82. package/dist/chunk-TDWGXBUT.js.map +0 -1
  83. /package/dist/{chunk-4S3AYCOG.js.map → chunk-E422JDF5.js.map} +0 -0
  84. /package/dist/{chunk-7UQWVN52.js.map → chunk-UYWON5AX.js.map} +0 -0
  85. /package/dist/{domEditingLayers-IEVLO54Z.js.map → domEditingLayers-KBTPQKGA.js.map} +0 -0
@@ -22,7 +22,7 @@ interface TimelineSpies {
22
22
 
23
23
  function renderHarness(
24
24
  initialProps: HarnessProps,
25
- options: { timelineElements?: TimelineElement[] } = {},
25
+ options: { timelineElements?: TimelineElement[]; previewDoc?: Document } = {},
26
26
  ): {
27
27
  current: () => ReturnType<typeof useDomSelection>;
28
28
  rerender: (props: HarnessProps) => void;
@@ -49,7 +49,11 @@ function renderHarness(
49
49
  isMasterView: false,
50
50
  compIdToSrc: new Map(),
51
51
  captionEditMode: false,
52
- previewIframeRef: { current: null },
52
+ previewIframeRef: {
53
+ current: options.previewDoc
54
+ ? ({ contentDocument: options.previewDoc } as HTMLIFrameElement)
55
+ : null,
56
+ },
53
57
  timelineElements: options.timelineElements ?? [],
54
58
  getTimelineSelectionSet: () => publishedSet,
55
59
  setSelectedTimelineElementId: timeline.setSelectedTimelineElementId,
@@ -307,6 +311,21 @@ describe("useDomSelection", () => {
307
311
  harness.cleanup();
308
312
  });
309
313
 
314
+ it("drops the selection when the reloaded preview no longer has its element", async () => {
315
+ const selection = makeSelection("Headline", document.createElement("div"));
316
+ const harness = renderHarness(
317
+ { activeCompPath: "index.html", projectId: "project-1", refreshKey: 0 },
318
+ { previewDoc: document.implementation.createHTMLDocument("") },
319
+ );
320
+ act(() => harness.current().applyDomSelection(selection));
321
+
322
+ await act(() => harness.current().refreshDomEditSelectionFromPreview(selection));
323
+
324
+ expect(harness.current().domEditSelection).toBeNull();
325
+ expect(harness.timeline.setSelectedTimelineElementId).toHaveBeenLastCalledWith(null);
326
+ harness.cleanup();
327
+ });
328
+
310
329
  it("clears a committed selection when the active composition path changes", () => {
311
330
  const { selection, harness } = setupSelectedHarness();
312
331
  expect(harness.current().domEditSelection).toBe(selection);
@@ -9,6 +9,7 @@ import {
9
9
  beginStudioPendingEdit,
10
10
  isStudioEditSaving,
11
11
  setStudioPendingEditClaimClock,
12
+ setStudioWaitingPressCancel,
12
13
  trackStudioPendingEdit,
13
14
  } from "../utils/studioPendingEdits";
14
15
 
@@ -18,6 +19,7 @@ let root: Root | null = null;
18
19
  afterEach(() => {
19
20
  act(() => root?.unmount());
20
21
  setStudioPendingEditClaimClock(null);
22
+ setStudioWaitingPressCancel(null);
21
23
  });
22
24
 
23
25
  type RestoreFiles = Record<string, { previous: string; restored: string }>;
@@ -71,6 +73,24 @@ const PREDICTED = { id: "e1", files: { "index.html": { previous: "B", restored:
71
73
  const SERVER_FILES = { "index.html": { previous: "B", restored: "A2" } };
72
74
 
73
75
  describe("useEditHistoryActions", () => {
76
+ it("takes back a canvas press still waiting to run, with no history step", async () => {
77
+ const { deps, actions } = mount({ ok: true, label: "Undid: Move" });
78
+ const cancel = vi.fn(() => (setStudioWaitingPressCancel(null), true));
79
+ setStudioWaitingPressCancel(cancel);
80
+ const revert = vi.fn(() => () => {});
81
+ const saving = beginStudioPendingEdit(revert);
82
+ await act(() => actions.redo());
83
+ expect(cancel, "redo leaves the press").not.toHaveBeenCalled();
84
+ await act(() => actions.undo());
85
+ expect(cancel).toHaveBeenCalledTimes(1);
86
+ expect(revert, "the press is newer than any edit still saving").not.toHaveBeenCalled();
87
+ expect(deps.editHistory.undo).not.toHaveBeenCalled();
88
+
89
+ await act(() => actions.undo());
90
+ expect(revert, "the following Cmd+Z takes back the edit still saving").toHaveBeenCalledTimes(1);
91
+ saving.settle();
92
+ });
93
+
74
94
  it("asks for the edit claimed after the key's claim count only when an edit was saving at the key", async () => {
75
95
  const { deps, actions } = mount({ ok: false, reason: "empty" });
76
96
  await act(() => actions.undo());
@@ -5,6 +5,7 @@ import { serializeStudioFileMutations } from "../utils/studioFileMutationCoordin
5
5
  import type { RestoreFiles } from "../utils/gsapUndoRestore";
6
6
  import {
7
7
  beginStudioShownRestore,
8
+ cancelNewestStudioWaitingPress,
8
9
  hasStudioPendingEdits,
9
10
  paintBackNewestStudioPendingEdit,
10
11
  } from "../utils/studioPendingEdits";
@@ -79,6 +80,7 @@ export function useEditHistoryActions({
79
80
  const apply = useCallback(
80
81
  async (direction: "undo" | "redo") => {
81
82
  const noun = direction === "undo" ? "Undo" : "Redo";
83
+ if (direction === "undo" && cancelNewestStudioWaitingPress()) return;
82
84
  // An edit still saving is newer than any step this tab predicts, so it paints back first.
83
85
  const pendingEditShown = direction === "undo" ? paintBackNewestStudioPendingEdit() : null;
84
86
  const predicted = pendingEditShown ? null : (editHistory.predict?.(direction) ?? null);
@@ -42,6 +42,7 @@ vi.mock("./useSafeGsapCommitMutation", () => ({
42
42
  }));
43
43
 
44
44
  import { useGsapAwareEditing } from "./useGsapAwareEditing";
45
+ import { isPreviewChanging } from "../player/previewReloading";
45
46
  import { tryGsapRotationIntercept } from "./gsapRuntimeBridge";
46
47
 
47
48
  afterEach(() => {
@@ -235,6 +236,43 @@ describe("useGsapAwareEditing keeps the route a gesture chose at press", () => {
235
236
  });
236
237
  });
237
238
 
239
+ describe("useGsapAwareEditing holds a canvas press while a drop saves", () => {
240
+ it.each([
241
+ ["a move through the script", false, "move"],
242
+ ["a group move through the script", false, "group"],
243
+ ["a move saved as CSS", true, "move"],
244
+ ])("%s", async (_, plainTranslate, kind) => {
245
+ let land: () => void = () => {};
246
+ const landed = new Promise<void>((resolve) => (land = resolve));
247
+ mocks.drag.mockImplementation(async () => {
248
+ await landed;
249
+ return { status: "persisted" };
250
+ });
251
+ const save = vi.fn().mockReturnValue(landed);
252
+ const stageElementPositionOffset = vi.fn(() => ({ save, rollback: vi.fn() }));
253
+ const { pathOffsetCommit, groupCommit, root } = mountGroupHandler({
254
+ stageElementPositionOffset,
255
+ });
256
+ const box = { element: document.createElement("div"), id: "box", selector: "#box" };
257
+ const selection = box as unknown as DomEditSelection;
258
+ const to = { x: 40, y: 20 };
259
+ let saved!: Promise<unknown>;
260
+ act(() => {
261
+ saved =
262
+ kind === "group"
263
+ ? groupCommit([{ selection, next: to, plainTranslate }])
264
+ : pathOffsetCommit(selection, to, { plainTranslate });
265
+ });
266
+ expect(isPreviewChanging(), "from the drop").toBe(!plainTranslate);
267
+ await act(async () => {
268
+ land();
269
+ await saved;
270
+ });
271
+ expect(isPreviewChanging()).toBe(false);
272
+ act(() => root.unmount());
273
+ });
274
+ });
275
+
238
276
  describe("useGsapAwareEditing keeps the box route a resize chose at press", () => {
239
277
  it.each([
240
278
  ["the CSS route, on an element GSAP has since taken over", true],
@@ -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,