@hyperframes/studio 0.8.123 → 0.8.125

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 (48) hide show
  1. package/dist/assets/{hyperframes-player-yABdb6yk.js → hyperframes-player-CmIh-wq9.js} +1 -1
  2. package/dist/assets/{index-D77MIjSJ.js → index-4LRR6o2F.js} +1 -1
  3. package/dist/assets/{index-Dt7lWtY7.js → index-CrYoEZ5p.js} +139 -139
  4. package/dist/assets/{index-LrNlT5Tg.css → index-D11atkBt.css} +1 -1
  5. package/dist/assets/{index-DWjF2Kxw.js → index-yCK8osBg.js} +1 -1
  6. package/dist/index.d.ts +43 -35
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +283 -143
  9. package/dist/index.js.map +1 -1
  10. package/dist/styles.css +1 -1
  11. package/package.json +8 -8
  12. package/src/components/OpenInDesktopButton.dom.test.tsx +27 -10
  13. package/src/components/OpenInDesktopButton.tsx +22 -15
  14. package/src/components/editor/domEditOverlayGestures.ts +2 -0
  15. package/src/components/editor/domEditOverlayStartGesture.ts +6 -0
  16. package/src/components/editor/dragUndoPaint.test.ts +170 -2
  17. package/src/components/editor/gestureUndoRevert.ts +104 -0
  18. package/src/components/editor/manualOffsetDrag.ts +1 -12
  19. package/src/components/editor/useDomEditNudge.ts +1 -1
  20. package/src/components/editor/useDomEditOverlayGestures.ts +9 -5
  21. package/src/hooks/gestureTransaction.test.ts +60 -0
  22. package/src/hooks/gestureTransaction.ts +5 -2
  23. package/src/hooks/gsapGestureOutcome.ts +9 -2
  24. package/src/hooks/gsapResizeDropPoint.test.ts +39 -1
  25. package/src/hooks/gsapResizeElementSize.test.tsx +27 -0
  26. package/src/hooks/gsapResizeIntercept.ts +55 -68
  27. package/src/hooks/gsapScriptCommitTypes.ts +2 -0
  28. package/src/hooks/useGsapAwareEditing.groupPlan.test.tsx +29 -1
  29. package/src/hooks/useGsapAwareEditing.test.tsx +1 -0
  30. package/src/hooks/useGsapAwareEditing.ts +21 -25
  31. package/src/hooks/useGsapAwareGroupMove.ts +4 -6
  32. package/src/hooks/useGsapScriptCommits.test.tsx +42 -1
  33. package/src/hooks/useGsapScriptCommits.ts +12 -3
  34. package/src/player/components/AudibleVideoClipContent.test.tsx +67 -0
  35. package/src/player/components/AudibleVideoClipContent.tsx +5 -4
  36. package/src/player/components/AudioWaveform.tsx +2 -1
  37. package/src/player/components/ClipPeakMarks.tsx +5 -2
  38. package/src/player/components/OutOfSyncBadge.tsx +1 -1
  39. package/src/player/components/Timeline.trackPadding.test.tsx +1 -3
  40. package/src/player/components/TimelineCanvas.tsx +1 -0
  41. package/src/player/components/TimelineClip.filmstrip.test.tsx +27 -0
  42. package/src/player/components/TimelineClipFades.test.tsx +2 -1
  43. package/src/player/components/timelineClipDragPreview.test.ts +31 -0
  44. package/src/player/components/timelineClipDragPreview.ts +42 -37
  45. package/src/player/hooks/useTimelinePlayer.seek.test.ts +11 -0
  46. package/src/styles/components.css +33 -3
  47. package/src/utils/studioPendingEdits.test.ts +52 -0
  48. package/src/utils/studioPendingEdits.ts +55 -10
@@ -281,6 +281,66 @@ describe("runGestureTransaction", () => {
281
281
  log.mockRestore();
282
282
  });
283
283
 
