@hyperframes/studio 0.8.16 → 0.8.18

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 (55) hide show
  1. package/dist/assets/{hyperframes-player-iAIHATIw.js → hyperframes-player-DzRNJZAz.js} +1 -1
  2. package/dist/assets/{index-Cf-mbMRL.js → index-B4qse6wy.js} +1 -1
  3. package/dist/assets/index-BX3KHhGX.js +71 -0
  4. package/dist/assets/{index-D8o3ZIo2.js → index-F-PUkOVc.js} +128 -128
  5. package/dist/assets/{index-YmetcS6L.js → index-SGl0bb71.js} +1 -1
  6. package/dist/index.d.ts +1 -1
  7. package/dist/index.html +1 -1
  8. package/dist/index.js +1640 -1282
  9. package/dist/index.js.map +1 -1
  10. package/package.json +8 -7
  11. package/src/components/EditorShell.tsx +4 -0
  12. package/src/components/editor/DomEditCropHandles.test.tsx +1 -1
  13. package/src/components/editor/DomEditCropHandles.tsx +1 -1
  14. package/src/components/editor/DomEditOverlay.tsx +1 -1
  15. package/src/components/editor/DomEditSelectionChrome.tsx +1 -1
  16. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +38 -4
  17. package/src/components/editor/propertyPanelCommitField.tsx +1 -1
  18. package/src/components/editor/propertyPanelFlatLayoutSection.tsx +3 -3
  19. package/src/components/editor/propertyPanelFlatMaskInsetRows.tsx +1 -1
  20. package/src/components/editor/propertyPanelFlatMediaSection.tsx +1 -1
  21. package/src/components/editor/propertyPanelFlatPrimitives.tsx +1 -1
  22. package/src/components/editor/propertyPanelFlatStyleSections.tsx +8 -8
  23. package/src/components/editor/propertyPanelMediaSection.tsx +1 -1
  24. package/src/components/editor/propertyPanelPrimitives.tsx +1 -1
  25. package/src/components/editor/propertyPanelStyleSections.tsx +1 -1
  26. package/src/components/editor/propertyPanelTypes.ts +1 -1
  27. package/src/components/editor/useDomEditOverlayGestures.ts +8 -2
  28. package/src/components/editor/useInspectorGestureTransaction.ts +3 -3
  29. package/src/components/nle/useCompositionStack.test.tsx +69 -0
  30. package/src/components/nle/useCompositionStack.ts +8 -2
  31. package/src/hooks/domEditCommitRunner.ts +47 -0
  32. package/src/hooks/useDomEditPositionPatchCommit.test.tsx +116 -0
  33. package/src/hooks/useDomEditPositionPatchCommit.ts +6 -1
  34. package/src/hooks/useDomEditTextCommits.test.tsx +175 -21
  35. package/src/hooks/useDomEditTextCommits.ts +17 -9
  36. package/src/hooks/useDomEditWiring.ts +1 -1
  37. package/src/hooks/useDomGeometryCommits.test.tsx +1 -0
  38. package/src/hooks/useDomGeometryCommits.ts +10 -2
  39. package/src/hooks/useElementLifecycleOps.multiDelete.test.tsx +81 -31
  40. package/src/hooks/useElementLifecycleOps.ts +11 -5
  41. package/src/hooks/useGsapSelectionHandlers.ts +4 -2
  42. package/src/utils/studioUiPreferences.ts +11 -0
  43. package/src/webmcp/StudioAgentTools.tsx +46 -0
  44. package/src/webmcp/handles.test.ts +130 -0
  45. package/src/webmcp/handles.ts +129 -0
  46. package/src/webmcp/polyfill.test.ts +98 -0
  47. package/src/webmcp/polyfill.ts +60 -0
  48. package/src/webmcp/registrar.test.ts +150 -0
  49. package/src/webmcp/registrar.ts +115 -0
  50. package/src/webmcp/toolResult.ts +67 -0
  51. package/src/webmcp/tools/lookTools.test.ts +225 -0
  52. package/src/webmcp/tools/lookTools.ts +201 -0
  53. package/src/webmcp/types.ts +90 -0
  54. package/src/webmcp/useStudioAgentTools.test.tsx +221 -0
  55. package/src/webmcp/useStudioAgentTools.ts +119 -0
