@hyperframes/studio 0.8.16 → 0.8.17
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.
- package/dist/assets/{hyperframes-player-iAIHATIw.js → hyperframes-player-DejBDgXB.js} +1 -1
- package/dist/assets/index-BX3KHhGX.js +71 -0
- package/dist/assets/{index-D8o3ZIo2.js → index-CU6o8PuW.js} +128 -128
- package/dist/assets/{index-Cf-mbMRL.js → index-CklNVmi3.js} +1 -1
- package/dist/assets/{index-YmetcS6L.js → index-uu4Zd3BU.js} +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.html +1 -1
- package/dist/index.js +1638 -1280
- package/dist/index.js.map +1 -1
- package/package.json +8 -7
- package/src/components/EditorShell.tsx +4 -0
- package/src/components/editor/DomEditCropHandles.test.tsx +1 -1
- package/src/components/editor/DomEditCropHandles.tsx +1 -1
- package/src/components/editor/DomEditOverlay.tsx +1 -1
- package/src/components/editor/DomEditSelectionChrome.tsx +1 -1
- package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +38 -4
- package/src/components/editor/propertyPanelCommitField.tsx +1 -1
- package/src/components/editor/propertyPanelFlatLayoutSection.tsx +3 -3
- package/src/components/editor/propertyPanelFlatMaskInsetRows.tsx +1 -1
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +1 -1
- package/src/components/editor/propertyPanelFlatPrimitives.tsx +1 -1
- package/src/components/editor/propertyPanelFlatStyleSections.tsx +8 -8
- package/src/components/editor/propertyPanelMediaSection.tsx +1 -1
- package/src/components/editor/propertyPanelPrimitives.tsx +1 -1
- package/src/components/editor/propertyPanelStyleSections.tsx +1 -1
- package/src/components/editor/propertyPanelTypes.ts +1 -1
- package/src/components/editor/useDomEditOverlayGestures.ts +8 -2
- package/src/components/editor/useInspectorGestureTransaction.ts +3 -3
- package/src/hooks/domEditCommitRunner.ts +47 -0
- package/src/hooks/useDomEditPositionPatchCommit.test.tsx +116 -0
- package/src/hooks/useDomEditPositionPatchCommit.ts +6 -1
- package/src/hooks/useDomEditTextCommits.test.tsx +175 -21
- package/src/hooks/useDomEditTextCommits.ts +17 -9
- package/src/hooks/useDomEditWiring.ts +1 -1
- package/src/hooks/useDomGeometryCommits.test.tsx +1 -0
- package/src/hooks/useDomGeometryCommits.ts +10 -2
- package/src/hooks/useElementLifecycleOps.multiDelete.test.tsx +81 -31
- package/src/hooks/useElementLifecycleOps.ts +11 -5
- package/src/hooks/useGsapSelectionHandlers.ts +4 -2
- package/src/utils/studioUiPreferences.ts +11 -0
- package/src/webmcp/StudioAgentTools.tsx +46 -0
- package/src/webmcp/handles.test.ts +130 -0
- package/src/webmcp/handles.ts +129 -0
- package/src/webmcp/polyfill.test.ts +98 -0
- package/src/webmcp/polyfill.ts +60 -0
- package/src/webmcp/registrar.test.ts +150 -0
- package/src/webmcp/registrar.ts +115 -0
- package/src/webmcp/toolResult.ts +67 -0
- package/src/webmcp/tools/lookTools.test.ts +225 -0
- package/src/webmcp/tools/lookTools.ts +201 -0
- package/src/webmcp/types.ts +90 -0
- package/src/webmcp/useStudioAgentTools.test.tsx +221 -0
- package/src/webmcp/useStudioAgentTools.ts +119 -0
|
@@ -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 =
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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))
|
|
192
|
-
|
|
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
|
-
|
|
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))
|
|
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
|
-
|
|
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
|
-
|
|
145
|
+
return commitPositionPatchToHtml(selection, clearPatches, {
|
|
143
146
|
label: "Reset layer edits",
|
|
144
147
|
coalesceKey: `manual-reset:${getDomEditTargetKey(selection)}`,
|
|
145
148
|
skipRefresh: false,
|
|
146
|
-
}).catch(() =>
|
|
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
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
});
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
type LayerRevealCommitOwnership,
|
|
21
21
|
} from "../components/editor/useLayerRevealOverride";
|
|
22
22
|
import type { CommitDomEditPatchBatches, DomEditPatchBatch } from "./domEditCommitTypes";
|
|
23
|
+
import { domEditCommitDeclined, type DomEditCommitOutcome } from "./domEditCommitRunner";
|
|
23
24
|
import { cutoverCommittedOrThrow, type CutoverResult } from "../utils/sdkCutover";
|
|
24
25
|
import { studioWriteHeaders } from "../utils/studioFileVersion";
|
|
25
26
|
|
|
@@ -87,11 +88,11 @@ export function useElementLifecycleOps({
|
|
|
87
88
|
// fallow-ignore-next-line complexity
|
|
88
89
|
const handleDomEditElementsDelete = useCallback(
|
|
89
90
|
// fallow-ignore-next-line complexity
|
|
90
|
-
async (selections: DomEditSelection[]) => {
|
|
91
|
+
async (selections: DomEditSelection[]): Promise<DomEditCommitOutcome> => {
|
|
91
92
|
const pid = projectIdRef.current;
|
|
92
|
-
if (!pid) return;
|
|
93
|
+
if (!pid) return domEditCommitDeclined("no-project");
|
|
93
94
|
const [selection] = selections;
|
|
94
|
-
if (!selection) return;
|
|
95
|
+
if (!selection) return domEditCommitDeclined("no-selection");
|
|
95
96
|
const label =
|
|
96
97
|
selections.length === 1
|
|
97
98
|
? selection.label || selection.id || selection.selector || selection.tagName
|
|
@@ -141,7 +142,7 @@ export function useElementLifecycleOps({
|
|
|
141
142
|
`Deleted ${label}. Use Undo to restore ${sameFile.length === 1 ? "it" : "them"}.`,
|
|
142
143
|
"info",
|
|
143
144
|
);
|
|
144
|
-
return;
|
|
145
|
+
return { ok: true } as const;
|
|
145
146
|
}
|
|
146
147
|
}
|
|
147
148
|
|
|
@@ -174,7 +175,8 @@ export function useElementLifecycleOps({
|
|
|
174
175
|
// matching at all means the preview is describing a document the file
|
|
175
176
|
// does not have — say so rather than reporting a delete that happened.
|
|
176
177
|
reloadPreview();
|
|
177
|
-
|
|
178
|
+
showToast("Nothing to delete, the preview was out of date. Try again.");
|
|
179
|
+
return domEditCommitDeclined("preview-stale");
|
|
178
180
|
}
|
|
179
181
|
const patchedContent =
|
|
180
182
|
typeof removeData.content === "string" ? removeData.content : originalContent;
|
|
@@ -208,9 +210,13 @@ export function useElementLifecycleOps({
|
|
|
208
210
|
`Deleted ${label}. Use Undo to restore ${sameFile.length === 1 ? "it" : "them"}.`,
|
|
209
211
|
"info",
|
|
210
212
|
);
|
|
213
|
+
return { ok: true } as const;
|
|
211
214
|
} catch (error) {
|
|
212
215
|
const message = error instanceof Error ? error.message : "Failed to delete element";
|
|
213
216
|
showToast(message);
|
|
217
|
+
// The toast is what tells the human. The returned outcome is what tells
|
|
218
|
+
// a caller that has no screen to read.
|
|
219
|
+
return domEditCommitDeclined("persist-failed");
|
|
214
220
|
}
|
|
215
221
|
},
|
|
216
222
|
[
|
|
@@ -111,7 +111,7 @@ export function useGsapSelectionHandlers({
|
|
|
111
111
|
) => Promise<void>;
|
|
112
112
|
removeAllKeyframes: (sel: DomEditSelection, animId: string) => Promise<void>;
|
|
113
113
|
|
|
114
|
-
handleDomManualEditsReset: (sel: DomEditSelection) => void
|
|
114
|
+
handleDomManualEditsReset: (sel: DomEditSelection) => Promise<void>;
|
|
115
115
|
selectedGsapAnimations: GsapAnimation[];
|
|
116
116
|
showToast: (message: string, tone?: "error" | "info") => void;
|
|
117
117
|
}) {
|
|
@@ -230,7 +230,9 @@ export function useGsapSelectionHandlers({
|
|
|
230
230
|
},
|
|
231
231
|
);
|
|
232
232
|
if (domEditSelection.element.hasAttribute("data-hf-studio-path-offset")) {
|
|
233
|
-
|
|
233
|
+
// The reset owns rollback and the position commit already owns user and
|
|
234
|
+
// telemetry reporting. This is only the fire-and-forget UI boundary.
|
|
235
|
+
void handleDomManualEditsReset(domEditSelection).catch(() => undefined);
|
|
234
236
|
}
|
|
235
237
|
},
|
|
236
238
|
[domEditSelection, addGsapAnimation, handleDomManualEditsReset, trackGsapHandlerFailure],
|
|
@@ -34,6 +34,14 @@ export interface StudioUiPreferences {
|
|
|
34
34
|
timelineZoomMode?: "fit" | "manual";
|
|
35
35
|
/** Manual timeline zoom percent, paired with `timelineZoomMode: "manual"`. */
|
|
36
36
|
timelineManualZoomPercent?: number;
|
|
37
|
+
/**
|
|
38
|
+
* Expose Studio's editing capabilities to an agentic browser as WebMCP tools.
|
|
39
|
+
* Absent means on: the browser still gates every actual call behind its own
|
|
40
|
+
* permission prompt, so "registered" is not "reachable without consent".
|
|
41
|
+
* Changes take effect on the next Studio reload because registration is
|
|
42
|
+
* intentionally scoped to one mount.
|
|
43
|
+
*/
|
|
44
|
+
agentToolsEnabled?: boolean;
|
|
37
45
|
}
|
|
38
46
|
|
|
39
47
|
const STUDIO_UI_PREFERENCES_KEY = "hf-studio-ui-preferences";
|
|
@@ -140,6 +148,9 @@ function readStorage(storage: Storage | null): StudioUiPreferences {
|
|
|
140
148
|
) {
|
|
141
149
|
preferences.timelineManualZoomPercent = parsed.timelineManualZoomPercent;
|
|
142
150
|
}
|
|
151
|
+
if (typeof parsed.agentToolsEnabled === "boolean") {
|
|
152
|
+
preferences.agentToolsEnabled = parsed.agentToolsEnabled;
|
|
153
|
+
}
|
|
143
154
|
return preferences;
|
|
144
155
|
} catch {
|
|
145
156
|
return {};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { useCallback } from "react";
|
|
2
|
+
import { useDomEditSelectionContext } from "../contexts/DomEditContext";
|
|
3
|
+
import { useStudioShellContext } from "../contexts/StudioContext";
|
|
4
|
+
import { usePlayerStore } from "../player";
|
|
5
|
+
import { useStudioAgentTools } from "./useStudioAgentTools";
|
|
6
|
+
import type { StudioLookSnapshot } from "./tools/lookTools";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Mounts Studio's WebMCP tool surface. Renders nothing.
|
|
10
|
+
*
|
|
11
|
+
* Lives inside `EditorShell` rather than `App` for two reasons: the DomEdit
|
|
12
|
+
* contexts are only readable below `DomEditProvider`, which `App` renders, and
|
|
13
|
+
* `App.tsx` sits three lines under the 600-line cap.
|
|
14
|
+
*
|
|
15
|
+
* The player store is read IMPERATIVELY through `getState()` inside the
|
|
16
|
+
* snapshot callback rather than subscribed to. Subscribing to `currentTime`
|
|
17
|
+
* would re-render this component on every animation frame during playback for
|
|
18
|
+
* a value nothing here displays.
|
|
19
|
+
*/
|
|
20
|
+
export function StudioAgentTools() {
|
|
21
|
+
const { projectId, activeCompPath, editHistory } = useStudioShellContext();
|
|
22
|
+
const { domEditSelection, selectedGsapAnimations } = useDomEditSelectionContext();
|
|
23
|
+
|
|
24
|
+
const getSnapshot = useCallback((): StudioLookSnapshot => {
|
|
25
|
+
const player = usePlayerStore.getState();
|
|
26
|
+
return {
|
|
27
|
+
projectId,
|
|
28
|
+
compositionPath: activeCompPath,
|
|
29
|
+
currentTime: player.currentTime,
|
|
30
|
+
duration: player.duration,
|
|
31
|
+
isPlaying: player.isPlaying,
|
|
32
|
+
elements: player.elements,
|
|
33
|
+
selection: domEditSelection,
|
|
34
|
+
selectionAnimationCount: selectedGsapAnimations.length,
|
|
35
|
+
history: {
|
|
36
|
+
canUndo: editHistory.canUndo,
|
|
37
|
+
canRedo: editHistory.canRedo,
|
|
38
|
+
undoLabel: editHistory.undoLabel ?? null,
|
|
39
|
+
redoLabel: editHistory.redoLabel ?? null,
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}, [projectId, activeCompPath, domEditSelection, selectedGsapAnimations, editHistory]);
|
|
43
|
+
|
|
44
|
+
useStudioAgentTools({ getSnapshot });
|
|
45
|
+
return null;
|
|
46
|
+
}
|