@hyperframes/studio 0.8.124 → 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 (31) hide show
  1. package/dist/assets/{hyperframes-player-ColOZ_78.js → hyperframes-player-CmIh-wq9.js} +1 -1
  2. package/dist/assets/{index-jQEqFKMk.js → index-4LRR6o2F.js} +1 -1
  3. package/dist/assets/{index-BuSTrK-Z.js → index-CrYoEZ5p.js} +139 -139
  4. package/dist/assets/{index-CVuUfURE.js → index-yCK8osBg.js} +1 -1
  5. package/dist/index.d.ts +43 -35
  6. package/dist/index.html +1 -1
  7. package/dist/index.js +213 -94
  8. package/dist/index.js.map +1 -1
  9. package/package.json +8 -8
  10. package/src/components/editor/domEditOverlayGestures.ts +2 -0
  11. package/src/components/editor/domEditOverlayStartGesture.ts +6 -0
  12. package/src/components/editor/dragUndoPaint.test.ts +170 -2
  13. package/src/components/editor/gestureUndoRevert.ts +104 -0
  14. package/src/components/editor/manualOffsetDrag.ts +1 -12
  15. package/src/components/editor/useDomEditNudge.ts +1 -1
  16. package/src/components/editor/useDomEditOverlayGestures.ts +9 -5
  17. package/src/hooks/gestureTransaction.test.ts +60 -0
  18. package/src/hooks/gestureTransaction.ts +5 -2
  19. package/src/hooks/gsapGestureOutcome.ts +9 -2
  20. package/src/hooks/gsapResizeDropPoint.test.ts +39 -1
  21. package/src/hooks/gsapResizeElementSize.test.tsx +27 -0
  22. package/src/hooks/gsapResizeIntercept.ts +55 -68
  23. package/src/hooks/gsapScriptCommitTypes.ts +2 -0
  24. package/src/hooks/useGsapAwareEditing.groupPlan.test.tsx +29 -1
  25. package/src/hooks/useGsapAwareEditing.test.tsx +1 -0
  26. package/src/hooks/useGsapAwareEditing.ts +21 -25
  27. package/src/hooks/useGsapAwareGroupMove.ts +4 -6
  28. package/src/hooks/useGsapScriptCommits.test.tsx +42 -1
  29. package/src/hooks/useGsapScriptCommits.ts +12 -3
  30. package/src/utils/studioPendingEdits.test.ts +52 -0
  31. package/src/utils/studioPendingEdits.ts +55 -10
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.124",
3
+ "version": "0.8.125",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -60,12 +60,12 @@
60
60
  "dockview-react": "8.3.1",
61
61
  "gsap": "^3.13.0",
62
62
  "mediabunny": "^1.45.3",
63
- "@hyperframes/core": "0.8.124",
64
- "@hyperframes/player": "0.8.124",
65
- "@hyperframes/sdk": "0.8.124",
66
- "@hyperframes/parsers": "0.8.124",
67
- "@hyperframes/shader-transitions": "0.8.124",
68
- "@hyperframes/studio-server": "0.8.124"
63
+ "@hyperframes/parsers": "0.8.125",
64
+ "@hyperframes/player": "0.8.125",
65
+ "@hyperframes/core": "0.8.125",
66
+ "@hyperframes/sdk": "0.8.125",
67
+ "@hyperframes/shader-transitions": "0.8.125",
68
+ "@hyperframes/studio-server": "0.8.125"
69
69
  },
70
70
  "devDependencies": {
71
71
  "@tailwindcss/vite": "^4.3.3",
@@ -81,7 +81,7 @@
81
81
  "vite": "^6.4.2",
82
82
  "vitest": "^4.1.11",
83
83
  "zustand": "^5.0.0",
84
- "@hyperframes/producer": "0.8.124"
84
+ "@hyperframes/producer": "0.8.125"
85
85
  },