@@ -0,0 +1,116 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from "react";
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+ import type { DomEditSelection } from "../components/editor/domEditing";
5
+ import { DomEditSaveQueueOpenError } from "../utils/domEditSaveQueue";
6
+ import { mountReactHarness } from "./domSelectionTestHarness";
7
+ import { useDomEditPositionPatchCommit } from "./useDomEditPositionPatchCommit";
8
+
9
+ Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
10
+
11
+ let cleanup: (() => void) | null = null;
12
+
13
+ function selectionStub(): DomEditSelection {
14
+ const element = document.createElement("div");
15
+ element.id = "card";
16
+ return {
17
+ id: "card",
18
+ element,
19
+ label: "Card",
20
+ tagName: "div",
21
+ sourceFile: "index.html",
22
+ compositionPath: "index.html",
23
+ isCompositionHost: false,
24
+ isInsideLockedComposition: false,
25
+ boundingBox: { x: 0, y: 0, width: 100, height: 100 },
26
+ textContent: null,
27
+ dataAttributes: {},
28
+ inlineStyles: {},
29
+ computedStyles: {},
30
+ textFields: [],
31
+ capabilities: {
32
+ canSelect: true,
33
+ canEditStyles: true,
34
+ canCrop: true,
35
+ canMove: true,
36
+ canResize: true,
37
+ canApplyManualOffset: true,
38
+ canApplyManualSize: true,
39
+ canApplyManualRotation: true,
40
+ },
41
+ };
42
+ }
43
+
44
+ function renderCommit(params: Parameters<typeof useDomEditPositionPatchCommit>[0]) {
45
+ const captured: { commit: ReturnType<typeof useDomEditPositionPatchCommit> | null } = {
46
+ commit: null,
47
+ };
48
+ function Probe() {
49
+ captured.commit = useDomEditPositionPatchCommit(params);
50
+ return null;
51
+ }
52
+ const root = mountReactHarness(<Probe />);
53
+ cleanup = () => act(() => root.unmount());
54
+ if (!captured.commit) throw new Error("hook did not initialize");
55
+ return captured.commit;
56
+ }
57
+
58
+ function paramsWith(queueDomEditSave: (save: () => Promise<void>) => Promise<void>) {
59
+ const showToast = vi.fn();
60
+ return {
61
+ showToast,
62
+ params: {
63
+ activeCompPath: "index.html",
64
+ persistDomEditOperations: vi.fn().mockResolvedValue(undefined),
65
+ queueDomEditSave,
66
+ showToast,
67
+ },
68
+ };
69
+ }
70
+
71
+ const options = { label: "Move layer", coalesceKey: "path-offset:card" };
72
+
73
+ afterEach(() => {
74
+ cleanup?.();
75
+ cleanup = null;
76
+ vi.restoreAllMocks();
77
+ });
78
+
79
+ describe("useDomEditPositionPatchCommit", () => {
80
+ it("rejects when the save queue is paused, so the caller can revert its optimistic change", async () => {
81
+ const { showToast, params } = paramsWith(() => Promise.reject(new DomEditSaveQueueOpenError()));
82
+ const commit = renderCommit(params);
83
+
84
+ await act(async () => {
85
+ await expect(commit(selectionStub(), [], options)).rejects.toBeInstanceOf(
86
+ DomEditSaveQueueOpenError,
87
+ );
88
+ });
89
+
90
+ // No toast: the paused-save banner already tells the human, and one toast per
91
+ // blocked edit is what the original swallow existed to prevent.
92
+ expect(showToast).not.toHaveBeenCalled();
93
+ });
94
+
95
+ it("toasts and rejects on an ordinary save failure", async () => {
96
+ const { showToast, params } = paramsWith(() => Promise.reject(new Error("server said no")));
97
+ const commit = renderCommit(params);
98
+
99
+ await act(async () => {
100
+ await expect(commit(selectionStub(), [], options)).rejects.toThrow("server said no");
101
+ });
102
+
103
+ expect(showToast).toHaveBeenCalledWith("server said no");
104
+ });
105
+
106
+ it("resolves when the write lands", async () => {
107
+ const { showToast, params } = paramsWith((save) => save());
108
+ const commit = renderCommit(params);
109
+
110
+ await act(async () => {
111
+ await expect(commit(selectionStub(), [], options)).resolves.toBeUndefined();
112
+ });
113
+
114
+ expect(showToast).not.toHaveBeenCalled();
115
+ });
116
+ });
@@ -35,7 +35,12 @@ export function useDomEditPositionPatchCommit({
35
35
  skipRefresh: options.skipRefresh ?? true,
36
36
  });
