@hyperframes/studio 0.8.98 → 0.8.100

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 (63) hide show
  1. package/dist/assets/{hyperframes-player-C6muFtfk.js → hyperframes-player-fWt87gyh.js} +1 -1
  2. package/dist/assets/{index-B6vU39NM.js → index-D34Oy5DN.js} +1 -1
  3. package/dist/assets/{index-itc1XziP.js → index-D6TjRAvz.js} +171 -171
  4. package/dist/assets/{index-BxnoLgxG.js → index-DofdzdxJ.js} +1 -1
  5. package/dist/index.html +1 -1
  6. package/dist/index.js +718 -438
  7. package/dist/index.js.map +1 -1
  8. package/package.json +9 -9
  9. package/src/components/editor/DomEditCropHandles.test.tsx +34 -66
  10. package/src/components/editor/DomEditCropHandles.tsx +30 -62
  11. package/src/components/editor/DomEditOverlay.test.ts +2 -2
  12. package/src/components/editor/PropertyPanel.tsx +3 -3
  13. package/src/components/editor/cropResize.test.ts +129 -0
  14. package/src/components/editor/cropResize.ts +143 -0
  15. package/src/components/editor/domEditOverlayCrop.ts +65 -15
  16. package/src/components/editor/domEditOverlayCropLift.test.ts +44 -0
  17. package/src/components/editor/domEditOverlayGestures.ts +2 -5
  18. package/src/components/editor/domEditOverlayStartGesture.test.ts +83 -0
  19. package/src/components/editor/domEditOverlayStartGesture.ts +6 -4
  20. package/src/components/editor/elementPositionOffset.ts +5 -4
  21. package/src/components/editor/manualEdits.test.ts +22 -8
  22. package/src/components/editor/manualEditsDom.ts +27 -11
  23. package/src/components/editor/manualEditsParsing.ts +0 -4
  24. package/src/components/editor/manualEditsSnapshot.ts +3 -0
  25. package/src/components/editor/manualOffsetDrag.test.ts +84 -0
  26. package/src/components/editor/manualOffsetDrag.ts +50 -40
  27. package/src/components/editor/plainTranslate.test.ts +62 -0
  28. package/src/components/editor/plainTranslate.ts +85 -0
  29. package/src/components/editor/propertyPanelTransformCommit.ts +3 -2
  30. package/src/components/editor/useDomEditNudge.ts +1 -1
  31. package/src/hooks/domSelectionTestHarness.ts +8 -0
  32. package/src/hooks/draggedGsapPosition.ts +4 -2
  33. package/src/hooks/elementOffsetStager.ts +33 -2
  34. package/src/hooks/gsapDragCommit.ts +6 -6
  35. package/src/hooks/gsapEditOutcome.ts +6 -0
  36. package/src/hooks/gsapResizeIntercept.ts +5 -5
  37. package/src/hooks/gsapRuntimeBridge.test.ts +11 -0
  38. package/src/hooks/gsapRuntimeBridge.ts +3 -18
  39. package/src/hooks/gsapRuntimeKeyframes.test.ts +7 -0
  40. package/src/hooks/gsapRuntimeKeyframes.ts +69 -21
  41. package/src/hooks/timelineTimingSync.ts +40 -4
  42. package/src/hooks/useAnimatedPropertyCommit.test.tsx +40 -1
  43. package/src/hooks/useAnimatedPropertyCommit.ts +11 -13
  44. package/src/hooks/useCropOverlay.ts +8 -2
  45. package/src/hooks/useDomEditCommits.test.tsx +18 -4
  46. package/src/hooks/useDomEditCommits.ts +3 -1
  47. package/src/hooks/useDomEditPositionPatchCommit.ts +2 -2
  48. package/src/hooks/useDomEditSession.ts +2 -0
  49. package/src/hooks/useDomEditTextCommits.test.tsx +62 -5
  50. package/src/hooks/useDomEditTextCommits.ts +35 -35
  51. package/src/hooks/useDomGeometryCommit.test.tsx +62 -4
  52. package/src/hooks/useDomGeometryCommit.ts +1 -0
  53. package/src/hooks/useDomGeometryCommits.test.tsx +116 -2
  54. package/src/hooks/useDomGeometryCommits.ts +5 -0
  55. package/src/hooks/useElementLifecycleOps.multiDelete.test.tsx +3 -1
  56. package/src/hooks/useGsapAwareEditing.test.tsx +147 -23
  57. package/src/hooks/useGsapAwareEditing.ts +48 -34
  58. package/src/hooks/useTimelineEditing.test.tsx +190 -135
  59. package/src/hooks/useTimelineEditing.ts +13 -13
  60. package/src/hooks/useTimelineGroupEditing.ts +13 -9
  61. package/src/player/components/useTimelineVirtualRows.test.tsx +30 -1
  62. package/src/utils/rounding.ts +5 -0
  63. package/src/utils/sdkEditTransaction.ts +10 -3