284
+ it("measures a box undo painted back mid-save as the edit drew it, so it reports no change", async () => {
285
+ const element = document.createElement("div");
286
+ let undone = false;
287
+ vi.spyOn(element, "getBoundingClientRect").mockImplementation(() =>
288
+ undone ? rect(0, 0, 50, 40) : rect(10, 20, 100, 80),
289
+ );
290
+ const draw = <T>(run: () => T): T => {
291
+ undone = false;
292
+ try {
293
+ return run();
294
+ } finally {
295
+ undone = true;
296
+ }
297
+ };
298
+
299
+ await runGestureTransaction({
300
+ element,
301
+ label: "Resize layer",
302
+ settle: vi.fn(),
303
+ persist: async () => void (undone = true),
304
+ restore: vi.fn(),
305
+ draw,
306
+ });
307
+
308
+ expect(trackStudioEventMock).not.toHaveBeenCalledWith(
309
+ "commit_invariant_violation",
310
+ expect.anything(),
311
+ );
312
+ });
313
+
314
+ it("measures as the edit drew it when undo painted the box back before the transaction began", async () => {
315
+ const element = document.createElement("div");
316
+ let undone = true;
317
+ vi.spyOn(element, "getBoundingClientRect").mockImplementation(() =>
318
+ undone ? rect(0, 0, 50, 40) : rect(10, 20, 100, 80),
319
+ );
320
+ const draw = <T>(run: () => T): T => {
321
+ undone = false;
322
+ try {
323
+ return run();
324
+ } finally {
325
+ undone = true;
326
+ }
327
+ };
328
+
329
+ await runGestureTransaction({
330
+ element,
331
+ label: "Resize layer",
332
+ settle: vi.fn(),
333
+ persist: async () => undefined,
334
+ restore: vi.fn(),
335
+ draw,
336
+ });
337
+
338
+ expect(trackStudioEventMock).not.toHaveBeenCalledWith(
339
+ "commit_invariant_violation",
340
+ expect.anything(),
341
+ );
342
+ });
343
+
284
344
  it("skips the pixel assertion for live position tweens", async () => {
285
345
  const element = document.createElement("div");
286
346
  const getRect = vi.spyOn(element, "getBoundingClientRect");
@@ -18,6 +18,7 @@ export interface GestureTransaction {
18
18
  afterBufferedCommitsSaved?(): Promise<void>;
19
19
  restore(): void;
20
20
  skipPixelAssert?: boolean;
21
+ draw?<T>(run: () => T): T;
21
22
  }
22
23
 
23
24
  let transactionCounter = 0;
@@ -126,7 +127,9 @@ export function runGestureTransaction(tx: GestureTransaction): Promise<void> {
126
127
  tx.settle();
127
128
  logCommit("settled", { label: tx.label, coalesceKey });
128
129
 
129
- const before = !tx.skipPixelAssert ? readPixelRect(tx.element) : null;
130
+ const measure = () =>
131
+ tx.draw ? tx.draw(() => readPixelRect(tx.element)) : readPixelRect(tx.element);
132
+ const before = !tx.skipPixelAssert ? measure() : null;
130
133
  const commit: TxCommit = (commitMutation) => {
131
134
  const wrapped: CommitMutation = (selection, mutation, options) => {
132
135
  mutationCount += 1;
@@ -150,7 +153,7 @@ export function runGestureTransaction(tx: GestureTransaction): Promise<void> {
150
153
  const durationMs = Math.round(performance.now() - startedAt);
151
154
  logCommit("persisted", { label: tx.label, coalesceKey });
152
155
  if (before) {
153
- const after = readPixelRect(tx.element);
156
+ const after = measure();
154
157
  const delta = pixelDelta(before, after);
155
158
  if (exceedsPixelTolerance(delta)) {
156
159
  logCommit("persist-changed-pixels", { label: tx.label, before, after, delta });
@@ -12,8 +12,11 @@ import {
12
12
  type KeyframeUsageAction,
13
13
  } from "../utils/keyframeUsage";
14
14
  import type { GeometryCommitResult } from "../utils/previewFeatureUsage";
15
+ import { adoptingStudioPendingEdit } from "../utils/studioPendingEdits";
15
16
 
16
17
  export function observeGsapGesture(writer: CommitMutation | null) {
18
+ const edit = adoptingStudioPendingEdit();
19
+ const join = <T>(run: () => T): T => (edit ? edit.within(run) : run());
17
20
  let changed = false;
18
21
  let pendingResults = 0;
19
22
  const actions = new Set<KeyframeUsageAction>();
@@ -21,12 +24,14 @@ export function observeGsapGesture(writer: CommitMutation | null) {
21
24
  pendingResults += 1;
22
25
  return {
23
26
  ...options,
27
+ ...(edit && { pendingEdit: edit }),
24
28
  keyframeTelemetry: false,
25
29
  onResult: (result: MutationResult) => {
26
30
  pendingResults -= 1;
27
31
  options.onResult?.(result);
28
32
  if (!result.ok || result.changed !== true) return;
29
33
  changed = true;
34
+ edit?.markSaved();
30
35
  const members = changedMutationIndices(
31
36
  result,
32
37
  calls.length,
@@ -43,16 +48,18 @@ export function observeGsapGesture(writer: CommitMutation | null) {
43
48
  let commit: CommitMutation | null = null;
44
49
  if (writer) {
45
50
  commit = (selection, mutation, options) =>
46
- writer(selection, mutation, observe([{ selection, mutation, options }], options));
51
+ join(() => writer(selection, mutation, observe([{ selection, mutation, options }], options)));
47
52
  if (writer.batch) {
48
53
  const batch = writer.batch;
49
- commit.batch = (calls, options) => batch(calls, observe(calls, options));
54
+ commit.batch = (calls, options) => join(() => batch(calls, observe(calls, options)));
50
55
  }
51
56
  }
52
57
  return {
53
58
  commit,
59
+ drawKeepingUndone: <T>(draw: () => T): T => (edit ? edit.drawKeepingUndone(draw) : draw()),
54
60
  recordDomResult: (result: { changed: boolean } | undefined) => {
55
61
  changed ||= result?.changed === true;
62
+ if (result?.changed) edit?.markSaved();
56
63
  },
57
64
  finish: (domChanged = false): GeometryCommitResult => {
58
65
  if (pendingResults !== 0) return { ok: true, changed: false };
@@ -75,6 +75,8 @@ interface ResizeCase {
75
75
  longhandTween?: boolean;
76
76
  /** What already writes this element's position. */
77
77
  positionWrite?: "static-set" | "keyframed-tween" | "none";
78
+ /** Undo paints the box back to its press look while the animations load. */
79
+ paintedBackWhileLoading?: boolean;
78
80
  }
79
81
 
80
82
  function createResizeElement(testCase: ResizeCase): HTMLElement {
@@ -241,6 +243,30 @@ async function runCase(testCase: ResizeCase) {
241
243
  } as unknown as HTMLIFrameElement;
242
244
 
243
245
  const dropPoint = el.getBoundingClientRect();
246
+ // A painted-back edit: undone on screen, shown again only inside a draw (as `drawKeepingUndone` does).
247
+ const look = () => ({
248
+ w: el.style.width,
249
+ h: el.style.height,
250
+ pos: { ...live.pos },
251
+ scale: { ...live.scale },
252
+ });
253
+ const show = (shown: ReturnType<typeof look>) => {
254
+ el.style.width = shown.w;
255
+ el.style.height = shown.h;
256
+ Object.assign(live, { pos: { ...shown.pos }, scale: { ...shown.scale } });
257
+ };
258
+ let shownEdit: ReturnType<typeof look> | null = null;
259
+ const paintBack = () => {
260
+ shownEdit = look();
261
+ show({ w: "", h: "", pos: { ...testCase.base }, scale: { ...testCase.liveScale } });
262
+ };
263
+ const draw = <T>(run: () => T): T => {
264
+ if (!shownEdit) return run();
265
+ show(shownEdit);
266
+ const drawn = run();
267
+ paintBack();
268
+ return drawn;
269
+ };
244
270
  const position = positionAnimation(testCase.positionWrite ?? "static-set", testCase.base);
245
271
  const animations = [scaleTween(!!testCase.longhandTween), ...(position ? [position] : [])];
246
272
  const commitMutation = vi.fn();
@@ -252,7 +278,12 @@ async function runCase(testCase: ResizeCase) {
252
278
  animations,
253
279
  iframe,
254
280
  commitMutation,
255
- async () => animations,
281
+ async () => {
282
+ if (testCase.paintedBackWhileLoading) paintBack();
283
+ return animations;
284
+ },
285
+ undefined,
286
+ draw,
256
287
  );
257
288
 
258
289
  // Re-render what the file now says: the committed scale, the persisted
@@ -393,3 +424,10 @@ it("holds the drop point across a second drag", async () => {
393
424
  expect(second.settled.y).toBeCloseTo(second.dropPoint.y, 0);
394
425
  expect(second.settled.w).toBeCloseTo(second.dropPoint.w, 0);
395
426
  });
427
+
428
+ it("lands on the drop point when undo paints the box back while its animations load", async () => {
429
+ const { dropPoint, settled } = await runCase({ ...CASES[0]!, paintedBackWhileLoading: true });
430
+ expect(settled.x).toBeCloseTo(dropPoint.x, 0);
431
+ expect(settled.y).toBeCloseTo(dropPoint.y, 0);
432
+ expect(settled.w).toBeCloseTo(dropPoint.w, 0);
433
+ });
@@ -5,6 +5,10 @@ import { afterEach, describe, expect, it, vi } from "vitest";
5
5
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
6
6
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
7
7
  import { applyStudioBoxSizeDraft } from "../components/editor/manualEdits";
8
+ import {
9
+ beginStudioPendingEdit,
10
+ paintBackNewestStudioPendingEdit,
11
+ } from "../utils/studioPendingEdits";
8
12
  import { mountReactHarness } from "./domSelectionTestHarness";
9
13
  import { useDomGeometryCommits } from "./useDomGeometryCommits";
10
14
  import { useGsapAwareEditing } from "./useGsapAwareEditing";
@@ -134,4 +138,27 @@ describe("resizing an element GSAP positions", () => {
134
138
  expect(cssSize).toEqual([]);
135
139
  act(() => h.root.unmount());
136
140
  });
141
+
142
+ it("keeps a resize undo painted back drawn undone while its save lands, and draws it again on show again", async () => {
143
+ const h = mount([positionHold]);
144
+ const element = h.selection.element;
145
+ const edit = beginStudioPendingEdit(() => {
146
+ const shown = element.getAttribute("style");
147
+ element.setAttribute("style", "scale: none");
148
+ return () => element.setAttribute("style", shown ?? "");
149
+ });
150
+ const saved = edit.adopt(() => h.resize(h.selection, h.size, { x: -50, y: -33.5 }));
151
+ edit.settle(saved);
152
+ const shown = paintBackNewestStudioPendingEdit();
153
+
154
+ await act(() => saved);
155
+ expect(h.commitPatch.mock.calls.at(-1)![1]).toEqual(
156
+ expect.arrayContaining([{ type: "inline-style", property: "width", value: "340px" }]),
157
+ );
158
+ expect(element.getAttribute("style")).toBe("scale: none");
159
+
160
+ shown!.showAgain();
161
+ expect(element.getAttribute("style")).toContain("--hf-studio-width: 340px");
162
+ act(() => h.root.unmount());
163
+ });
137
164
  });
@@ -135,6 +135,7 @@ export async function tryGsapResizeIntercept(
135
135
  commitMutation: GsapDragCommitCallbacks["commitMutation"],
136
136
  fetchFallbackAnimations?: () => Promise<GsapAnimation[]>,
137
137
  dragOffset?: { x: number; y: number },
138
+ draw: <T>(run: () => T) => T = (run) => run(),
138
139
  ): Promise<GsapEditOutcome> {
139
140
  const fetchedAnimations = fetchFallbackAnimations ? await fetchFallbackAnimations() : [];
140
141
  const outcome = preflightGsapResizeIntercept(selection, animations, iframe, fetchedAnimations);
@@ -315,7 +316,7 @@ export async function tryGsapResizeIntercept(
315
316
  // difference and compensates, so release matches the drop pixel-for-pixel
316
317
  // regardless of live scale or repeat resizes.
317
318
  if (el) {
318
- const dropRect = el.getBoundingClientRect();
319
+ const dropRect = draw(() => el.getBoundingClientRect());
319
320
  scaleDraftDropPoint = { x: dropRect.x, y: dropRect.y };
320
321
  }
321
322
  } else {
@@ -342,75 +343,61 @@ export async function tryGsapResizeIntercept(
342
343
  // resize took the size route and never moved anything: the drop point is
343
344
  // the drag's to settle, not ours.
344
345
  if (!scaleDraftEl) return false;
345
- clearStudioBoxSize(scaleDraftEl);
346
- if (!scaleDraftDropPoint || !selector) return false;
347
- // Put the committed scale on the live element before measuring.
348
- //
349
- // This step reads where the commit lands the box and shifts the position
350
- // hold by the difference. That only works if the commit has actually
351
- // rendered, and whether it had was luck: on the FIRST resize of an element
352
- // the timeline had not re-seeked yet, so this measured the element at its
353
- // natural size, still sitting on the drop point, computed a residual of
354
- // zero, and skipped the correction entirely. The scale then landed, GSAP
355
- // rendered it around the element's centre, and the element jumped by the
356
- // whole drag distance. Elements that had been resized before got a
357
- // correction only because their PREVIOUS scale made the residual non-zero.
358
- //
359
- // Setting it here costs nothing when the commit has already rendered (same
360
- // value) and makes the measurement below mean what it says either way.
361
- if (committedScale) {
362
- setElementGsapScale(scaleDraftEl, committedScale.x, committedScale.y);
363
- }
364
- // Measure from the pre-gesture position, not the draft one.
365
- //
366
- // The resize draft translates the element to keep the dragged corner under
367
- // the cursor, but the scale route never persists that translation — the
368
- // element renders back at its pre-gesture position as soon as the commit
369
- // lands. Measuring while the draft translation was still applied made the
370
- // residual carry the whole drag distance, and the position commit then
371
- // composed that residual onto the pre-gesture base (it reads the gesture's
372
- // own base attributes, not the live value), so the element landed a full
373
- // drag away from the drop point on every scale resize.
374
- const gsapPos = readGsapPositionFromIframe(iframe, selector) ?? { x: 0, y: 0 };
375
- const { baseGsapX, baseGsapY } = computeDraggedGsapPosition(
376
- selection.element,
377
- { x: 0, y: 0 },
378
- gsapPos,
379
- );
380
- const base = { x: baseGsapX, y: baseGsapY };
381
- setElementGsapPosition(scaleDraftEl, base.x, base.y);
382
- const post = scaleDraftEl.getBoundingClientRect();
383
- const residual = { x: scaleDraftDropPoint.x - post.x, y: scaleDraftDropPoint.y - post.y };
384
- if (!Number.isFinite(residual.x) || !Number.isFinite(residual.y)) return false;
385
- if (Math.abs(residual.x) < 0.5 && Math.abs(residual.y) < 0.5) {
386
- logResize("scale-finalize", { skipped: "already-on-drop-point", residual, base });
387
- // Settled, with nothing to write. Still ours: forwarding the drag offset
388
- // on top would move the box off the point it is already sitting on.
389
- return true;
346
+ const draftEl = scaleDraftEl;
347
+ if (!scaleDraftDropPoint || !selector) {
348
+ draw(() => clearStudioBoxSize(draftEl));
349
+ return false;
390
350
  }
391
- // The ONE corrected position — rounded once so the live runtime and the
392
- // persisted file agree exactly (commitStaticGsapPosition composes the same
393
- // rounded value from this delta).
394
- const corrected = {
395
- x: roundTo3(base.x + residual.x),
396
- y: roundTo3(base.y + residual.y),
397
- };
398
- logResize("scale-finalize", {
399
- dropPoint: scaleDraftDropPoint,
400
- post: { x: post.x, y: post.y },
401
- residual,
402
- gsapPos,
403
- base,
404
- corrected,
351
+ const dropPoint = scaleDraftDropPoint;
352
+ const measured = draw(() => {
353
+ clearStudioBoxSize(draftEl);
354
+ // Draw the committed scale before measuring: on a first resize the timeline has not re-seeked yet,
355
+ // so the box would read at its natural size and the correction would be skipped.
356
+ if (committedScale) {
357
+ setElementGsapScale(draftEl, committedScale.x, committedScale.y);
358
+ }
359
+ // Measure from the pre-gesture position: the scale route never saves the draft's translation, and
360
+ // the position write composes the residual onto that same base.
361
+ const gsapPos = readGsapPositionFromIframe(iframe, selector) ?? { x: 0, y: 0 };
362
+ const { baseGsapX, baseGsapY } = computeDraggedGsapPosition(
363
+ selection.element,
364
+ { x: 0, y: 0 },
365
+ gsapPos,
366
+ );
367
+ const base = { x: baseGsapX, y: baseGsapY };
368
+ setElementGsapPosition(draftEl, base.x, base.y);
369
+ const post = draftEl.getBoundingClientRect();
370
+ const residual = { x: dropPoint.x - post.x, y: dropPoint.y - post.y };
371
+ if (!Number.isFinite(residual.x) || !Number.isFinite(residual.y)) return null;
372
+ if (Math.abs(residual.x) < 0.5 && Math.abs(residual.y) < 0.5) {
373
+ logResize("scale-finalize", { skipped: "already-on-drop-point", residual, base });
374
+ // Settled, with nothing to write. Still ours: forwarding the drag offset
375
+ // on top would move the box off the point it is already sitting on.
376
+ return "settled" as const;
377
+ }
378
+ // The ONE corrected position — rounded once so the live runtime and the
379
+ // persisted file agree exactly (commitStaticGsapPosition composes the same
380
+ // rounded value from this delta).
381
+ const corrected = {
382
+ x: roundTo3(base.x + residual.x),
383
+ y: roundTo3(base.y + residual.y),
384
+ };
385
+ logResize("scale-finalize", {
386
+ dropPoint,
387
+ post: { x: post.x, y: post.y },
388
+ residual,
389
+ gsapPos,
390
+ base,
391
+ corrected,
392
+ });
393
+ // Correct the live box in the same task as the measurement, so no frame shows it off the drop
394
+ // point while the position write is in flight.
395
+ setElementGsapPosition(draftEl, corrected.x, corrected.y);
396
+ return { base, corrected };
405
397
  });
406
- // Correct the LIVE runtime NOW, synchronously: the soft reload above just
407
- // rendered the committed scale around the element center — NOT at the drop
408
- // point — and everything up to here runs in the same microtask chain as
409
- // that reload, so no frame has painted the uncorrected position yet. The
410
- // server persist below costs network round-trips; without this set, the
411
- // element visibly sits at the wrong spot for those frames (the drop
412
- // "jump"). The persisted commit re-applies the same values (idempotent).
413
- setElementGsapPosition(scaleDraftEl, corrected.x, corrected.y);
398
+ if (measured === null) return false;
399
+ if (measured === "settled") return true;
400
+ const { base, corrected } = measured;
414
401
  // Re-fetch: the scale commit above just rewrote the script, so the caller's
415
402
  // animation list (and its ids) may be stale for the position lookup.
416
403
  const currentAnimations = fetchFallbackAnimations
@@ -2,6 +2,7 @@ import type { ParsedGsap } from "@hyperframes/core/gsap-parser";
2
2
  import type { Composition } from "@hyperframes/sdk";
3
3
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
4
4
  import type { PublishSdkSession } from "../utils/sdkCutover";
5
+ import type { StudioEditInFlight } from "../utils/studioPendingEdits";
5
6
  import type { RuntimeTweenChange } from "./gsapRuntimePatch";
6
7
  import type { KeyframeUsageAction } from "../utils/keyframeUsage";
7
8
 
@@ -47,6 +48,7 @@ export interface CommitMutationOptions {
47
48
  /** Carries a deferred write not yet on screen (no patch, or a missed one) to the final render. */
48
49
  previewFallbackLatch?: { pending: boolean };
49
50
  beforeReload?: () => void;
51
+ pendingEdit?: StudioEditInFlight;
50
52
  /**
51
53
  * Serialize this commit against others sharing the same key. Used to chain
52
54
  * per-animationId GSAP meta updates. Every commit independently takes the
@@ -6,6 +6,10 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
6
6
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
7
7
  import type { DomEditGroupPathOffsetCommit } from "../components/editor/domEditOverlayGestures";
8
8
  import { usePlayerStore } from "../player/store/playerStore";
9
+ import {
10
+ beginStudioPendingEdit,
11
+ paintBackNewestStudioPendingEdit,
12
+ } from "../utils/studioPendingEdits";
9
13
  import { trackStudioEditBlocked } from "../utils/studioSaveDiagnostics";
10
14
  import { GSAP_EDIT_BLOCK_COPY } from "./gsapEditOutcome";
11
15
  import { mountGsapAwareEditing } from "./useGsapAwareEditing.testHelpers";
@@ -65,7 +69,10 @@ function mountGroup(animations: GsapAnimation[]) {
65
69
  batch: vi.fn().mockResolvedValue(undefined),
66
70
  });
67
71
  const showToast = vi.fn();
68
- const stageElementPositionOffset = vi.fn(() => ({ save: vi.fn(), rollback: vi.fn() }));
72
+ const stageElementPositionOffset = vi.fn((selection: DomEditSelection) => {
73
+ selection.element.style.setProperty("translate", "30px 0px");
74
+ return { save: vi.fn().mockResolvedValue(undefined), rollback: vi.fn() };
75
+ });
69
76
  const { editing, root } = mountGsapAwareEditing({
70
77
  gsapCommitMutation: commitMutation,
71
78
  activeCompPath: "index.html",
@@ -158,3 +165,24 @@ describe("a group drag plans every member before its first write", () => {
158
165
  act(() => h.root.unmount());
159
166
  });
160
167
  });
168
+
169
+ it("keeps a member it moves on its own CSS undone when undo painted the group back", async () => {
170
+ const h = mountGroup(["b", "c"].map((id) => positionTween(id)));
171
+ const plain = h.elements[0]!;
172
+ const edit = beginStudioPendingEdit(() => {
173
+ const shown = plain.getAttribute("style") ?? "";
174
+ plain.setAttribute("style", "");
175
+ return () => plain.setAttribute("style", shown);
176
+ });
177
+ const saved = edit.adopt(() =>
178
+ h.groupCommit(h.updates.map((u, i) => (i === 0 ? { ...u, plainTranslate: true } : u))),
179
+ );
180
+ edit.settle(saved);
181
+ const shown = paintBackNewestStudioPendingEdit();
182
+
183
+ await act(() => saved);
184
+ expect(plain.getAttribute("style")).toBe("");
185
+ shown!.showAgain();
186
+ expect(plain.getAttribute("style")).toContain("translate: 30px 0px");
187
+ act(() => h.root.unmount());
188
+ });
@@ -423,6 +423,7 @@ describe("useGsapAwareEditing anchored resize", () => {
423
423
  expect.any(Function),
424
424
  expect.any(Function),
425
425
  undefined,
426
+ expect.any(Function),
426
427
  );
427
428
  act(() => h.root.unmount());
428
429
  });
@@ -169,8 +169,10 @@ export function useGsapAwareEditing({
169
169
  modifiers,
170
170
  );
171
171
  await saveMove(outcome, async () => {
172
- const result = await stageElementPositionOffset(selection, next, false).save();
173
- writes.recordDomResult(result);
172
+ const staged = writes.drawKeepingUndone(() =>
173
+ stageElementPositionOffset(selection, next, false),
174
+ );
175
+ writes.recordDomResult(await staged.save());
174
176
  });
175
177
  return writes.finish();
176
178
  } catch (error) {
@@ -258,11 +260,8 @@ export function useGsapAwareEditing({
258
260
  // Saved after the size, under its undo key, so the two are one step.
259
261
  await saveMove(dragOutcome, async () => {
260
262
  const plainAfterSettle = editsPlainCss(selection.element, "move");
261
- anchorMove = stageElementPositionOffset(
262
- selection,
263
- offset,
264
- plainAfterSettle,
265
- coalesceKey,
263
+ anchorMove = writes.drawKeepingUndone(() =>
264
+ stageElementPositionOffset(selection, offset, plainAfterSettle, coalesceKey),
266
265
  );
267
266
  });
268
267
  }
@@ -270,17 +269,20 @@ export function useGsapAwareEditing({
270
269
  await runGestureTransaction({
271
270
  element: selection.element,
272
271
  label: "Resize layer",
272
+ draw: writes.drawKeepingUndone,
273
273
  settle: () => {
274
274
  // Scale resize settles its center-scale residual after the scale commit
275
275
  // renders. Width/height can settle its anchored position immediately.
276
276
  if (!offset || scaleRoute || !selector) return;
277
- const gsapPos = readGsapPositionFromIframe(previewIframeRef.current, selector) ?? {
278
- x: 0,
279
- y: 0,
280
- };
281
- const { newX, newY } = computeDraggedGsapPosition(selection.element, offset, gsapPos);
282
- logResize("sync-settle", { gsapPos, offset, newX, newY });
283
- setElementGsapPosition(selection.element, newX, newY);
277
+ writes.drawKeepingUndone(() => {
278
+ const gsapPos = readGsapPositionFromIframe(previewIframeRef.current, selector) ?? {
279
+ x: 0,
280
+ y: 0,
281
+ };
282
+ const { newX, newY } = computeDraggedGsapPosition(selection.element, offset, gsapPos);
283
+ logResize("sync-settle", { gsapPos, offset, newX, newY });
284
+ setElementGsapPosition(selection.element, newX, newY);
285
+ });
284
286
  },
285
287
  persist: async (commit, coalesceKey) => {
286
288
  if (writes.commit) {
@@ -294,16 +296,13 @@ export function useGsapAwareEditing({
294
296
  commitMutation,
295
297
  makeFetchFallback(selection),
296
298
  offset,
299
+ writes.drawKeepingUndone,
297
300
  );
298
301
  assertGsapEditPersisted(outcome);
299
302
  // Saved before the buffered GSAP writes, so their reload stays the gesture's last render.
300
303
  if (outcome.status === "element-size") {
301
- const result = await handleDomBoxSizeCommit(
302
- selection,
303
- next,
304
- undefined,
305
- undefined,
306
- coalesceKey,
304
+ const result = await writes.drawKeepingUndone(() =>
305
+ handleDomBoxSizeCommit(selection, next, undefined, undefined, coalesceKey),
307
306
  );
308
307
  writes.recordDomResult(result);
309
308
  } else cropUndoKey = coalesceKey;
@@ -332,11 +331,8 @@ export function useGsapAwareEditing({
332
331
  writes.recordDomResult(anchorResult);
333
332
  // Only now is the size live for every caller, drag or not.
334
333
  if (cropUndoKey) {
335
- const cropResult = await saveCropResize(
336
- stageCrop,
337
- selection,
338
- commitPositionPatchToHtml,
339
- cropUndoKey,
334
+ const cropResult = await writes.drawKeepingUndone(() =>
335
+ saveCropResize(stageCrop, selection, commitPositionPatchToHtml, cropUndoKey!),
340
336
  );
341
337
  writes.recordDomResult(cropResult);
342
338
  }
@@ -123,12 +123,10 @@ export function useGsapAwareGroupMove({
123
123
  renderOnCommit = index === lastScriptWrite;
124
124
  const plain = offsetMembers.get(selection);
125
125
  if (plain !== undefined) {
126
- const result = await stageElementPositionOffset(
127
- selection,
128
- next,
129
- plain,
130
- coalesceKey,
131
- ).save();
126
+ const staged = writes.drawKeepingUndone(() =>
127
+ stageElementPositionOffset(selection, next, plain, coalesceKey),
128
+ );
129
+ const result = await staged.save();
132
130
  writes.recordDomResult(result);
133
131
  continue;
134
132
  }
@@ -38,7 +38,12 @@ import type {
38
38
  import { persistSdkSerialize } from "../utils/sdkCutover";
39
39
  import { jsonResponse } from "./fetchStubTestUtils";
40
40
  import { applyPreviewSync, useGsapScriptCommits } from "./useGsapScriptCommits";
41
- import { hasStudioPendingEdits } from "../utils/studioPendingEdits";
41
+ import {
42
+ beginStudioPendingEdit,
43
+ hasStudioPendingEdits,
44
+ paintBackNewestStudioPendingEdit,
45
+ } from "../utils/studioPendingEdits";
46
+ import { observeGsapGesture } from "./gsapGestureOutcome";
42
47
 
43
48
  // ── applyPreviewSync (pure preview-sync decision) ────────────────────────────
44
49
 
@@ -490,6 +495,42 @@ describe("a GSAP script commit", () => {
490
495
  await act(async () => void (await committed));
491
496
  expect(hasStudioPendingEdits()).toBe(false);
492
497
  });
498
+
499
+ it("a gesture's write after an await is still its edit, and once undo paints that edit back it saves without drawing it", async () => {
500
+ applySoftReload.mockReturnValue("applied");
501
+ let respond!: () => void;
502
+ const body: MutationResult = { ok: true, changed: true, after: "AFTER", scriptText: "SCRIPT" };
503
+ vi.stubGlobal(
504
+ "fetch",
505
+ vi.fn(
506
+ () =>
507
+ new Promise((resolve) => {
508
+ respond = () => resolve({ ok: true, json: async () => body } as unknown as Response);
509
+ }),
510
+ ),
511
+ );
512
+ const deps = renderCommitHook();
513
+ const edit = beginStudioPendingEdit(() => () => undefined);
514
+ const saved = edit.adopt(async () => {
515
+ const writes = observeGsapGesture(deps.api.commitMutation);
516
+ await Promise.resolve();
517
+ return writes.commit!(selection, { x: 10 }, { label: "Move layer", softReload: true });
518
+ });
519
+ edit.settle(saved);
520
+ await vi.waitFor(() => expect(fetch).toHaveBeenCalled());
521
+
522
+ const shown = paintBackNewestStudioPendingEdit();
523
+ expect(shown).not.toBeNull();
524
+ await act(async () => {
525
+ respond();
526
+ await saved;
527
+ });
528
+ expect(deps.onFileContentChanged).toHaveBeenCalledWith("index.html", "AFTER");
529
+ expect(applySoftReload).not.toHaveBeenCalled();
530
+
531
+ shown!.showAgain();
532
+ expect(applySoftReload).toHaveBeenCalledTimes(1);
533
+ });
493
534
  });
494
535
 
495
536
  describe("runCommit — instantPatch wiring", () => {
@@ -113,11 +113,18 @@ function finishUnchangedMutation(
113
113
  !options.skipReload &&
114
114
  (instantPatchesFor(options).length > 0 || options.previewFallbackLatch?.pending)
115
115
  ) {
116
- applyPreviewSync(iframe, result, options, reloadPreview);
116
+ redrawUnlessPaintedBack(options, () =>
117
+ applyPreviewSync(iframe, result, options, reloadPreview),
118
+ );
117
119
  }
118
120
  return true;
119
121
  }
120
122
 
123
+ function redrawUnlessPaintedBack(options: CommitMutationOptions, redraw: () => void): void {
124
+ if (options.pendingEdit) options.pendingEdit.drawUnlessUndone(redraw);
125
+ else redraw();
126
+ }
127
+
121
128
  function refreshMutationPreview(
122
129
  iframe: HTMLIFrameElement | null,
123
130
  result: MutationResult,
@@ -126,8 +133,10 @@ function refreshMutationPreview(
126
133
  onCacheInvalidate: () => void,
127
134
  nestedFiles?: Map<string, string> | null,
128
135
  ): void {
129
- options.beforeReload?.();
130
- applyPreviewSync(iframe, result, options, reloadPreview, nestedFiles);
136
+ redrawUnlessPaintedBack(options, () => {
137
+ options.beforeReload?.();
138
+ applyPreviewSync(iframe, result, options, reloadPreview, nestedFiles);
139
+ });
131
140
  onCacheInvalidate();
132
141
  }
133
142