37
37
  }).catch((error) => {
38
- if (error instanceof DomEditSaveQueueOpenError) return;
38
+ // A paused save queue is not worth a toast: the paused-save banner is
39
+ // already on screen, and one toast per blocked edit is what this branch
40
+ // exists to prevent. It still has to REJECT, though. Swallowing it
41
+ // resolved the commit, which skipped the caller's revert, so the element
42
+ // stayed where the drag put it while nothing reached the file.
43
+ if (error instanceof DomEditSaveQueueOpenError) throw error;
39
44
  showToast(error instanceof Error ? error.message : "Failed to save position");
40
45
  trackStudioSaveFailure({
41
46
  source: "dom_edit",
@@ -66,6 +66,42 @@ function selectionFor(element: HTMLElement): DomEditSelection {
66
66
  };
67
67
  }
68
68
 
69
+ /** A preview element inside a real iframe, which is where Studio's chrome expects to find it. */
70
+ function previewElement(
71
+ html: string,
72
+ id: string,
73
+ ): { iframe: HTMLIFrameElement; element: HTMLElement } {
74
+ const iframe = document.createElement("iframe");
75
+ document.body.append(iframe);
76
+ const doc = iframe.contentDocument;
77
+ if (!doc) throw new Error("expected iframe document");
78
+ doc.body.innerHTML = html;
79
+ const element = doc.getElementById(id);
80
+ const HTMLElementCtor = doc.defaultView?.HTMLElement;
81
+ if (!HTMLElementCtor || !(element instanceof HTMLElementCtor)) {
82
+ throw new Error("expected preview element");
83
+ }
84
+ return { iframe, element };
85
+ }
86
+
87
+ /** Hook params with nothing selected and a writer that succeeds; override what the test is about. */
88
+ function commitParams(
89
+ overrides: Partial<UseDomEditTextCommitsParams> = {},
90
+ ): UseDomEditTextCommitsParams {
91
+ return {
92
+ activeCompPath: "index.html",
93
+ previewIframeRef: { current: null },
94
+ showToast: vi.fn(),
95
+ domEditSelection: null,
96
+ applyDomSelection: vi.fn(),
97
+ refreshDomEditSelectionFromPreview: vi.fn(),
98
+ buildDomSelectionFromTarget: vi.fn(async () => null),
99
+ persistDomEditOperations: vi.fn().mockResolvedValue(undefined),
100
+ resolveImportedFontAsset: () => null,
101
+ ...overrides,
102
+ };
103
+ }
104
+
69
105
  let cleanup: (() => void) | null = null;
70
106
 
71
107
  function renderTextCommitHook(params: UseDomEditTextCommitsParams) {
@@ -89,16 +125,7 @@ afterEach(() => {
89
125
 
90
126
  describe("useDomEditTextCommits", () => {
91
127
  it("does not let a stale failed fields commit revert newer text", async () => {
92
- const iframe = document.createElement("iframe");
93
- document.body.append(iframe);
94
- const doc = iframe.contentDocument;
95
- if (!doc) throw new Error("expected iframe document");
96
- doc.body.innerHTML = '<div id="card">Original</div>';
97
- const element = doc.getElementById("card");
98
- const HTMLElementCtor = doc.defaultView?.HTMLElement;
99
- if (!HTMLElementCtor || !(element instanceof HTMLElementCtor)) {
100
- throw new Error("expected preview element");
101
- }
128
+ const { iframe, element } = previewElement("<div id='card'>Original</div>", "card");
102
129
  vi.spyOn(console, "warn").mockImplementation(() => {});
103
130
  const selection = selectionFor(element);
104
131
  const stalePersist = createDeferred<void>();
@@ -106,17 +133,13 @@ describe("useDomEditTextCommits", () => {
106
133
  .fn()
107
134
  .mockImplementationOnce(() => stalePersist.promise)
108
135
  .mockResolvedValueOnce(undefined);
109
- const hook = renderTextCommitHook({
110
- activeCompPath: "index.html",
111
- previewIframeRef: { current: iframe },
112
- showToast: vi.fn(),
113
- domEditSelection: selection,
114
- applyDomSelection: vi.fn(),
115
- refreshDomEditSelectionFromPreview: vi.fn(),
116
- buildDomSelectionFromTarget: vi.fn(async () => null),
117
- persistDomEditOperations,
118
- resolveImportedFontAsset: () => null,
119
- });
136
+ const hook = renderTextCommitHook(
137
+ commitParams({
138
+ previewIframeRef: { current: iframe },
139
+ domEditSelection: selection,
140
+ persistDomEditOperations,
141
+ }),
142
+ );
120
143
 
121
144
  let staleCommit: Promise<void> | undefined;
122
145
  act(() => {
@@ -132,4 +155,135 @@ describe("useDomEditTextCommits", () => {
132
155
 
133
156
  expect(element.innerHTML).toBe("Newest");
134
157
  });
158
+
159
+ it("reports persist failure from a style commit instead of resolving silently", async () => {
160
+ const { iframe, element } = previewElement("<div id='card'>Original</div>", "card");
161
+ const selection = selectionFor(element);
162
+ const showToast = vi.fn();
163
+ const hook = renderTextCommitHook(
164
+ commitParams({
165
+ previewIframeRef: { current: iframe },
166
+ showToast,
167
+ domEditSelection: selection,
168
+ persistDomEditOperations: vi.fn().mockRejectedValue(new Error("server said no")),
169
+ }),
170
+ );
171
+
172
+ let outcome: unknown;
173
+ await act(async () => {
174
+ outcome = await hook.handleDomStyleCommit("color", "red");
175
+ });
176
+
177
+ expect(outcome).toEqual({ ok: false, reason: "persist-failed" });
178
+ // The human-facing behaviour must be unchanged: still toasts, still reverts.
179
+ expect(showToast).toHaveBeenCalled();
180
+ expect(element.style.getPropertyValue("color")).toBe("");
181
+ });
182
+
183
+ it("reports a successful style commit", async () => {
184
+ const { iframe, element } = previewElement("<div id='card'>Original</div>", "card");
185
+ const selection = selectionFor(element);
186
+ const hook = renderTextCommitHook(
187
+ commitParams({ previewIframeRef: { current: iframe }, domEditSelection: selection }),
188
+ );
189
+
190
+ let outcome: unknown;
191
+ await act(async () => {
192
+ outcome = await hook.handleDomStyleCommit("color", "red");
193
+ });
194
+
195
+ expect(outcome).toEqual({ ok: true });
196
+ });
197
+
198
+ it("declines a style commit with no selection, without reaching the writer", async () => {
199
+ const persistDomEditOperations = vi.fn().mockResolvedValue(undefined);
200
+ const hook = renderTextCommitHook(
201
+ commitParams({
202
+ domEditSelection: null,
203
+ persistDomEditOperations,
204
+ }),
205
+ );
206
+
207
+ let outcome: unknown;
208
+ await act(async () => {
209
+ outcome = await hook.handleDomStyleCommit("color", "red");
210
+ });
211
+
212
+ expect(outcome).toEqual({ ok: false, reason: "no-selection" });
213
+ expect(persistDomEditOperations).not.toHaveBeenCalled();
214
+ });
215
+
216
+ it("declines a style commit for a manual-geometry property", async () => {
217
+ const persistDomEditOperations = vi.fn().mockResolvedValue(undefined);
218
+ const { element } = previewElement("<div id='card'>Original</div>", "card");
219
+ const hook = renderTextCommitHook(
220
+ commitParams({
221
+ domEditSelection: selectionFor(element),
222
+ persistDomEditOperations,
223
+ }),
224
+ );
225
+
226
+ let outcome: unknown;
227
+ await act(async () => {
228
+ // `left` is a manual-geometry property the style path deliberately refuses.
229
+ outcome = await hook.handleDomStyleCommit("left", "10px");
230
+ });
231
+
232
+ expect(outcome).toEqual({ ok: false, reason: "geometry-property" });
233
+ expect(persistDomEditOperations).not.toHaveBeenCalled();
234
+ });
235
+
236
+ it("declines a style commit when the selection cannot edit styles", async () => {
237
+ const persistDomEditOperations = vi.fn().mockResolvedValue(undefined);
238
+ const { element } = previewElement("<div id='card'>Original</div>", "card");
239
+ const locked = selectionFor(element);
240
+ locked.capabilities = { ...locked.capabilities, canEditStyles: false };
241
+ const hook = renderTextCommitHook(
242
+ commitParams({
243
+ domEditSelection: locked,
244
+ persistDomEditOperations,
245
+ }),
246
+ );
247
+
248
+ let outcome: unknown;
249
+ await act(async () => {
250
+ outcome = await hook.handleDomStyleCommit("color", "red");
251
+ });
252
+
253
+ expect(outcome).toEqual({ ok: false, reason: "styles-not-editable" });
254
+ expect(persistDomEditOperations).not.toHaveBeenCalled();
255
+ });
256
+
257
+ it("reports persist failure from a text commit instead of resolving silently", async () => {
258
+ const { iframe, element } = previewElement("<div id='card'>Original</div>", "card");
259
+ const selection = selectionFor(element);
260
+ const hook = renderTextCommitHook(
261
+ commitParams({
262
+ previewIframeRef: { current: iframe },
263
+ domEditSelection: selection,
264
+ persistDomEditOperations: vi.fn().mockRejectedValue(new Error("server said no")),
265
+ }),
266
+ );
267
+
268
+ let outcome: unknown;
269
+ await act(async () => {
270
+ outcome = await hook.handleDomTextCommit("Updated");
271
+ });
272
+
273
+ expect(outcome).toEqual({ ok: false, reason: "persist-failed" });
274
+ expect(element.innerHTML).toBe("Original");
275
+ });
276
+
277
+ it("reports a text commit declined for an unselected target", async () => {
278
+ const persistDomEditOperations = vi.fn().mockResolvedValue(undefined);
279
+ const hook = renderTextCommitHook(commitParams({ persistDomEditOperations }));
280
+
281
+ let outcome: unknown;
282
+ await act(async () => {
283
+ outcome = await hook.handleDomTextCommit("Updated");
284
+ });
285
+
286
+ expect(outcome).toEqual({ ok: false, reason: "no-selection" });
287
+ expect(persistDomEditOperations).not.toHaveBeenCalled();
288
+ });
135
289
  });
@@ -30,7 +30,10 @@ import { reportDomEditPersistFailure } from "./domEditPersistFailure";
30
30
  import {
31
31
  bumpDomEditCommitMapVersion,
32
32
  bumpDomEditCommitVersion,
33
+ domEditCommitDeclined,
33
34
  runDomEditCommit,
35
+ runReportedDomEditCommit,
36
+ type DomEditCommitOutcome,
34
37
  } from "./domEditCommitRunner";
35
38
  import { useDomEditAttributeCommits } from "./useDomEditAttributeCommits";
36
39
  import type { InlineTextEditCommit } from "./useInlineTextEdit";
@@ -186,10 +189,13 @@ export function useDomEditTextCommits({
186
189
  });
187
190
 
188
191
  const handleDomStyleCommit = useCallback(
189
- async (property: string, value: string) => {
190
- if (!domEditSelection) return;
191
- if (isManualGeometryStyleProperty(property)) return;
192
- if (!domEditSelection.capabilities.canEditStyles) return;
192
+ async (property: string, value: string): Promise<DomEditCommitOutcome> => {
193
+ if (!domEditSelection) return domEditCommitDeclined("no-selection");
194
+ if (isManualGeometryStyleProperty(property))
195
+ return domEditCommitDeclined("geometry-property");
196
+ if (!domEditSelection.capabilities.canEditStyles) {
197
+ return domEditCommitDeclined("styles-not-editable");
198
+ }
193
199
  const styleCommitKey = `${getDomEditTargetKey(domEditSelection)}:${property}`;
194
200
  const isLatestStyleCommit = bumpDomEditCommitMapVersion(
195
201
  domStyleCommitVersionRef.current,
@@ -210,7 +216,7 @@ export function useDomEditTextCommits({
210
216
  // element in-browser immediately, so a reload would only cost a black blink.
211
217
  const skipRefresh = true;
212
218
 
213
- await runDomEditCommit({
219
+ return runReportedDomEditCommit({
214
220
  capture: () => {
215
221
  if (!doc) return;
216
222
  const el = findElementForSelection(doc, domEditSelection, activeCompPath);
@@ -267,9 +273,11 @@ export function useDomEditTextCommits({
267
273
  );
268
274
 
269
275
  const handleDomTextCommit = useCallback(
270
- async (value: string, fieldKey?: string) => {
271
- if (!domEditSelection) return;
272
- if (!isTextEditableSelection(domEditSelection)) return;
276
+ async (value: string, fieldKey?: string): Promise<DomEditCommitOutcome> => {
277
+ if (!domEditSelection) return domEditCommitDeclined("no-selection");
278
+ if (!isTextEditableSelection(domEditSelection)) {
279
+ return domEditCommitDeclined("not-text-editable");
280
+ }
273
281
  const isLatestTextCommit = bumpDomEditCommitVersion(domTextCommitVersionRef);
274
282
  const nextTextFields = buildNextDomTextFields(domEditSelection.textFields, value, fieldKey);
275
283
  const textCommit = planDomTextCommit(domEditSelection.textFields, nextTextFields, value);
@@ -278,7 +286,7 @@ export function useDomEditTextCommits({
278
286
  let editedElement: HTMLElement | null = null;
279
287
  let previousInnerHtml: string | null = null;
280
288
 
281
- await runDomEditCommit({
289
+ return runReportedDomEditCommit({
282
290
  capture: () => {
283
291
  if (!doc) return;
284
292
  const el = findElementForSelection(doc, domEditSelection, activeCompPath);
@@ -107,7 +107,7 @@ export interface UseDomEditWiringParams {
107
107
  resolvedFromValues?: Record<string, number | string>,
108
108
  ) => Promise<void>;
109
109
  removeAllKeyframes: (sel: DomEditSelection, animId: string) => Promise<void>;
110
- handleDomManualEditsReset: (sel: DomEditSelection) => void;
110
+ handleDomManualEditsReset: (sel: DomEditSelection) => Promise<void>;
111
111
  }
112
112
 
113
113
  // fallow-ignore-next-line complexity
@@ -52,6 +52,7 @@ describe("useDomGeometryCommits rollback", () => {
52
52
  commits!.handleDomBoxSizeCommit(selection, { width: 200, height: 160 }, { x: 30, y: 40 }),
53
53
  ).rejects.toBe(failure);
54
54
  await expect(commits!.handleDomRotationCommit(selection, { angle: 45 })).rejects.toBe(failure);
55
+ await expect(commits!.handleDomManualEditsReset(selection)).rejects.toBe(failure);
55
56
 
56
57
  expect(readStudioPathOffset(element)).toEqual({ x: 10, y: 20 });
57
58
  expect(readStudioBoxSize(element)).toEqual({ width: 100, height: 80 });
@@ -130,6 +130,9 @@ export function useDomGeometryCommits({
130
130
  const handleDomManualEditsReset = useCallback(
131
131
  (selection: DomEditSelection) => {
132
132
  const element = selection.element;
133
+ const beforeOffset = captureStudioPathOffset(element);
134
+ const beforeSize = captureStudioBoxSize(element);
135
+ const beforeRotation = captureStudioRotation(element);
133
136
  const clearPatches = [
134
137
  ...buildClearPathOffsetPatches(element),
135
138
  ...buildClearBoxSizePatches(element),
@@ -139,11 +142,16 @@ export function useDomGeometryCommits({
139
142
  clearStudioBoxSize(element);
140
143
  clearStudioRotation(element);
141
144
  // skipRefresh:false triggers reloadPreview() which re-syncs selection on load
142
- void commitPositionPatchToHtml(selection, clearPatches, {
145
+ return commitPositionPatchToHtml(selection, clearPatches, {
143
146
  label: "Reset layer edits",
144
147
  coalesceKey: `manual-reset:${getDomEditTargetKey(selection)}`,
145
148
  skipRefresh: false,
146
- }).catch(() => undefined);
149
+ }).catch((error) => {
150
+ restoreStudioPathOffset(element, beforeOffset);
151
+ restoreStudioBoxSize(element, beforeSize);
152
+ restoreStudioRotation(element, beforeRotation);
153
+ throw error;
154
+ });
147
155
  },
148
156
  [commitPositionPatchToHtml],
149
157
  );
@@ -6,6 +6,8 @@ import { useElementLifecycleOps } from "./useElementLifecycleOps";
6
6
  import { makeLifecycleOpsParams } from "./elementLifecycleOpsTestUtils";
7
7
  import { mountReactHarness, makeSelection } from "./domSelectionTestHarness";
8
8
 
9
+ Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
10
+
9
11
  function selectionFor(id: string) {
10
12
  const el = document.createElement("div");
11
13
  el.id = id;
@@ -13,28 +15,51 @@ function selectionFor(id: string) {
13
15
  return { ...makeSelection(id, el), sourceFile: "index.html" };
14
16
  }
15
17
 
18
+ function mountDeleteOps(overrides: Partial<Parameters<typeof useElementLifecycleOps>[0]> = {}) {
19
+ const captured: { ops: ReturnType<typeof useElementLifecycleOps> | null } = { ops: null };
20
+ function Probe() {
21
+ captured.ops = useElementLifecycleOps(
22
+ makeLifecycleOpsParams({
23
+ commitDomEditPatchBatches: vi.fn(async () => ({ ok: true }) as never),
24
+ ...overrides,
25
+ }),
26
+ );
27
+ return null;
28
+ }
29
+ mountReactHarness(<Probe />);
30
+ if (!captured.ops) throw new Error("hook did not initialize");
31
+ return captured.ops;
32
+ }
33
+
16
34
  describe("useElementLifecycleOps — deleting a canvas multi-selection", () => {
17
35
  const removed: string[] = [];
18
36
  const requests: string[] = [];
19
37
  let changes = true;
38
+ let removeOk = true;
20
39
 
21
40
  beforeEach(() => {
22
41
  removed.length = 0;
23
42
  requests.length = 0;
24
43
  changes = true;
44
+ removeOk = true;
25
45
  vi.stubGlobal(
26
46
  "fetch",
27
47
  vi.fn(async (url: string, init?: RequestInit) => {
28
- requests.push(String(url));
48
+ const requestUrl = String(url);
49
+ requests.push(requestUrl);
29
50
  const body = JSON.parse(String(init?.body ?? "{}")) as {
30
51
  targets?: { id?: string; selector?: string }[];
31
52
  };
32
- for (const target of body.targets ?? []) {
33
- const key = target.id ?? target.selector;
34
- if (key) removed.push(key);
35
- }
53
+ const keys = (body.targets ?? [])
54
+ .map((target) => target.id ?? target.selector)
55
+ .filter((key): key is string => key !== undefined);
56
+ removed.push(...keys);
57
+ const isRemove = requestUrl.includes("/file-mutations/remove-elements/");
58
+ const status = isRemove && !removeOk ? 500 : 200;
36
59
  return {
37
- ok: true,
60
+ ok: status === 200,
61
+ status,
62
+ text: async () => (status === 200 ? "" : "server said no"),
38
63
  json: async () => ({ changed: changes, content: "<html></html>" }),
39
64
  } as unknown as Response;
40
65
  }),
@@ -48,21 +73,12 @@ describe("useElementLifecycleOps — deleting a canvas multi-selection", () => {
48
73
  it("removes every selected element, not just the first", async () => {
49
74
  // The reported bug: select several elements on the canvas, press Delete, and
50
75
  // one disappears while the rest stay — still drawn as selected.
51
- let ops: ReturnType<typeof useElementLifecycleOps> | null = null;
52
- function Probe() {
53
- ops = useElementLifecycleOps(
54
- makeLifecycleOpsParams({
55
- commitDomEditPatchBatches: vi.fn(async () => ({ ok: true }) as never),
56
- projectIdRef: { current: "p1" },
57
- }),
58
- );
59
- return null;
60
- }
61
- mountReactHarness(<Probe />);
76
+ const ops = mountDeleteOps({ projectIdRef: { current: "p1" } });
62
77
 
63
78
  const selections = ["a", "b", "c"].map(selectionFor);
79
+ let outcome: unknown;
64
80
  await act(async () => {
65
- await ops!.handleDomEditElementsDelete(selections);
81
+ outcome = await ops.handleDomEditElementsDelete(selections);
66
82
  });
67
83
 
68
84
  // The defect: only the first was ever removed.
@@ -70,6 +86,49 @@ describe("useElementLifecycleOps — deleting a canvas multi-selection", () => {
70
86
  // And one request for the selection, not one per member: a canvas selection
71
87
  // runs to hundreds, and a round trip each made Delete look like a no-op.
72
88
  expect(requests.filter((url) => url.includes("remove-elements"))).toHaveLength(1);
89
+ expect(outcome).toEqual({ ok: true });
90
+ });
91
+
92
+ it("reports a successful SDK delete as landed", async () => {
93
+ const ops = mountDeleteOps({
94
+ projectIdRef: { current: "p1" },
95
+ onTrySdkDelete: vi.fn(async () => ({ status: "committed", version: "v1" }) as const),
96
+ });
97
+
98
+ const target = { ...selectionFor("a"), hfId: "hf-a" };
99
+ let outcome: unknown;
100
+ await act(async () => {
101
+ outcome = await ops.handleDomEditElementsDelete([target]);
102
+ });
103
+
104
+ expect(outcome).toEqual({ ok: true });
105
+ expect(requests.some((url) => url.includes("remove-elements"))).toBe(false);
106
+ });
107
+
108
+ it("reports missing project and selection without starting a request", async () => {
109
+ const projectIdRef = { current: null as string | null };
110
+ const ops = mountDeleteOps({ projectIdRef });
111
+
112
+ await expect(ops.handleDomEditElementsDelete([selectionFor("a")])).resolves.toEqual({
113
+ ok: false,
114
+ reason: "no-project",
115
+ });
116
+ projectIdRef.current = "p1";
117
+ await expect(ops.handleDomEditElementsDelete([])).resolves.toEqual({
118
+ ok: false,
119
+ reason: "no-selection",
120
+ });
121
+ expect(requests).toEqual([]);
122
+ });
123
+
124
+ it("reports an HTTP write failure instead of only toasting", async () => {
125
+ removeOk = false;
126
+ const ops = mountDeleteOps({ projectIdRef: { current: "p1" } });
127
+
128
+ await expect(ops.handleDomEditElementsDelete([selectionFor("a")])).resolves.toEqual({
129
+ ok: false,
130
+ reason: "persist-failed",
131
+ });
73
132
  });
74
133
 
75
134
  it("says so when the preview is stale instead of claiming a delete", async () => {
@@ -78,23 +137,14 @@ describe("useElementLifecycleOps — deleting a canvas multi-selection", () => {
78
137
  // nothing at all, with nothing on screen to explain it.
79
138
  changes = false;
80
139
  const showToast = vi.fn();
81
- let ops: ReturnType<typeof useElementLifecycleOps> | null = null;
82
- function Probe() {
83
- ops = useElementLifecycleOps(
84
- makeLifecycleOpsParams({
85
- commitDomEditPatchBatches: vi.fn(async () => ({ ok: true }) as never),
86
- projectIdRef: { current: "p1" },
87
- showToast,
88
- }),
89
- );
90
- return null;
91
- }
92
- mountReactHarness(<Probe />);
140
+ const ops = mountDeleteOps({ projectIdRef: { current: "p1" }, showToast });
93
141
 
142
+ let outcome: unknown;
94
143
  await act(async () => {
95
- await ops!.handleDomEditElementsDelete([selectionFor("a")]);
144
+ outcome = await ops.handleDomEditElementsDelete([selectionFor("a")]);
96
145
  });
97
146
 
98
147
  expect(showToast.mock.calls.flat().join(" ")).toContain("out of date");
148
+ expect(outcome).toEqual({ ok: false, reason: "preview-stale" });
99
149
  });
100
150
  });