@@ -125,13 +125,20 @@ afterEach(() => {
125
125
  });
126
126
 
127
127
  describe("useDomEditTextCommits", () => {
128
- function richTextProbe() {
129
- const { iframe, element } = previewElement('<h1 id="t">Old</h1>', "t");
128
+ function richTextProbe(
129
+ html = '<h1 id="t">Old</h1>',
130
+ overrides: (doc: Document) => Partial<UseDomEditTextCommitsParams> = () => ({}),
131
+ ) {
132
+ const { iframe, element } = previewElement(html, "t");
130
133
  const persist = vi.fn().mockResolvedValue(undefined);
134
+ const showToast = vi.fn();
131
135
  const base = commitParams({
132
136
  previewIframeRef: { current: iframe },
133
137
  domEditSelection: selectionFor(element),
138
+ buildDomSelectionFromTarget: vi.fn(async (target: HTMLElement) => selectionFor(target)),
134
139
  persistDomEditOperations: persist,
140
+ showToast,
141
+ ...overrides(element.ownerDocument),
135
142
  });
136
143
  const captured: { hook: ReturnType<typeof useDomEditTextCommits> | null } = { hook: null };
137
144
  function Probe({ readOnlyPreview }: { readOnlyPreview: boolean }) {
@@ -143,13 +150,25 @@ describe("useDomEditTextCommits", () => {
143
150
  const save = captured.hook!.handleDomRichTextCommit;
144
151
  const commit = { element, html: "New", previousHtml: "Old" };
145
152
  element.innerHTML = "New";
146
- return { root, Probe, persist, element, save: () => act(async () => save(commit)) };
153
+ return {
154
+ root,
155
+ Probe,
156
+ persist,
157
+ showToast,
158
+ element,
159
+ save: () => act(async () => save(commit)),
160
+ };
147
161
  }
148
162
 
149
- it("saves in-place text while the preview is editable", async () => {
150
- const { persist, save } = richTextProbe();
163
+ it("saves in-place text while the preview is editable, and refreshes the selection it edited", async () => {
164
+ const applyDomSelection = vi.fn();
165
+ const { persist, element, save } = richTextProbe(undefined, () => ({ applyDomSelection }));
151
166
  await save();
152
167
  expect(persist).toHaveBeenCalledTimes(1);
168
+ expect(applyDomSelection).toHaveBeenCalledWith(
169
+ expect.objectContaining({ element }),
170
+ expect.objectContaining({ preserveGroup: true }),
171
+ );
153
172
  });
154
173
 
155
174
  it("refuses in-place text once the preview turns read-only, through an earlier handler, and puts the old text back", async () => {
@@ -160,6 +179,44 @@ describe("useDomEditTextCommits", () => {
160
179
  expect(element.innerHTML).toBe("Old");
161
180
  });
162
181
 
182
+ it("saves the edited element, and leaves the selection alone, when the selection is another element or none", async () => {
183
+ for (const selected of ["card", null]) {
184
+ const applyDomSelection = vi.fn();
185
+ const { persist, element, save } = richTextProbe(
186
+ '<div id="card"><p id="t">Old</p></div>',
187
+ (doc) => ({
188
+ domEditSelection: selected ? selectionFor(doc.getElementById(selected)!) : null,
189
+ applyDomSelection,
190
+ buildDomSelectionFromTarget: vi.fn(async (target: HTMLElement) => ({
191
+ ...selectionFor(target),
192
+ label: "Resolved from the edited element",
193
+ })),
194
+ }),
195
+ );
196
+ await save();
197
+ expect(persist).toHaveBeenCalledTimes(1);
198
+ expect(persist.mock.calls[0]![0]).toMatchObject({
199
+ element,
200
+ label: "Resolved from the edited element",
201
+ });
202
+ expect(applyDomSelection).not.toHaveBeenCalled();
203
+ cleanup?.();
204
+ cleanup = null;
205
+ }
206
+ });
207
+
208
+ it("says so and puts the old text back when the edited text cannot be saved", async () => {
209
+ const error = vi.spyOn(console, "error").mockImplementation(() => {});
210
+ const { persist, showToast, element, save } = richTextProbe(undefined, () => ({
211
+ buildDomSelectionFromTarget: vi.fn(async () => null),
212
+ }));
213
+ await save();
214
+ expect(persist).not.toHaveBeenCalled();
215
+ expect(showToast).toHaveBeenCalledWith(expect.stringContaining("Couldn't save"), "error");
216
+ expect(error).toHaveBeenCalled();
217
+ expect(element.innerHTML).toBe("Old");
218
+ });
219
+
163
220
  it("keeps concurrent text commit ownership isolated by target", async () => {
164
221
  const { iframe, element: firstElement } = previewElement(
165
222
  "<div id='first'>First</div><div id='second'>Second</div>",
@@ -29,6 +29,7 @@ import {
29
29
  import { commitDomStyles } from "./domStyleCommit";
30
30
  import { useDomEditAttributeCommits } from "./useDomEditAttributeCommits";
31
31
  import type { InlineTextEditCommit } from "./useInlineTextEdit";
32
+ import type { ResolveDomSelectionOptions } from "./useDomSelectionTypes";
32
33
 
33
34
  // ── Types ──
34
35
 
@@ -44,7 +45,7 @@ export interface UseDomEditTextCommitsParams {
44
45
  refreshDomEditSelectionFromPreview: (selection: DomEditSelection) => void;
45
46
  buildDomSelectionFromTarget: (
46
47
  target: HTMLElement,
47
- options?: { preferClipAncestor?: boolean },
48
+ options?: ResolveDomSelectionOptions,
48
49
  ) => Promise<DomEditSelection | null>;
49
50
  persistDomEditOperations: PersistDomEditOperations;
50
51
  resolveImportedFontAsset: (fontFamilyValue: string) => ImportedFontAsset | null;
@@ -56,15 +57,6 @@ function canCommitInlineTextSelection(selection: DomEditSelection, element: HTML
56
57
  return canEditElementTextInline(element);
57
58
  }
58
59
 
59
- function ownsCurrentPreviewElement(
60
- selection: DomEditSelection,
61
- element: HTMLElement,
62
- document: Document | null | undefined,
63
- ): document is Document {
64
- if (!document || !element.isConnected) return false;
65
- return element === selection.element && element.ownerDocument === document;
66
- }
67
-
68
60
  async function resyncDomTextSelectionFromPreview(
69
61
  doc: Document | null | undefined,
70
62
  selection: DomEditSelection,
@@ -96,6 +88,8 @@ export function useDomEditTextCommits({
96
88
  }: UseDomEditTextCommitsParams) {
97
89
  const latestReadOnlyPreviewRef = useRef(readOnlyPreview);
98
90
  latestReadOnlyPreviewRef.current = readOnlyPreview;
91
+ const latestSelectionRef = useRef(domEditSelection);
92
+ latestSelectionRef.current = domEditSelection;
99
93
  const domTextCommitVersionRef = useRef(new Map<string, symbol>());
100
94
  const domStyleCommitVersionRef = useRef(new Map<string, symbol>());
101
95
 
@@ -238,29 +232,36 @@ export function useDomEditTextCommits({
238
232
  */
239
233
  const handleDomRichTextCommit = useCallback(
240
234
  async ({ element, html, previousHtml }: InlineTextEditCommit) => {
241
- if (!domEditSelection) return;
242
- if (latestReadOnlyPreviewRef.current) {
235
+ const putBack = () => {
243
236
  if (element.isConnected && element.innerHTML === html) element.innerHTML = previousHtml;
244
- return;
237
+ };
238
+ if (latestReadOnlyPreviewRef.current) return putBack();
239
+ const refuse = (reason: string) => {
240
+ console.error("[Studio] text edit not saved:", reason, element);
241
+ showToast(`Couldn't save the text edit: ${reason}`, "error");
242
+ putBack();
243
+ };
244
+ // The edited node, not the current selection: a press can open an edit on a child of what
245
+ // is selected, and a host can clear the selection before the edit closes.
246
+ const doc = previewIframeRef.current?.contentDocument;
247
+ if (!doc || !element.isConnected || element.ownerDocument !== doc) {
248
+ return refuse("the text's element is gone from the preview");
249
+ }
250
+ const selection = await buildDomSelectionFromTarget(element, {
251
+ exactTarget: true,
252
+ skipSourceProbe: true,
253
+ });
254
+ if (selection?.element !== element) {
255
+ return refuse("this text was not found in the composition's source");
256
+ }
257
+ // The same gate that let the edit open, not the design panel's field rule: an element
258
+ // whose text holds a line break has no fields, and editing in place rewrites its markup.
259
+ if (!canCommitInlineTextSelection(selection, element)) {
260
+ return refuse("this text can't be edited in place");
245
261
  }
246
- // The same gate that let the edit open, not the design panel's.
247
- //
248
- // The panel's rule is about its text fields, and it has none for an
249
- // element whose text contains a line break: a `<span>` holding `<br>`s
250
- // is not a leaf, so nothing inside is a field and the element reports no
251
- // editable text at all. Editing in place does not use fields — it
252
- // rewrites the element's own markup — so refusing on that rule refused
253
- // elements the caret had just been opened in, and every colour the user
254
- // chose was dropped on the way out with nothing said about it.
255
- if (!canCommitInlineTextSelection(domEditSelection, element)) return;
256
- const iframe = previewIframeRef.current;
257
- const doc = iframe?.contentDocument;
258
- // A preview reload replaces the document. Never resolve this commit onto
259
- // the replacement node: it did not own the edit or its rollback snapshot.
260
- if (!ownsCurrentPreviewElement(domEditSelection, element, doc)) return;
261
262
  const isLatestTextCommit = bumpDomEditCommitMapVersion(
262
263
  domTextCommitVersionRef.current,
263
- getDomEditTargetKey(domEditSelection),
264
+ getDomEditTargetKey(selection),
264
265
  );
265
266
  const operations = [buildDomEditRichTextPatchOperation(html)];
266
267
  let appliedHtml = "";
@@ -274,7 +275,7 @@ export function useDomEditTextCommits({
274
275
  appliedHtml = element.innerHTML;
275
276
  },
276
277
  persist: async () => {
277
- await persistDomEditOperations(domEditSelection, operations, {
278
+ await persistDomEditOperations(selection, operations, {
278
279
  label: "Edit text",
279
280
  skipRefresh: true,
280
281
  shouldSave: isLatestTextCommit,
@@ -288,13 +289,13 @@ export function useDomEditTextCommits({
288
289
  element.innerHTML = previousHtml;
289
290
  }
290
291
  },
291
- onError: (error) =>
292
- reportDomEditPersistFailure(domEditSelection, operations, error, showToast),
293
- shouldResync: isLatestTextCommit,
292
+ onError: (error) => reportDomEditPersistFailure(selection, operations, error, showToast),
293
+ // Re-select only what is still selected: the selection may have moved on, or a host cleared it.
294
+ shouldResync: () => isLatestTextCommit() && latestSelectionRef.current?.element === element,
294
295
  resync: () =>
295
296
  resyncDomTextSelectionFromPreview(
296
297
  doc,
297
- domEditSelection,
298
+ selection,
298
299
  activeCompPath,
299
300
  buildDomSelectionFromTarget,
300
301
  applyDomSelection,
@@ -306,7 +307,6 @@ export function useDomEditTextCommits({
306
307
  activeCompPath,
307
308
  applyDomSelection,
308
309
  buildDomSelectionFromTarget,
309
- domEditSelection,
310
310
  persistDomEditOperations,
311
311
  previewIframeRef,
312
312
  showToast,
@@ -16,6 +16,11 @@ vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
16
16
 
17
17
  const SOURCE = '<div id="card">Card</div><div id="other">Other</div>';
18
18
 
19
+ /** GSAP already renders this element's transform, so a move takes the GSAP writer. */
20
+ function gsapPositioned(element: HTMLElement): HTMLElement {
21
+ return Object.assign(element, { _gsap: { renderTransform: () => {} } });
22
+ }
23
+
19
24
  /** A warm parse where only another element animates, and a GSAP writer answering `status`. */
20
25
  function stubServer(status = 200, parseStatus = 200) {
21
26
  const mutations: unknown[] = [];
@@ -116,7 +121,8 @@ describe("useDomGeometryCommit, from the package entry", () => {
116
121
  onRotationCommit: hook().commitRotation,
117
122
  };
118
123
 
119
- const outcome = await overlayCommits.onPathOffsetCommit(makeSelection("card", element), {
124
+ const card = makeSelection("card", gsapPositioned(element));
125
+ const outcome = await overlayCommits.onPathOffsetCommit(card, {
120
126
  x: 40,
121
127
  y: 20,
122
128
  });
@@ -132,6 +138,56 @@ describe("useDomGeometryCommit, from the package entry", () => {
132
138
  unmount();
133
139
  });
134
140
 
141
+ function stubPatchServer(patchStatus = 200) {
142
+ const calls = { urls: [] as string[], patches: [] as unknown[] };
143
+ vi.stubGlobal(
144
+ "fetch",
145
+ vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
146
+ const url = String(input instanceof Request ? input.url : input);
147
+ calls.urls.push(url);
148
+ if (url.includes("/patch-element/")) calls.patches.push(JSON.parse(String(init?.body)));
149
+ const saved = { ok: true, changed: true, matched: true, content: "AFTER", version: "v2" };
150
+ const body = url.includes("/files/") ? { content: SOURCE } : saved;
151
+ const status = url.includes("/patch-element/") ? patchStatus : 200;
152
+ return new Response(JSON.stringify(body), {
153
+ status,
154
+ headers: { "content-type": "application/json" },
155
+ });
156
+ }),
157
+ );
158
+ return calls;
159
+ }
160
+
161
+ it("saves a GSAP-free move as its inline translate, with no GSAP script and no animation read", async () => {
162
+ const calls = stubPatchServer();
163
+ const { element, recordEdit, hook, unmount } = renderHost();
164
+
165
+ const card = makeSelection("card", element);
166
+ await expect(hook().commitPathOffset(card, { x: 130.5, y: 90 })).resolves.toEqual({ ok: true });
167
+
168
+ expect(element.style.getPropertyValue("translate")).toBe("130.5px 90px");
169
+ expect(calls.patches).toEqual([
170
+ expect.objectContaining({
171
+ operations: [{ type: "inline-style", property: "translate", value: "130.5px 90px" }],
172
+ }),
173
+ ]);
174
+ expect(calls.urls.filter((url) => url.includes("gsap"))).toEqual([]);
175
+ expect(recordEdit).toHaveBeenCalledTimes(1);
176
+ unmount();
177
+ });
178
+
179
+ it("puts a GSAP-free element's translate back when its move cannot be saved", async () => {
180
+ stubPatchServer(500);
181
+ const { element, hook, unmount } = renderHost();
182
+ element.style.setProperty("translate", "40px 30px");
183
+
184
+ await expect(
185
+ hook().commitPathOffset(makeSelection("card", element), { x: 1, y: 2 }),
186
+ ).rejects.toThrow();
187
+ expect(element.style.getPropertyValue("translate")).toBe("40px 30px");
188
+ unmount();
189
+ });
190
+
135
191
  it("keeps the same commits across renders, so the overlay's handlers stay put", () => {
136
192
  stubServer();
137
193
  const { hook, rerender, unmount } = renderHost();
@@ -148,7 +204,7 @@ describe("useDomGeometryCommit, from the package entry", () => {
148
204
  const { element, recordEdit, hook, unmount } = renderHost({ showToast });
149
205
 
150
206
  await expect(
151
- hook().commitPathOffset(makeSelection("card", element), { x: 40, y: 20 }),
207
+ hook().commitPathOffset(makeSelection("card", gsapPositioned(element)), { x: 40, y: 20 }),
152
208
  ).rejects.toThrow();
153
209
  expect(recordEdit).not.toHaveBeenCalled();
154
210
  expect(showToast).toHaveBeenCalledWith(expect.any(String), "error");
@@ -204,7 +260,8 @@ describe("useDomGeometryCommit, from the package entry", () => {
204
260
  const mutations = stubServer();
205
261
  const { element, recordEdit, hook, unmount } = renderHost();
206
262
 
207
- const move = hook().commitPathOffset(makeSelection("card", element), { x: 40, y: 20 });
263
+ const card = makeSelection("card", gsapPositioned(element));
264
+ const move = hook().commitPathOffset(card, { x: 40, y: 20 });
208
265
  await hook().waitForPendingSaves();
209
266
 
210
267
  expect(mutations).toHaveLength(1);
@@ -280,7 +337,8 @@ describe("useDomGeometryCommit, one word of a staggered phrase", () => {
280
337
  offsetLeft: { get: () => 100 + (Number.parseFloat(word.style.left) || 0) },
281
338
  offsetTop: { get: () => 200 + (Number.parseFloat(word.style.top) || 0) },
282
339
  });
283
- return { ...makeSelection("How", word), id: undefined, selector: ".w", hfId: "hf-w0" };
340
+ const selection = makeSelection("How", gsapPositioned(word));
341
+ return { ...selection, id: undefined, selector: ".w", hfId: "hf-w0" };
284
342
  }
285
343
 
286
344
  it("saves left/top on the dragged word only and never rewrites the shared tween", async () => {
@@ -136,6 +136,7 @@ export function useDomGeometryCommit({
136
136
  trackGsapInteractionFailure,
137
137
  stageElementPositionOffset,
138
138
  handleDomBoxSizeCommit: noDomBoxSizeRoute,
139
+ commitPositionPatchToHtml: commitWithFreshQueue,
139
140
  addGsapAnimation: gsap.addGsapAnimation,
140
141
  convertToKeyframes: gsap.convertToKeyframes,
141
142
  setArcPath: gsap.setArcPath,
@@ -13,6 +13,8 @@ import {
13
13
  readStudioRotation,
14
14
  } from "../components/editor/manualEdits";
15
15
  import { useDomGeometryCommits, type UseDomGeometryCommitsParams } from "./useDomGeometryCommits";
16
+ import { DomEditCropHandles } from "../components/editor/DomEditCropHandles";
17
+ import { withInlineLayoutBox } from "./domSelectionTestHarness";
16
18
 
17
19
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
18
20
 
@@ -145,8 +147,10 @@ describe("useDomGeometryCommits read-only preview", () => {
145
147
  });
146
148
 
147
149
  describe("useDomGeometryCommits element position offset", () => {
148
- it("persists left/top on the element and no translate offset", async () => {
149
- const element = document.createElement("span");
150
+ it("persists left/top on a word a shared tween positions, and no translate offset", async () => {
151
+ const element = Object.assign(document.createElement("span"), {
152
+ _gsap: { renderTransform: () => {} },
153
+ });
150
154
  Object.defineProperties(element, {
151
155
  offsetLeft: { get: () => 100 + (Number.parseFloat(element.style.left) || 0) },
152
156
  offsetTop: { get: () => 200 + (Number.parseFloat(element.style.top) || 0) },
@@ -170,3 +174,113 @@ describe("useDomGeometryCommits element position offset", () => {
170
174
  unmount();
171
175
  });
172
176
  });
177
+
178
+ describe("useDomGeometryCommits resize of a cropped element", () => {
179
+ it("saves the crop scaled per axis with the box in the resize's own commit", async () => {
180
+ const element = withInlineLayoutBox(document.createElement("div"));
181
+ element.id = "cropped";
182
+ element.style.cssText = "width: 300px; height: 200px; clip-path: inset(8px 60px 16px 30px)";
183
+ document.body.append(element);
184
+ const selection = {
185
+ id: "cropped",
186
+ selector: "#cropped",
187
+ element,
188
+ } as unknown as DomEditSelection;
189
+ const commitPositionPatchToHtml = vi
190
+ .fn<UseDomGeometryCommitsParams["commitPositionPatchToHtml"]>()
191
+ .mockResolvedValue(undefined);
192
+ const { commits, unmount } = mountCommits(commitPositionPatchToHtml);
193
+ const rect = { left: 0, top: 0, width: 300, height: 200, editScaleX: 1, editScaleY: 1 };
194
+ const host = document.createElement("div");
195
+ const crop = createRoot(host);
196
+ act(() =>
197
+ crop.render(
198
+ <DomEditCropHandles selection={selection} overlayRect={rect} onStyleCommit={vi.fn()} />,
199
+ ),
200
+ );
201
+
202
+ await commits().handleDomBoxSizeCommit(selection, { width: 450, height: 250 });
203
+
204
+ const scaled = "inset(10px 90px 20px 45px)";
205
+ expect(commitPositionPatchToHtml).toHaveBeenCalledTimes(1);
206
+ expect(commitPositionPatchToHtml.mock.calls[0]![1]).toContainEqual({
207
+ type: "inline-style",
208
+ property: "clip-path",
209
+ value: scaled,
210
+ });
211
+ act(() => crop.unmount());
212
+ expect(element.style.getPropertyValue("clip-path")).toBe(scaled);
213
+ unmount();
214
+ });
215
+
216
+ function mountCroppedSelection(id: string, authored: string) {
217
+ const element = withInlineLayoutBox(document.createElement("div"));
218
+ element.id = id;
219
+ element.style.cssText = authored;
220
+ document.body.append(element);
221
+ const selection = { id, selector: `#${id}`, element } as unknown as DomEditSelection;
222
+ const commitPositionPatchToHtml = vi
223
+ .fn<UseDomGeometryCommitsParams["commitPositionPatchToHtml"]>()
224
+ .mockResolvedValue(undefined);
225
+ const mounted = mountCommits(commitPositionPatchToHtml);
226
+ const onStyleCommit = vi.fn((property: string, value: string) => {
227
+ element.style.setProperty(property, value);
228
+ });
229
+ const rect = { left: 0, top: 0, width: 300, height: 200, editScaleX: 1, editScaleY: 1 };
230
+ const crop = createRoot(document.body.appendChild(document.createElement("div")));
231
+ const draw = () =>
232
+ act(() =>
233
+ crop.render(
234
+ <DomEditCropHandles
235
+ selection={selection}
236
+ overlayRect={rect}
237
+ onStyleCommit={onStyleCommit}
238
+ />,
239
+ ),
240
+ );
241
+ draw();
242
+ const dragRightEdge = (by: number) => {
243
+ const handle = document.querySelector<HTMLButtonElement>('[aria-label="Crop right"]')!;
244
+ const press = (type: string, clientX: number) =>
245
+ act(() =>
246
+ handle.dispatchEvent(new PointerEvent(type, { bubbles: true, pointerId: 3, clientX })),
247
+ );
248
+ press("pointerdown", 100);
249
+ press("pointermove", 100 - by);
250
+ press("pointerup", 100 - by);
251
+ };
252
+ const resize = () =>
253
+ mounted.commits().handleDomBoxSizeCommit(selection, { width: 450, height: 300 });
254
+ const done = () => {
255
+ act(() => crop.unmount());
256
+ mounted.unmount();
257
+ };
258
+ return { element, onStyleCommit, draw, dragRightEdge, resize, done };
259
+ }
260
+
261
+ it("after an undo while still selected, the next crop drag starts from the undone crop", async () => {
262
+ const authored = "width: 300px; height: 200px; clip-path: inset(0px 60px 0px 0px)";
263
+ const h = mountCroppedSelection("undone", authored);
264
+ await h.resize();
265
+ // Undo writes the file's style attribute back onto the same live element.
266
+ h.element.setAttribute("style", authored);
267
+ h.draw();
268
+ h.dragRightEdge(20);
269
+
270
+ expect(h.onStyleCommit).toHaveBeenCalledWith("clip-path", "inset(0px 80px 0px 0px)");
271
+ h.done();
272
+ expect(h.element.style.getPropertyValue("clip-path")).toBe("inset(0px 80px 0px 0px)");
273
+ });
274
+
275
+ it("a crop drag right after a resize, with no render between, starts from the scaled crop", async () => {
276
+ const h = mountCroppedSelection(
277
+ "resized",
278
+ "width: 300px; height: 200px; clip-path: inset(0px 60px 0px 0px)",
279
+ );
280
+ await h.resize();
281
+ h.dragRightEdge(20);
282
+
283
+ expect(h.onStyleCommit).toHaveBeenCalledWith("clip-path", "inset(0px 110px 0px 0px)");
284
+ h.done();
285
+ });
286
+ });
@@ -15,6 +15,7 @@ import {
15
15
  clearStudioRotation,
16
16
  } from "../components/editor/manualEdits";
17
17
  import { stageElementOffset } from "./elementOffsetStager";
18
+ import { prepareCropResize } from "../components/editor/cropResize";
18
19
  import {
19
20
  buildPathOffsetPatches,
20
21
  buildBoxSizePatches,
@@ -103,7 +104,9 @@ export function useDomGeometryCommits({
103
104
  if (gsapFallback) return gsapFallback;
104
105
  const beforeSize = captureStudioBoxSize(selection.element);
105
106
  const beforeOffset = offset ? captureStudioPathOffset(selection.element) : null;
107
+ const stageCrop = prepareCropResize(selection.element);
106
108
  applyStudioBoxSize(selection.element, next);
109
+ const crop = stageCrop();
107
110
  // Anchored-corner resize (NW/NE/SW) also moves the element to keep the
108
111
  // opposite corner fixed. Apply the offset and emit BOTH patch sets in a
109
112
  // SINGLE commit: one persist = one undo entry, and there is no
@@ -111,6 +114,7 @@ export function useDomGeometryCommits({
111
114
  // offset is not (that frame was the release "jump"). Both builders read
112
115
  // the already-mutated live element, so concatenation is safe.
113
116
  const patches = buildBoxSizePatches(selection.element);
117
+ if (crop) patches.push(crop.patch);
114
118
  if (offset) {
115
119
  applyStudioPathOffset(selection.element, offset);
116
120
  patches.push(...buildPathOffsetPatches(selection.element));
@@ -121,6 +125,7 @@ export function useDomGeometryCommits({
121
125
  }).catch((error) => {
122
126
  restoreStudioBoxSize(selection.element, beforeSize);
123
127
  if (beforeOffset) restoreStudioPathOffset(selection.element, beforeOffset);
128
+ crop?.revert();
124
129
  throw error;
125
130
  });
126
131
  },
@@ -92,7 +92,9 @@ describe("useElementLifecycleOps — deleting a canvas multi-selection", () => {
92
92
  it("reports a successful SDK delete as landed", async () => {
93
93
  const ops = mountDeleteOps({
94
94
  projectIdRef: { current: "p1" },
95
- onTrySdkDelete: vi.fn(async () => ({ status: "committed", version: "v1" }) as const),
95
+ onTrySdkDelete: vi.fn(
96
+ async () => ({ status: "committed", version: "v1", before: "", after: "" }) as const,
97
+ ),
96
98
  });
97
99
 
98
100
  const target = { ...selectionFor("a"), hfId: "hf-a" };