86
86
  "peerDependencies": {
87
87
  "react": "19",
@@ -6,6 +6,7 @@ import type {
6
6
  StudioRotationSnapshot,
7
7
  } from "./manualEdits";
8
8
  import type { ManualOffsetDragMember } from "./manualOffsetDrag";
9
+ import type { StudioElementLook } from "./gestureUndoRevert";
9
10
  import type { CssRotationTarget, RotationCommit } from "./rotationDraft";
10
11
  import type { GroupOverlayItem, OverlayRect } from "./domEditOverlayGeometry";
11
12
  import type { SnapContext } from "./snapTargetCollection";
@@ -57,6 +58,7 @@ export interface GestureState {
57
58
  initialPathOffset: StudioPathOffsetSnapshot;
58
59
  initialRotation: StudioRotationSnapshot;
59
60
  initialBoxSize: StudioBoxSizeSnapshot;
61
+ initialLook: StudioElementLook;
60
62
  pathOffsetMember?: ManualOffsetDragMember;
61
63
  originLeft: number;
62
64
  originTop: number;
@@ -9,6 +9,7 @@ import {
9
9
  restoreManualOffsetDragMembers,
10
10
  type ManualOffsetDragMember,
11
11
  } from "./manualOffsetDrag";
12
+ import { readElementLook } from "./gestureUndoRevert";
12
13
  import { readCssRotationTarget, readRotationBase } from "./rotationDraft";
13
14
  import {
14
15
  beginStudioManualEditGesture,
@@ -179,6 +180,10 @@ export function startGesture(
179
180
  Number.isFinite(rawContentScaleX) && rawContentScaleX > 0 ? rawContentScaleX : 1;
180
181
  const contentScaleY =
181
182
  Number.isFinite(rawContentScaleY) && rawContentScaleY > 0 ? rawContentScaleY : 1;
183
+ const initialLook = readElementLook(
184
+ sel.element,
185
+ kind !== "drag" && !editsPlainCss(sel.element, kind === "rotate" ? "rotate" : "resize"),
186
+ );
182
187
  let initialPathOffset = captureStudioPathOffset(sel.element);
183
188
  let manualEditDragToken: string | undefined;
184
189
  let pathOffsetMember: ManualOffsetDragMember | undefined;
@@ -274,6 +279,7 @@ export function startGesture(
274
279
  initialPathOffset,
275
280
  initialRotation: captureStudioRotation(sel.element),
276
281
  initialBoxSize: captureStudioBoxSize(sel.element),
282
+ initialLook,
277
283
  pathOffsetMember,
278
284
  originLeft: rect.left,
279
285
  originTop: rect.top,
@@ -1,15 +1,40 @@
1
1
  // @vitest-environment happy-dom
2
2
  import { afterEach, expect, it, vi } from "vitest";
3
+ import { savePlainRotation } from "../../hooks/plainRotation";
3
4
  import type { DomEditSelection } from "./domEditing";
4
5
  import type { GestureState } from "./domEditOverlayGestures";
5
6
  import { createDomEditOverlayGestureHandlers } from "./useDomEditOverlayGestures";
6
7
  import {
8
+ adoptingStudioPendingEdit,
7
9
  hasStudioPendingEdits,
8
10
  paintBackNewestStudioPendingEdit,
11
+ type StudioEditInFlight,
9
12
  } from "../../utils/studioPendingEdits";
10
13
 
14
+ const gsapOwns = vi.hoisted(() => ({ on: false }));
15
+ vi.mock("../../hooks/gsapRuntimeKeyframes", async (importOriginal) => ({
16
+ ...(await importOriginal<typeof import("../../hooks/gsapRuntimeKeyframes")>()),
17
+ editsPlainCss: () => !gsapOwns.on,
18
+ }));
19
+
20
+ /** GSAP's own transform values per element, as `gsap.set` / `gsap.getProperty` see them. */
21
+ function fakeGsap(start: Record<string, number>) {
22
+ const values = new Map<Element, Record<string, number>>();
23
+ const of = (el: Element) => values.get(el) ?? values.set(el, { ...start }).get(el)!;
24
+ gsapOwns.on = true;
25
+ Object.assign(window, {
26
+ gsap: {
27
+ set: (el: Element, props: Record<string, number>) => Object.assign(of(el), props),
28
+ getProperty: (el: Element, prop: string) => of(el)[prop] ?? 0,
29
+ },
30
+ });
31
+ return of;
32
+ }
33
+
11
34
  afterEach(() => {
12
35
  document.body.innerHTML = "";
36
+ gsapOwns.on = false;
37
+ Reflect.deleteProperty(window, "gsap");
13
38
  });
14
39
 
15
40
  const ref = <T>(current: T) => ({ current });
@@ -24,7 +49,7 @@ const pointer = (x: number, y: number) => ({
24
49
  });
25
50
 
26
51
  /** Drags a box without GSAP 100 px right and 60 px down; its save waits for `save`. */
27
- function dragWithSaveRunning(save: Promise<void>) {
52
+ function dragWithSaveRunning(save: Promise<void> | (() => Promise<void>)) {
28
53
  const element = document.createElement("div");
29
54
  element.style.setProperty("translate", "40px 30px");
30
55
  document.body.append(element);
@@ -39,7 +64,7 @@ function dragWithSaveRunning(save: Promise<void>) {
39
64
  rafPausedRef: ref(false),
40
65
  onManualDragStartRef: ref(vi.fn()),
41
66
  onBlockedMoveRef: ref(vi.fn()),
42
- onPathOffsetCommitRef: ref(vi.fn(() => save)),
67
+ onPathOffsetCommitRef: ref(vi.fn(typeof save === "function" ? save : () => save)),
43
68
  snapGuidesRef: ref(null),
44
69
  groupGestureRef: ref(null),
45
70
  blockedMoveRef: ref(null),
@@ -69,3 +94,146 @@ it("a drag whose save is still running can be painted back at once, and shown ag
69
94
  await vi.waitFor(() => expect(hasStudioPendingEdits()).toBe(false));
70
95
  expect(paintBackNewestStudioPendingEdit()).toBeNull();
71
96
  });
97
+
98
+ /** Resizes or rotates a plain 240x160 box; the commit draws what it saves at once and its save waits for `save`. */
99
+ function gestureWithSaveRunning(kind: "resize" | "rotate", save: Promise<void>) {
100
+ const element = document.createElement("div");
101
+ element.setAttribute(
102
+ "style",
103
+ "position: absolute; width: 240px; height: 160px; clip-path: inset(10px)",
104
+ );
105
+ document.body.append(element);
106
+ const selection = {
107
+ element,
108
+ capabilities: { canApplyManualSize: true, canApplyManualRotation: true },
109
+ };
110
+ const handlers = createDomEditOverlayGestureHandlers({
111
+ selectionRef: ref(selection as unknown as DomEditSelection),
112
+ overlayRectRef: ref({ left: 0, top: 0, width: 240, height: 160, editScaleX: 1, editScaleY: 1 }),
113
+ boxRef: ref(document.createElement("div")),
114
+ overlayRef: ref(null),
115
+ iframeRef: ref(null),
116
+ gestureRef: ref<GestureState | null>(null),
117
+ rafPausedRef: ref(false),
118
+ onBoxSizeCommitRef: ref(
119
+ vi.fn(() => {
120
+ element.style.setProperty("clip-path", "inset(15px)");
121
+ return save;
122
+ }),
123
+ ),
124
+ onRotationCommitRef: ref((sel: DomEditSelection, next: never) =>
125
+ savePlainRotation({ commitPositionPatchToHtml: () => save.then(() => undefined) }, sel, next),
126
+ ),
127
+ snapGuidesRef: ref(null),
128
+ groupGestureRef: ref(null),
129
+ blockedMoveRef: ref(null),
130
+ setOverlayRect: vi.fn(),
131
+ suppressNextBoxClickRef: ref(false),
132
+ hoverSelectionRef: ref(null),
133
+ onCanvasMouseDown: vi.fn(),
134
+ } as never);
135
+ const start = kind === "resize" ? pointer(240, 160) : pointer(240, 80);
136
+ expect(handlers.startGesture(kind, start as never, { resizeHandle: "se" })).toBe(true);
137
+ const end = kind === "resize" ? pointer(300, 200) : pointer(120, 200);
138
+ handlers.onPointerMove(end as never);
139
+ handlers.onPointerUp(end as never);
140
+ return element;
141
+ }
142
+
143
+ it.each(["resize", "rotate"] as const)(
144
+ "a %s whose save is still running can be painted back at once, and shown again",
145
+ async (kind) => {
146
+ let saved!: () => void;
147
+ const element = gestureWithSaveRunning(kind, new Promise<void>((resolve) => (saved = resolve)));
148
+ const before = "position: absolute; width: 240px; height: 160px; clip-path: inset(10px)";
149
+ const edited = element.getAttribute("style");
150
+ expect(edited).not.toBe(before);
151
+
152
+ const shown = paintBackNewestStudioPendingEdit();
153
+ expect(element.getAttribute("style")).toBe(before);
154
+ expect(paintBackNewestStudioPendingEdit()).toBeNull();
155
+ shown!.showAgain();
156
+ expect(element.getAttribute("style")).toBe(edited);
157
+
158
+ saved();
159
+ await vi.waitFor(() => expect(hasStudioPendingEdits()).toBe(false));
160
+ },
161
+ );
162
+
163
+ it("a drag of a box GSAP positions is painted back to where GSAP had it, and shown again", async () => {
164
+ const gsapOf = fakeGsap({ x: 5, y: 7 });
165
+ let saved!: () => void;
166
+ const element = dragWithSaveRunning(new Promise<void>((resolve) => (saved = resolve)));
167
+ const moved = { ...gsapOf(element) };
168
+ expect(moved).not.toMatchObject({ x: 5, y: 7 });
169
+
170
+ const shown = paintBackNewestStudioPendingEdit();
171
+ expect(gsapOf(element)).toMatchObject({ x: 5, y: 7 });
172
+ shown!.showAgain();
173
+ expect(gsapOf(element)).toMatchObject({ x: moved.x, y: moved.y });
174
+
175
+ saved();
176
+ await vi.waitFor(() => expect(hasStudioPendingEdits()).toBe(false));
177
+ });
178
+
179
+ it("a rotate of a box GSAP turns is painted back to GSAP's angle at press, and shown again", async () => {
180
+ const gsapOf = fakeGsap({ rotation: 10 });
181
+ let saved!: () => void;
182
+ const element = gestureWithSaveRunning(
183
+ "rotate",
184
+ new Promise<void>((resolve) => (saved = resolve)),
185
+ );
186
+ const turned = gsapOf(element).rotation;
187
+ expect(turned).not.toBe(10);
188
+
189
+ const shown = paintBackNewestStudioPendingEdit();
190
+ expect(gsapOf(element).rotation).toBe(10);
191
+ shown!.showAgain();
192
+ expect(gsapOf(element).rotation).toBe(turned);
193
+
194
+ saved();
195
+ await vi.waitFor(() => expect(hasStudioPendingEdits()).toBe(false));
196
+ });
197
+
198
+ it("a GSAP drag saved as a left/top offset stays painted back while that offset is drawn", async () => {
199
+ fakeGsap({ x: 5, y: 7 });
200
+ let saved!: () => void;
201
+ let drawn!: Promise<void>;
202
+ const element = dragWithSaveRunning(() => {
203
+ const edit = adoptingStudioPendingEdit()!;
204
+ drawn = new Promise<void>((resolve) => (saved = resolve)).then(() =>
205
+ edit.drawKeepingUndone(() => void element.style.setProperty("left", "99px")),
206
+ );
207
+ return drawn;
208
+ });
209
+
210
+ const shown = paintBackNewestStudioPendingEdit();
211
+ saved();
212
+ await drawn;
213
+ expect(element.style.getPropertyValue("left")).toBe("");
214
+ shown!.showAgain();
215
+ expect(element.style.getPropertyValue("left")).toBe("99px");
216
+ await vi.waitFor(() => expect(hasStudioPendingEdits()).toBe(false));
217
+ });
218
+
219
+ it("keeps a GSAP drag's base through every repaint of its undo, so its save adds the drag once", async () => {
220
+ const gsapOf = fakeGsap({ x: 5, y: 7 });
221
+ let edit!: StudioEditInFlight;
222
+ let release!: () => void;
223
+ const element = dragWithSaveRunning(() => {
224
+ edit = adoptingStudioPendingEdit()!;
225
+ return new Promise<void>((resolve) => (release = resolve));
226
+ });
227
+ const moved = { ...gsapOf(element) };
228
+
229
+ const shown = paintBackNewestStudioPendingEdit();
230
+ edit.drawKeepingUndone(() => undefined);
231
+ expect(gsapOf(element)).toMatchObject({ x: 5, y: 7 });
232
+ expect(element.getAttribute("data-hf-drag-gsap-base-x")).toBe("5");
233
+ shown!.showAgain();
234
+ expect(gsapOf(element)).toMatchObject({ x: moved.x, y: moved.y });
235
+ expect(element.getAttribute("data-hf-drag-gsap-base-x")).toBe("5");
236
+
237
+ release();
238
+ await vi.waitFor(() => expect(hasStudioPendingEdits()).toBe(false));
239
+ });
@@ -0,0 +1,104 @@
1
+ import {
2
+ captureStudioPathOffset,
3
+ restoreStudioPathOffset,
4
+ type StudioPathOffsetSnapshot,
5
+ } from "./manualEdits";
6
+ import {
7
+ getOffsetDragGsap,
8
+ stampGestureBase,
9
+ type ManualOffsetDragMember,
10
+ } from "./manualOffsetDrag";
11
+ import type { StudioEditRevert } from "../../utils/studioPendingEdits";
12
+
13
+ interface MemberPosition {
14
+ offset: StudioPathOffsetSnapshot;
15
+ gsap: { x: number; y: number } | null;
16
+ placement: string[];
17
+ }
18
+
19
+ // Where an element-offset move draws the element when no transform channel can take it.
20
+ const PLACEMENT = ["position", "left", "top"];
21
+ const placementOf = (element: HTMLElement) =>
22
+ PLACEMENT.map((prop) => element.style.getPropertyValue(prop));
23
+
24
+ function showPlacement(element: HTMLElement, placement: string[]): void {
25
+ PLACEMENT.forEach((prop, i) => {
26
+ if (placement[i]) element.style.setProperty(prop, placement[i]!);
27
+ else element.style.removeProperty(prop);
28
+ });
29
+ }
30
+
31
+ function readMemberPosition(member: ManualOffsetDragMember): MemberPosition {
32
+ const gsap = member.plainTranslate ? null : getOffsetDragGsap(member.element);
33
+ return {
34
+ offset: captureStudioPathOffset(member.element),
35
+ gsap: gsap && {
36
+ x: Number(gsap.getProperty(member.element, "x")),
37
+ y: Number(gsap.getProperty(member.element, "y")),
38
+ },
39
+ placement: placementOf(member.element),
40
+ };
41
+ }
42
+
43
+ function showMemberPosition(member: ManualOffsetDragMember, position: MemberPosition): void {
44
+ restoreStudioPathOffset(member.element, position.offset);
45
+ showPlacement(member.element, position.placement);
46
+ if (position.gsap) getOffsetDragGsap(member.element)?.set(member.element, { ...position.gsap });
47
+ // The restore drops the gesture's base stamps; its save still reads them.
48
+ if (!member.plainTranslate)
49
+ stampGestureBase(member.element, member.initialOffset, member.baseGsap);
50
+ }
51
+
52
+ /** Undo's live revert of a move: its members at gesture start. */
53
+ export function manualOffsetMoveRevert(members: ManualOffsetDragMember[]): StudioEditRevert {
54
+ const startPlacement = members.map((member) => placementOf(member.element));
55
+ return () => {
56
+ const shown = members.map(readMemberPosition);
57
+ members.forEach((member, i) =>
58
+ showMemberPosition(member, {
59
+ offset: member.initialPathOffset,
60
+ gsap: member.plainTranslate ? null : member.baseGsap,
61
+ placement: startPlacement[i]!,
62
+ }),
63
+ );
64
+ return () => members.forEach((member, i) => showMemberPosition(member, shown[i]!));
65
+ };
66
+ }
67
+
68
+ export interface StudioElementLook {
69
+ style: string | null;
70
+ gsap: Record<string, number> | null;
71
+ }
72
+
73
+ const GSAP_LOOK_PROPS = ["x", "y", "rotation", "scaleX", "scaleY"];
74
+
75
+ /** Reads GSAP only for an element it owns: reading a plain one makes GSAP bake its CSS into a transform. */
76
+ export function readElementLook(element: HTMLElement, gsapOwned: boolean): StudioElementLook {
77
+ const gsap = gsapOwned ? getOffsetDragGsap(element) : null;
78
+ return {
79
+ style: element.getAttribute("style"),
80
+ gsap:
81
+ gsap &&
82
+ Object.fromEntries(
83
+ GSAP_LOOK_PROPS.map((prop) => [prop, Number(gsap.getProperty(element, prop))]),
84
+ ),
85
+ };
86
+ }
87
+
88
+ function showElementLook(element: HTMLElement, look: StudioElementLook): void {
89
+ if (look.style === null) element.removeAttribute("style");
90
+ else element.setAttribute("style", look.style);
91
+ if (look.gsap) getOffsetDragGsap(element)?.set(element, { ...look.gsap });
92
+ }
93
+
94
+ /** Undo's live revert of a resize or rotate: the element as it looked at press. */
95
+ export function elementLookRevert(
96
+ element: HTMLElement,
97
+ start: StudioElementLook,
98
+ ): StudioEditRevert {
99
+ return () => {
100
+ const shown = readElementLook(element, start.gsap !== null);
101
+ showElementLook(element, start);
102
+ return () => showElementLook(element, shown);
103
+ };
104
+ }
@@ -13,7 +13,6 @@ import {
13
13
  import { computeDraggedGsapPosition } from "../../hooks/draggedGsapPosition";
14
14
  import { editsPlainCss } from "../../hooks/gsapRuntimeKeyframes";
15
15
  import { readTranslatePx, UNREADABLE_TRANSLATE, writeTranslatePx } from "./plainTranslate";
16
- import type { StudioEditRevert } from "../../utils/studioPendingEdits";
17
16
 
18
17
  interface OffsetDragGsap {
19
18
  set: (el: Element, vars: Record<string, number | string>) => void;
@@ -448,7 +447,7 @@ export function applyManualOffsetDragDraft(
448
447
  * the element flies off-screen the instant you drop it. The member holds the
449
448
  * true gesture-start values in JS, immune to the re-render.
450
449
  */
451
- function stampGestureBase(el: HTMLElement, initialOffset: Point, baseGsap: Point): void {
450
+ export function stampGestureBase(el: HTMLElement, initialOffset: Point, baseGsap: Point): void {
452
451
  el.setAttribute("data-hf-drag-gsap-base-x", String(baseGsap.x));
453
452
  el.setAttribute("data-hf-drag-gsap-base-y", String(baseGsap.y));
454
453
  el.setAttribute("data-hf-drag-initial-offset-x", String(initialOffset.x));
@@ -510,16 +509,6 @@ function restoreManualOffsetDragMember(member: ManualOffsetDragMember): void {
510
509
  endStudioManualEditGesture(member.element, member.gestureToken);
511
510
  }
512
511
 
513
- /** Undo's live revert of a move: its members at gesture start. Null off the plain route, which GSAP draws. */
514
- export function manualOffsetMoveRevert(members: ManualOffsetDragMember[]): StudioEditRevert | null {
515
- if (!members.every((member) => member.plainTranslate)) return null;
516
- return () => {
517
- const shown = members.map((member) => captureStudioPathOffset(member.element));
518
- for (const member of members) restoreStudioPathOffset(member.element, member.initialPathOffset);
519
- return () => members.forEach((member, i) => restoreStudioPathOffset(member.element, shown[i]!));
520
- };
521
- }
522
-
523
512
  /** Roll back a FAILED drag to the exact gesture-start state. */
524
513
  export function restoreManualOffsetDragMembers(members: ManualOffsetDragMember[]): void {
525
514
  for (const member of members) {
@@ -32,10 +32,10 @@ import {
32
32
  applyManualOffsetNudgeDraft,
33
33
  createManualOffsetDragMember,
34
34
  endManualOffsetDragMembers,
35
- manualOffsetMoveRevert,
36
35
  restoreManualOffsetDragMembers,
37
36
  type ManualOffsetDragMember,
38
37
  } from "./manualOffsetDrag";
38
+ import { manualOffsetMoveRevert } from "./gestureUndoRevert";
39
39
  import { isStudioManualEditGestureCurrent, restoreStudioPathOffset } from "./manualEdits";
40
40
  import {
41
41
  CANVAS_NUDGE_COMMIT_DEBOUNCE_MS,
@@ -12,8 +12,8 @@ import {
12
12
  applyManualOffsetDragDraft,
13
13
  endManualOffsetDragMembers,
14
14
  restoreManualOffsetDragMembers,
15
- manualOffsetMoveRevert,
16
15
  } from "./manualOffsetDrag";
16
+ import { manualOffsetMoveRevert, elementLookRevert } from "./gestureUndoRevert";
17
17
  import { applyRotationDraft, restoreRotationDraft } from "./rotationDraft";
18
18
  import {
19
19
  applyStudioBoxSize,
@@ -443,7 +443,8 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
443
443
  // Hold the final angle while the commit lands.
444
444
  applyRotationDraft(sel.element, finalRotation.angle, g.plainRotation);
445
445
  const commit = { ...finalRotation, plain: g.plainRotation };
446
- void Promise.resolve(opts.onRotationCommitRef.current(sel, commit))
446
+ const edit = beginStudioPendingEdit(elementLookRevert(sel.element, g.initialLook));
447
+ const saved = Promise.resolve(edit.adopt(() => opts.onRotationCommitRef.current(sel, commit)))
447
448
  .then((result) => trackPreviewEditResult("rotate", "drag", result))
448
449
  .catch((error) => {
449
450
  logGestureCommitFailure("rotate commit failed", error);
@@ -454,6 +455,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
454
455
  restoreRotation();
455
456
  })
456
457
  .finally(() => endStudioManualEditGesture(sel.element, g.manualEditDragToken));
458
+ edit.settle(saved);
457
459
  } else if (g.kind === "drag") {
458
460
  // A moved drag (taps returned earlier) must not let the release click
459
461
  // re-select whatever now sits under the pointer — dropping over a
@@ -535,9 +537,10 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
535
537
  restoreStudioBoxSize(sel.element, g.initialBoxSize);
536
538
  if (finalOffset) restoreStudioPathOffset(sel.element, g.initialPathOffset);
537
539
  };
538
- void Promise.resolve(
539
- opts.onBoxSizeCommitRef.current(sel, finalSize, finalOffset ?? undefined, restore, member),
540
- )
540
+ const commitSize = () =>
541
+ opts.onBoxSizeCommitRef.current(sel, finalSize, finalOffset ?? undefined, restore, member);
542
+ const edit = beginStudioPendingEdit(elementLookRevert(sel.element, g.initialLook));
543
+ const saved = Promise.resolve(edit.adopt(commitSize))
541
544
  .then((result) => trackPreviewEditResult("resize", "drag", result))
542
545
  .catch((error) => {
543
546
  logGestureCommitFailure("resize commit failed", error);
@@ -546,6 +549,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
546
549
  if (member) endManualOffsetDragMembers([member]);
547
550
  else endStudioManualEditGesture(sel.element, g.manualEditDragToken);
548
551
  });
552
+ edit.settle(saved);
549
553
  logResizeSettle(sel.element, "post-release");
550
554
  }
551
555
  };
@@ -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 };