@hyperframes/studio 0.8.23 → 0.8.25
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-oZ6g4tHJ.js → hyperframes-player-CTP_00ix.js} +1 -1
- package/dist/assets/{index-UyvIa_cE.js → index-Bjb4YqS_.js} +1 -1
- package/dist/assets/{index-Tiu74FPg.js → index-DSk_ALFr.js} +218 -218
- package/dist/assets/{index-DW-H7Y2b.js → index-Di-8Jxqc.js} +1 -1
- package/dist/assets/index-DnRfAiK2.css +1 -0
- package/dist/{chunk-6BT6DTB4.js → chunk-A7S5SGNA.js} +2 -2
- package/dist/chunk-A7S5SGNA.js.map +1 -0
- package/dist/{domEditingLayers-URA7BLWE.js → domEditingLayers-JMMVOGTO.js} +2 -2
- package/dist/index.d.ts +4 -0
- package/dist/index.html +2 -2
- package/dist/index.js +6206 -5011
- package/dist/index.js.map +1 -1
- package/package.json +8 -7
- package/src/components/editor/TopologyLens.test.tsx +294 -0
- package/src/components/editor/TopologyLens.tsx +206 -0
- package/src/components/editor/domEditOverlayGeometry.ts +14 -0
- package/src/components/editor/domEditOverlayGeometryBatch.test.ts +18 -0
- package/src/components/editor/domEditingLayers.test.ts +9 -0
- package/src/components/editor/domEditingLayers.ts +8 -8
- package/src/components/editor/topologyLensGeometry.test.ts +44 -0
- package/src/components/editor/topologyLensGeometry.ts +119 -0
- package/src/components/editor/topologyLensState.test.ts +116 -0
- package/src/components/editor/topologyLensState.ts +72 -0
- package/src/components/nle/PreviewOverlays.topologyLens.test.tsx +126 -0
- package/src/components/nle/PreviewOverlays.tsx +24 -18
- package/src/components/sidebar/CompositionsTab.drag.test.tsx +16 -0
- package/src/components/sidebar/CompositionsTab.tsx +10 -3
- package/src/components/ui/HyperframesLoader.tsx +3 -48
- package/src/components/ui/HyperframesMark.tsx +53 -0
- package/src/contexts/DomEditContext.tsx +12 -0
- package/src/hooks/domEditCommitRunner.test.ts +59 -0
- package/src/hooks/domEditCommitRunner.ts +55 -20
- package/src/hooks/domEditCommitTypes.ts +8 -1
- package/src/hooks/domEditTextCommitPlan.ts +47 -0
- package/src/hooks/useDomEditAttributeCommits.ts +4 -1
- package/src/hooks/useDomEditCommits.test.tsx +207 -8
- package/src/hooks/useDomEditCommits.ts +54 -14
- package/src/hooks/useDomEditPositionPatchCommit.test.tsx +3 -4
- package/src/hooks/useDomEditPositionPatchCommit.ts +27 -29
- package/src/hooks/useDomEditSession.ts +6 -2
- package/src/hooks/useDomEditTextCommits.test.tsx +86 -2
- package/src/hooks/useDomEditTextCommits.ts +64 -86
- package/src/hooks/useDomEditWiring.ts +1 -1
- package/src/hooks/useDomSelection.ts +2 -0
- package/src/hooks/useDomSelectionTypes.ts +2 -0
- package/src/hooks/useExternalFileChangeCoordinator.test.tsx +71 -4
- package/src/hooks/useExternalFileChangeCoordinator.ts +18 -9
- package/src/hooks/useGsapAnimationFetchFallback.test.ts +13 -1
- package/src/hooks/useGsapAnimationFetchFallback.ts +7 -3
- package/src/hooks/useGsapAnimationOps.test.tsx +101 -0
- package/src/hooks/useGsapAnimationOps.ts +4 -4
- package/src/hooks/useGsapAwareEditing.test.tsx +30 -2
- package/src/hooks/useGsapAwareEditing.ts +31 -10
- package/src/hooks/useGsapSelectionHandlers.test.tsx +3 -1
- package/src/hooks/useGsapSelectionHandlers.ts +21 -11
- package/src/hooks/useRenderClipContent.test.ts +27 -0
- package/src/hooks/useRenderClipContent.ts +5 -0
- package/src/hooks/useStudioExternalFileChanges.ts +5 -0
- package/src/player/components/CompositionThumbnail.test.ts +66 -1
- package/src/player/components/CompositionThumbnail.tsx +6 -0
- package/src/player/store/playerStore.test.ts +24 -0
- package/src/player/store/thumbnailSlice.ts +6 -0
- package/src/styles/studio.css +179 -0
- package/src/utils/domEditSaveQueue.test.ts +5 -3
- package/src/utils/domEditSaveQueue.ts +6 -1
- package/src/webmcp/StudioAgentTools.test.ts +70 -0
- package/src/webmcp/StudioAgentTools.tsx +89 -21
- package/src/webmcp/handles.test.ts +172 -15
- package/src/webmcp/handles.ts +152 -35
- package/src/webmcp/tools/animationTools.test.ts +241 -60
- package/src/webmcp/tools/animationTools.ts +191 -104
- package/src/webmcp/tools/contentTools.test.ts +103 -41
- package/src/webmcp/tools/contentTools.ts +149 -105
- package/src/webmcp/tools/inspectTools.test.ts +47 -5
- package/src/webmcp/tools/inspectTools.ts +59 -19
- package/src/webmcp/tools/lookTools.test.ts +185 -38
- package/src/webmcp/tools/lookTools.ts +134 -21
- package/src/webmcp/tools/selectionTools.test.ts +105 -9
- package/src/webmcp/tools/selectionTools.ts +34 -19
- package/src/webmcp/tools/targetedWriteTools.test.ts +125 -0
- package/src/webmcp/tools/transformTools.test.ts +140 -54
- package/src/webmcp/tools/transformTools.ts +215 -56
- package/src/webmcp/useStudioAgentTools.test.tsx +310 -10
- package/src/webmcp/useStudioAgentTools.ts +27 -15
- package/src/webmcp/webmcpTestUtils.ts +37 -0
- package/src/webmcp/writeCoordinator.test.ts +375 -0
- package/src/webmcp/writeCoordinator.ts +473 -0
- package/dist/assets/index-yGhfxxoL.css +0 -1
- package/dist/chunk-6BT6DTB4.js.map +0 -1
- /package/dist/{domEditingLayers-URA7BLWE.js.map → domEditingLayers-JMMVOGTO.js.map} +0 -0
|
@@ -7,34 +7,61 @@ import {
|
|
|
7
7
|
type StudioSetStyleResult,
|
|
8
8
|
type StudioSetTextResult,
|
|
9
9
|
} from "./contentTools";
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
expectFailure,
|
|
12
|
+
expectOk,
|
|
13
|
+
previewElement,
|
|
14
|
+
selectionFor,
|
|
15
|
+
sourceHandle,
|
|
16
|
+
targetedWriteDeps,
|
|
17
|
+
} from "../webmcpTestUtils";
|
|
18
|
+
|
|
19
|
+
const textInput = (input: Record<string, unknown>) => ({
|
|
20
|
+
handle: sourceHandle("headline"),
|
|
21
|
+
...input,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const styleInput = (styles: unknown) => ({ handle: sourceHandle("headline"), styles });
|
|
11
25
|
|
|
12
26
|
function contentDeps(overrides: Partial<ContentToolDeps> = {}): ContentToolDeps {
|
|
13
27
|
const element = previewElement('<h1 id="headline">Ship it</h1>', "headline");
|
|
28
|
+
const selection = selectionFor(element);
|
|
14
29
|
return {
|
|
15
|
-
|
|
16
|
-
getWriteBlockedReason: () => null,
|
|
30
|
+
...targetedWriteDeps(selection),
|
|
17
31
|
setText: async () => ({ ok: true }),
|
|
18
32
|
setStyle: async () => ({ ok: true }),
|
|
19
33
|
...overrides,
|
|
20
34
|
};
|
|
21
35
|
}
|
|
22
36
|
|
|
37
|
+
function childTextSelection() {
|
|
38
|
+
const element = previewElement('<h1 id="headline">Ship it</h1>', "headline");
|
|
39
|
+
const selection = selectionFor(element);
|
|
40
|
+
selection.textFields = [{ ...selection.textFields[0]!, key: "child:0:h1" }];
|
|
41
|
+
return selection;
|
|
42
|
+
}
|
|
43
|
+
|
|
23
44
|
describe("studioSetText", () => {
|
|
24
45
|
it("writes the text and reports what it now is", async () => {
|
|
25
|
-
const setText = vi.fn(async () =>
|
|
46
|
+
const setText = vi.fn(async (selection) => {
|
|
47
|
+
selection.element.textContent = "Ship it faster";
|
|
48
|
+
return { ok: true } as const;
|
|
49
|
+
});
|
|
26
50
|
|
|
27
|
-
const result = await studioSetText(
|
|
51
|
+
const result = await studioSetText(
|
|
52
|
+
contentDeps({ setText }),
|
|
53
|
+
textInput({ text: "Ship it faster" }),
|
|
54
|
+
);
|
|
28
55
|
|
|
29
56
|
const ok = expectOk<StudioSetTextResult>(result);
|
|
30
57
|
expect(ok.text).toBe("Ship it faster");
|
|
31
58
|
expect(ok.changed).toBe(true);
|
|
32
59
|
// The single field is resolved and named, rather than left undefined.
|
|
33
|
-
expect(setText).toHaveBeenCalledWith("Ship it faster", "self");
|
|
60
|
+
expect(setText).toHaveBeenCalledWith(expect.anything(), "Ship it faster", "self");
|
|
34
61
|
});
|
|
35
62
|
|
|
36
63
|
it("reports changed:false when the text already said that", async () => {
|
|
37
|
-
const result = await studioSetText(contentDeps(), { text: "Ship it" });
|
|
64
|
+
const result = await studioSetText(contentDeps(), textInput({ text: "Ship it" }));
|
|
38
65
|
|
|
39
66
|
expect(expectOk<StudioSetTextResult>(result).changed).toBe(false);
|
|
40
67
|
});
|
|
@@ -51,7 +78,7 @@ describe("studioSetText", () => {
|
|
|
51
78
|
getWriteBlockedReason: () => "an external change to this file is waiting to be resolved",
|
|
52
79
|
setText,
|
|
53
80
|
}),
|
|
54
|
-
{ text: "Ship it faster" },
|
|
81
|
+
textInput({ text: "Ship it faster" }),
|
|
55
82
|
),
|
|
56
83
|
);
|
|
57
84
|
|
|
@@ -66,7 +93,7 @@ describe("studioSetText", () => {
|
|
|
66
93
|
const result = expectFailure(
|
|
67
94
|
await studioSetText(
|
|
68
95
|
contentDeps({ setText: async () => ({ ok: false, reason: "persist-failed" }) }),
|
|
69
|
-
{ text: "Ship it faster" },
|
|
96
|
+
textInput({ text: "Ship it faster" }),
|
|
70
97
|
),
|
|
71
98
|
);
|
|
72
99
|
|
|
@@ -78,32 +105,38 @@ describe("studioSetText", () => {
|
|
|
78
105
|
const result = expectFailure(
|
|
79
106
|
await studioSetText(
|
|
80
107
|
contentDeps({ setText: async () => ({ ok: false, reason: "not-text-editable" }) }),
|
|
81
|
-
{ text: "x" },
|
|
108
|
+
textInput({ text: "x" }),
|
|
82
109
|
),
|
|
83
110
|
);
|
|
84
111
|
|
|
85
|
-
expect(result.kind).toBe("
|
|
112
|
+
expect(result.kind).toBe("failed");
|
|
86
113
|
expect(result.hint).toMatch(/studio_inspect/);
|
|
87
114
|
});
|
|
88
115
|
|
|
89
116
|
it("rejects a non-string text without dispatching", async () => {
|
|
90
117
|
const setText = vi.fn();
|
|
91
118
|
|
|
92
|
-
const result = expectFailure(
|
|
119
|
+
const result = expectFailure(
|
|
120
|
+
await studioSetText(contentDeps({ setText }), textInput({ text: 42 })),
|
|
121
|
+
);
|
|
93
122
|
|
|
94
123
|
expect(result.kind).toBe("invalid");
|
|
95
124
|
expect(setText).not.toHaveBeenCalled();
|
|
96
125
|
});
|
|
97
126
|
|
|
98
|
-
it("
|
|
127
|
+
it("reports an unsupported target when Studio cannot build its selection", async () => {
|
|
99
128
|
const setText = vi.fn();
|
|
100
129
|
|
|
101
130
|
const result = expectFailure(
|
|
102
|
-
await studioSetText(
|
|
131
|
+
await studioSetText(
|
|
132
|
+
contentDeps({ buildSelection: async () => null, setText }),
|
|
133
|
+
textInput({ text: "x" }),
|
|
134
|
+
),
|
|
103
135
|
);
|
|
104
136
|
|
|
105
|
-
expect(result.kind).toBe("
|
|
106
|
-
expect(result.
|
|
137
|
+
expect(result.kind).toBe("blocked");
|
|
138
|
+
expect(result.reason).toBe("the target resolved to an element Studio cannot edit");
|
|
139
|
+
expect(result.hint).toMatch(/parent or child/);
|
|
107
140
|
expect(setText).not.toHaveBeenCalled();
|
|
108
141
|
});
|
|
109
142
|
|
|
@@ -113,29 +146,26 @@ describe("studioSetText", () => {
|
|
|
113
146
|
// operations, and the server rejected the empty patch with
|
|
114
147
|
// "target and operations required" -- a persist failure that looked like a
|
|
115
148
|
// server problem and was not.
|
|
116
|
-
const
|
|
117
|
-
const selection = selectionFor(element);
|
|
118
|
-
selection.textFields = [{ ...selection.textFields[0]!, key: "child:0:h1" }];
|
|
149
|
+
const selection = childTextSelection();
|
|
119
150
|
const setText = vi.fn(async () => ({ ok: true }) as const);
|
|
120
151
|
|
|
121
|
-
await studioSetText(
|
|
122
|
-
|
|
123
|
-
|
|
152
|
+
await studioSetText(
|
|
153
|
+
contentDeps({ ...targetedWriteDeps(selection), setText }),
|
|
154
|
+
textInput({ text: "Shipped it" }),
|
|
155
|
+
);
|
|
124
156
|
|
|
125
|
-
expect(setText).toHaveBeenCalledWith("Shipped it", "child:0:h1");
|
|
157
|
+
expect(setText).toHaveBeenCalledWith(selection, "Shipped it", "child:0:h1");
|
|
126
158
|
});
|
|
127
159
|
|
|
128
160
|
it("rejects a field the element does not have, rather than writing nowhere", async () => {
|
|
129
|
-
const
|
|
130
|
-
const selection = selectionFor(element);
|
|
131
|
-
selection.textFields = [{ ...selection.textFields[0]!, key: "child:0:h1" }];
|
|
161
|
+
const selection = childTextSelection();
|
|
132
162
|
const setText = vi.fn();
|
|
133
163
|
|
|
134
164
|
const result = expectFailure(
|
|
135
|
-
await studioSetText(
|
|
136
|
-
|
|
137
|
-
field: "self",
|
|
138
|
-
|
|
165
|
+
await studioSetText(
|
|
166
|
+
contentDeps({ ...targetedWriteDeps(selection), setText }),
|
|
167
|
+
textInput({ text: "x", field: "self" }),
|
|
168
|
+
),
|
|
139
169
|
);
|
|
140
170
|
|
|
141
171
|
expect(result.kind).toBe("invalid");
|
|
@@ -154,7 +184,8 @@ describe("studioSetText", () => {
|
|
|
154
184
|
const setText = vi.fn();
|
|
155
185
|
|
|
156
186
|
const result = expectFailure(
|
|
157
|
-
await studioSetText(contentDeps({
|
|
187
|
+
await studioSetText(contentDeps({ ...targetedWriteDeps(selection), setText }), {
|
|
188
|
+
handle: sourceHandle("card"),
|
|
158
189
|
text: "x",
|
|
159
190
|
}),
|
|
160
191
|
);
|
|
@@ -171,7 +202,8 @@ describe("studioSetText", () => {
|
|
|
171
202
|
const setText = vi.fn();
|
|
172
203
|
|
|
173
204
|
const result = expectFailure(
|
|
174
|
-
await studioSetText(contentDeps({
|
|
205
|
+
await studioSetText(contentDeps({ ...targetedWriteDeps(selection), setText }), {
|
|
206
|
+
handle: sourceHandle("box"),
|
|
175
207
|
text: "x",
|
|
176
208
|
}),
|
|
177
209
|
);
|
|
@@ -186,7 +218,7 @@ describe("studioSetStyle", () => {
|
|
|
186
218
|
const setStyle = vi.fn(async () => ({ ok: true }) as const);
|
|
187
219
|
|
|
188
220
|
const result = await studioSetStyle(contentDeps({ setStyle }), {
|
|
189
|
-
|
|
221
|
+
...styleInput({ color: "red", "font-size": "48px" }),
|
|
190
222
|
});
|
|
191
223
|
|
|
192
224
|
const ok = expectOk<StudioSetStyleResult>(result);
|
|
@@ -195,6 +227,36 @@ describe("studioSetStyle", () => {
|
|
|
195
227
|
expect(setStyle).toHaveBeenCalledTimes(2);
|
|
196
228
|
});
|
|
197
229
|
|
|
230
|
+
it("keeps an earlier changed property when the latest saved property is a no-op", async () => {
|
|
231
|
+
const setStyle = vi
|
|
232
|
+
.fn()
|
|
233
|
+
.mockResolvedValueOnce({
|
|
234
|
+
ok: true,
|
|
235
|
+
persistence: { sourceFile: "index.html", version: "v1", changed: true },
|
|
236
|
+
})
|
|
237
|
+
.mockResolvedValueOnce({
|
|
238
|
+
ok: true,
|
|
239
|
+
persistence: { sourceFile: "index.html", version: "v2", changed: false },
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
const result = expectOk<StudioSetStyleResult>(
|
|
243
|
+
await studioSetStyle(
|
|
244
|
+
contentDeps({ setStyle }),
|
|
245
|
+
styleInput({ color: "red", "font-size": "48px" }),
|
|
246
|
+
),
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
expect(result).toMatchObject({
|
|
250
|
+
stage: "saved",
|
|
251
|
+
changed: true,
|
|
252
|
+
evidence: { kind: "content-version", sourceFile: "index.html", version: "v2" },
|
|
253
|
+
propertyReceipts: {
|
|
254
|
+
color: { stage: "saved", changed: true },
|
|
255
|
+
"font-size": { stage: "saved", changed: false },
|
|
256
|
+
},
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
198
260
|
it("commits sequentially, never concurrently", async () => {
|
|
199
261
|
// Two commits racing through Studio's client-side read-modify-write can
|
|
200
262
|
// record undo entries that both claim the same starting content.
|
|
@@ -209,21 +271,21 @@ describe("studioSetStyle", () => {
|
|
|
209
271
|
});
|
|
210
272
|
|
|
211
273
|
await studioSetStyle(contentDeps({ setStyle }), {
|
|
212
|
-
|
|
274
|
+
...styleInput({ color: "red", "font-size": "48px", opacity: "0.5" }),
|
|
213
275
|
});
|
|
214
276
|
|
|
215
277
|
expect(maxInFlight).toBe(1);
|
|
216
278
|
});
|
|
217
279
|
|
|
218
280
|
it("reports a partial success as partial, not whole", async () => {
|
|
219
|
-
const setStyle = vi.fn(async (property: string) =>
|
|
281
|
+
const setStyle = vi.fn(async (_selection, property: string) =>
|
|
220
282
|
property === "left"
|
|
221
283
|
? ({ ok: false, reason: "geometry-property" } as const)
|
|
222
284
|
: ({ ok: true } as const),
|
|
223
285
|
);
|
|
224
286
|
|
|
225
287
|
const result = await studioSetStyle(contentDeps({ setStyle }), {
|
|
226
|
-
|
|
288
|
+
...styleInput({ color: "red", left: "10px" }),
|
|
227
289
|
});
|
|
228
290
|
|
|
229
291
|
const ok = expectOk<StudioSetStyleResult>(result);
|
|
@@ -235,18 +297,18 @@ describe("studioSetStyle", () => {
|
|
|
235
297
|
const result = expectFailure(
|
|
236
298
|
await studioSetStyle(
|
|
237
299
|
contentDeps({ setStyle: async () => ({ ok: false, reason: "styles-not-editable" }) }),
|
|
238
|
-
{
|
|
300
|
+
styleInput({ color: "red" }),
|
|
239
301
|
),
|
|
240
302
|
);
|
|
241
303
|
|
|
242
|
-
expect(result.kind).toBe("
|
|
304
|
+
expect(result.kind).toBe("failed");
|
|
243
305
|
expect(result.reason).toMatch(/styles-not-editable/);
|
|
244
306
|
});
|
|
245
307
|
|
|
246
308
|
it("rejects an empty styles object rather than committing nothing", async () => {
|
|
247
309
|
const setStyle = vi.fn();
|
|
248
310
|
|
|
249
|
-
const result = expectFailure(await studioSetStyle(contentDeps({ setStyle }), {
|
|
311
|
+
const result = expectFailure(await studioSetStyle(contentDeps({ setStyle }), styleInput({})));
|
|
250
312
|
|
|
251
313
|
expect(result.kind).toBe("invalid");
|
|
252
314
|
expect(setStyle).not.toHaveBeenCalled();
|
|
@@ -254,7 +316,7 @@ describe("studioSetStyle", () => {
|
|
|
254
316
|
|
|
255
317
|
it("rejects a non-object styles value", async () => {
|
|
256
318
|
for (const styles of ["color: red", 42, null, ["color"]]) {
|
|
257
|
-
const result = expectFailure(await studioSetStyle(contentDeps(),
|
|
319
|
+
const result = expectFailure(await studioSetStyle(contentDeps(), styleInput(styles)));
|
|
258
320
|
expect(result.kind).toBe("invalid");
|
|
259
321
|
}
|
|
260
322
|
});
|
|
@@ -265,7 +327,7 @@ describe("studioSetStyle", () => {
|
|
|
265
327
|
const result = expectFailure(
|
|
266
328
|
await studioSetStyle(
|
|
267
329
|
contentDeps({ getWriteBlockedReason: () => "Auto-save is paused", setStyle }),
|
|
268
|
-
{
|
|
330
|
+
styleInput({ color: "red" }),
|
|
269
331
|
),
|
|
270
332
|
);
|
|
271
333
|
|
|
@@ -1,28 +1,36 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `studio_set_text` and `studio_set_style`: the first tools that change the file.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* `applyDomSelection` only schedules a state update, so selecting and
|
|
8
|
-
* committing inside one call would write to whatever was selected before.
|
|
9
|
-
* Two tool calls are separated by a render. Select first, then edit.
|
|
10
|
-
*
|
|
11
|
-
* Every write here is guarded before dispatch and verified after. Studio has
|
|
12
|
-
* several paths where a failed commit resolves anyway, so "the function did not
|
|
13
|
-
* throw" proves nothing; the outcome the handler now returns is what proves it.
|
|
4
|
+
* The explicit source-safe handle is resolved to one selection per invocation.
|
|
5
|
+
* That captured selection is passed into Studio's existing commit actor; visible
|
|
6
|
+
* selection is presentation only and cannot redirect the write.
|
|
14
7
|
*/
|
|
15
8
|
|
|
16
9
|
import type { DomEditCommitOutcome } from "../../hooks/domEditCommitRunner";
|
|
17
10
|
import type { DomEditSelection } from "../../components/editor/domEditingTypes";
|
|
18
|
-
import { toolFailure,
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
11
|
+
import { toolFailure, type ToolFailure } from "../toolResult";
|
|
12
|
+
import {
|
|
13
|
+
dispatched,
|
|
14
|
+
runTargetedWrite,
|
|
15
|
+
saved,
|
|
16
|
+
type StudioWriteAdapterSuccess,
|
|
17
|
+
type StudioWriteEvidence,
|
|
18
|
+
type StudioWriteResult,
|
|
19
|
+
type TargetedWriteDeps,
|
|
20
|
+
WRITE_RECEIPT_DESCRIPTION,
|
|
21
|
+
} from "../writeCoordinator";
|
|
22
|
+
|
|
23
|
+
export interface ContentToolDeps extends TargetedWriteDeps {
|
|
24
|
+
setText: (
|
|
25
|
+
selection: DomEditSelection,
|
|
26
|
+
value: string,
|
|
27
|
+
fieldKey?: string,
|
|
28
|
+
) => Promise<DomEditCommitOutcome>;
|
|
29
|
+
setStyle: (
|
|
30
|
+
selection: DomEditSelection,
|
|
31
|
+
property: string,
|
|
32
|
+
value: string,
|
|
33
|
+
) => Promise<DomEditCommitOutcome>;
|
|
26
34
|
}
|
|
27
35
|
|
|
28
36
|
/**
|
|
@@ -53,20 +61,6 @@ function fromOutcome(outcome: DomEditCommitOutcome, what: string): ToolFailure |
|
|
|
53
61
|
return toolFailure(mapped.kind, `${what} was not applied: ${outcome.reason}`, mapped.hint);
|
|
54
62
|
}
|
|
55
63
|
|
|
56
|
-
function guardWrite(deps: ContentToolDeps): ToolFailure | null {
|
|
57
|
-
// Both blocked states are banners in Studio's UI with no lock behind them, so
|
|
58
|
-
// nothing else stops a programmatic write from landing on top of a conflict
|
|
59
|
-
// the user has been asked to adjudicate.
|
|
60
|
-
const blocked = deps.getWriteBlockedReason();
|
|
61
|
-
if (blocked) {
|
|
62
|
-
return toolFailure("blocked", blocked, "Resolve it in Studio, then retry.");
|
|
63
|
-
}
|
|
64
|
-
if (!deps.getCurrentSelection()) {
|
|
65
|
-
return toolFailure("invalid", "nothing is selected", "Call studio_select first.");
|
|
66
|
-
}
|
|
67
|
-
return null;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
64
|
export interface StudioSetTextResult {
|
|
71
65
|
text: string;
|
|
72
66
|
changed: boolean;
|
|
@@ -74,107 +68,147 @@ export interface StudioSetTextResult {
|
|
|
74
68
|
|
|
75
69
|
export async function studioSetText(
|
|
76
70
|
deps: ContentToolDeps,
|
|
77
|
-
input: { text?: unknown; field?: unknown },
|
|
78
|
-
|
|
71
|
+
input: { handle?: unknown; text?: unknown; field?: unknown },
|
|
72
|
+
signal: AbortSignal = new AbortController().signal,
|
|
73
|
+
): Promise<StudioWriteResult<StudioSetTextResult>> {
|
|
79
74
|
if (typeof input.text !== "string") {
|
|
80
|
-
return toolFailure("invalid", "text must be a string");
|
|
75
|
+
return preDispatchFailure("set-text", toolFailure("invalid", "text must be a string"));
|
|
81
76
|
}
|
|
77
|
+
const text = input.text;
|
|
82
78
|
|
|
83
|
-
const blocked = guardWrite(deps);
|
|
84
|
-
if (blocked) return blocked;
|
|
85
|
-
|
|
86
|
-
const selection = deps.getCurrentSelection();
|
|
87
|
-
if (!selection) return toolFailure("invalid", "nothing is selected");
|
|
88
|
-
|
|
89
|
-
const fields = selection.textFields;
|
|
90
79
|
const requested = typeof input.field === "string" && input.field ? input.field : undefined;
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
)
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
80
|
+
let field: string | undefined;
|
|
81
|
+
return runTargetedWrite(deps, {
|
|
82
|
+
handle: input.handle,
|
|
83
|
+
operation: "set-text",
|
|
84
|
+
signal,
|
|
85
|
+
preflight: (selection) => {
|
|
86
|
+
const fields = selection.textFields;
|
|
87
|
+
if (requested && !fields.some((candidate) => candidate.key === requested)) {
|
|
88
|
+
return toolFailure(
|
|
89
|
+
"invalid",
|
|
90
|
+
`this element has no text field "${requested}"`,
|
|
91
|
+
`Its fields are: ${fields.map((candidate) => candidate.key).join(", ") || "none"}.`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
field = requested ?? (fields.length === 1 ? fields[0]?.key : undefined);
|
|
95
|
+
if (field) return null;
|
|
96
|
+
if (fields.length === 0) {
|
|
97
|
+
return toolFailure(
|
|
98
|
+
"blocked",
|
|
99
|
+
"this element has no editable text field",
|
|
100
|
+
"studio_inspect lists an element's textFields.",
|
|
101
|
+
);
|
|
102
|
+
}
|
|
107
103
|
return toolFailure(
|
|
108
|
-
"
|
|
109
|
-
|
|
110
|
-
|
|
104
|
+
"invalid",
|
|
105
|
+
`this element has ${fields.length} text fields, so one must be named`,
|
|
106
|
+
`Pass field as one of: ${fields.map((candidate) => candidate.key).join(", ")}.`,
|
|
111
107
|
);
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
if (failure) return failure;
|
|
124
|
-
|
|
125
|
-
return toolOk<StudioSetTextResult>({ text: input.text, changed: before !== input.text });
|
|
108
|
+
},
|
|
109
|
+
write: async (selection) => {
|
|
110
|
+
const before = selection.element.textContent;
|
|
111
|
+
const outcome = await deps.setText(selection, text, field);
|
|
112
|
+
if (!outcome.ok) return fromOutcome(outcome, "the text")!;
|
|
113
|
+
const value = { text, changed: selection.element.textContent !== before };
|
|
114
|
+
return outcome.persistence
|
|
115
|
+
? saved(value, outcome.persistence)
|
|
116
|
+
: dispatched(value, value.changed);
|
|
117
|
+
},
|
|
118
|
+
});
|
|
126
119
|
}
|
|
127
120
|
|
|
128
121
|
export interface StudioSetStyleResult {
|
|
129
122
|
applied: Record<string, string>;
|
|
130
123
|
/** Properties the element refused, with the reason. Empty when all landed. */
|
|
131
124
|
rejected: Record<string, string>;
|
|
125
|
+
partial: boolean;
|
|
126
|
+
propertyReceipts: Record<
|
|
127
|
+
string,
|
|
128
|
+
{ stage: "dispatched" | "saved"; changed: boolean; evidence: StudioWriteEvidence }
|
|
129
|
+
>;
|
|
132
130
|
}
|
|
133
131
|
|
|
134
132
|
export async function studioSetStyle(
|
|
135
133
|
deps: ContentToolDeps,
|
|
136
|
-
input: { styles?: unknown },
|
|
137
|
-
|
|
134
|
+
input: { handle?: unknown; styles?: unknown },
|
|
135
|
+
signal: AbortSignal = new AbortController().signal,
|
|
136
|
+
): Promise<StudioWriteResult<StudioSetStyleResult>> {
|
|
138
137
|
const styles = input.styles;
|
|
139
138
|
if (typeof styles !== "object" || styles === null || Array.isArray(styles)) {
|
|
140
|
-
return
|
|
139
|
+
return preDispatchFailure(
|
|
140
|
+
"set-style",
|
|
141
|
+
toolFailure("invalid", "styles must be an object of CSS property to value"),
|
|
142
|
+
);
|
|
141
143
|
}
|
|
142
144
|
const entries = Object.entries(styles).filter(
|
|
143
145
|
(entry): entry is [string, string] => typeof entry[1] === "string",
|
|
144
146
|
);
|
|
145
147
|
if (entries.length === 0) {
|
|
146
148
|
// An empty commit would report success having done nothing.
|
|
147
|
-
return
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
if (blocked) return blocked;
|
|
152
|
-
|
|
153
|
-
// `handleDomStyleCommit` is one property per call, so N properties are N
|
|
154
|
-
// commits and N undo entries. Sequential, not concurrent: two commits racing
|
|
155
|
-
// through Studio's client-side read-modify-write can record undo entries that
|
|
156
|
-
// both claim the same starting content.
|
|
157
|
-
const applied: Record<string, string> = {};
|
|
158
|
-
const rejected: Record<string, string> = {};
|
|
159
|
-
for (const [property, value] of entries) {
|
|
160
|
-
const outcome = await deps.setStyle(property, value);
|
|
161
|
-
if (outcome.ok) applied[property] = value;
|
|
162
|
-
else rejected[property] = outcome.reason;
|
|
149
|
+
return preDispatchFailure(
|
|
150
|
+
"set-style",
|
|
151
|
+
toolFailure("invalid", "styles must contain at least one string value"),
|
|
152
|
+
);
|
|
163
153
|
}
|
|
164
154
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
155
|
+
return runTargetedWrite(deps, {
|
|
156
|
+
handle: input.handle,
|
|
157
|
+
operation: "set-style",
|
|
158
|
+
signal,
|
|
159
|
+
preflight: (selection) =>
|
|
160
|
+
selection.capabilities.canEditStyles
|
|
161
|
+
? null
|
|
162
|
+
: toolFailure(
|
|
163
|
+
"blocked",
|
|
164
|
+
"this element's styles are not editable",
|
|
165
|
+
"studio_inspect reports why, in can.reasonIfDisabled.",
|
|
166
|
+
),
|
|
167
|
+
write: async (selection) => {
|
|
168
|
+
const applied: Record<string, string> = {};
|
|
169
|
+
const rejected: Record<string, string> = {};
|
|
170
|
+
const propertyReceipts: StudioSetStyleResult["propertyReceipts"] = {};
|
|
171
|
+
let weakest: StudioWriteAdapterSuccess<object> | null = null;
|
|
172
|
+
for (const [property, value] of entries) {
|
|
173
|
+
const outcome = await deps.setStyle(selection, property, value);
|
|
174
|
+
if (!outcome.ok) {
|
|
175
|
+
rejected[property] = outcome.reason;
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
applied[property] = value;
|
|
179
|
+
const receipt = outcome.persistence ? saved({}, outcome.persistence) : dispatched({}, true);
|
|
180
|
+
propertyReceipts[property] = {
|
|
181
|
+
stage: receipt.stage,
|
|
182
|
+
changed: receipt.changed,
|
|
183
|
+
evidence: receipt.evidence,
|
|
184
|
+
};
|
|
185
|
+
if (!weakest || weakest.stage !== "dispatched") weakest = receipt;
|
|
186
|
+
}
|
|
171
187
|
|
|
172
|
-
|
|
188
|
+
if (!weakest) {
|
|
189
|
+
const reasons = Object.entries(rejected)
|
|
190
|
+
.map(([property, reason]) => `${property}: ${reason}`)
|
|
191
|
+
.join(", ");
|
|
192
|
+
return toolFailure("blocked", `no style was applied (${reasons})`);
|
|
193
|
+
}
|
|
194
|
+
const value: StudioSetStyleResult = {
|
|
195
|
+
applied,
|
|
196
|
+
rejected,
|
|
197
|
+
partial: Object.keys(rejected).length > 0,
|
|
198
|
+
propertyReceipts,
|
|
199
|
+
};
|
|
200
|
+
const changed = Object.values(propertyReceipts).some((receipt) => receipt.changed);
|
|
201
|
+
return weakest.stage === "dispatched"
|
|
202
|
+
? dispatched(value, changed)
|
|
203
|
+
: { ...weakest, ...value, changed };
|
|
204
|
+
},
|
|
205
|
+
});
|
|
173
206
|
}
|
|
174
207
|
|
|
175
208
|
export const STUDIO_SET_TEXT_INPUT_SCHEMA = {
|
|
176
209
|
type: "object",
|
|
177
210
|
properties: {
|
|
211
|
+
handle: { type: "string", description: "A source-safe element handle from studio_look." },
|
|
178
212
|
text: { type: "string", description: "The new text content." },
|
|
179
213
|
field: {
|
|
180
214
|
type: "string",
|
|
@@ -182,36 +216,46 @@ export const STUDIO_SET_TEXT_INPUT_SCHEMA = {
|
|
|
182
216
|
"Which text field to write, from studio_inspect. Omit for the element's own text.",
|
|
183
217
|
},
|
|
184
218
|
},
|
|
185
|
-
required: ["text"],
|
|
219
|
+
required: ["handle", "text"],
|
|
186
220
|
additionalProperties: false,
|
|
187
221
|
} as const;
|
|
188
222
|
|
|
189
223
|
export const STUDIO_SET_TEXT_DESCRIPTION = [
|
|
190
|
-
"Set
|
|
224
|
+
"Set one element's text using its source-safe handle from studio_look.",
|
|
191
225
|
"This is the edit a synthetic double-click cannot reach, because Studio's canvas",
|
|
192
226
|
"takes pointer capture and recognises the double press itself.",
|
|
193
227
|
"Returns `ok: true` with the resulting text and whether it changed, or `ok: false`",
|
|
194
228
|
"with `kind`, `reason` and usually a `hint` naming what to do instead.",
|
|
229
|
+
WRITE_RECEIPT_DESCRIPTION,
|
|
195
230
|
].join(" ");
|
|
196
231
|
|
|
197
232
|
export const STUDIO_SET_STYLE_INPUT_SCHEMA = {
|
|
198
233
|
type: "object",
|
|
199
234
|
properties: {
|
|
235
|
+
handle: { type: "string", description: "A source-safe element handle from studio_look." },
|
|
200
236
|
styles: {
|
|
201
237
|
type: "object",
|
|
202
238
|
description: 'CSS property to value, for example {"color": "red", "font-size": "48px"}.',
|
|
203
239
|
additionalProperties: { type: "string" },
|
|
204
240
|
},
|
|
205
241
|
},
|
|
206
|
-
required: ["styles"],
|
|
242
|
+
required: ["handle", "styles"],
|
|
207
243
|
additionalProperties: false,
|
|
208
244
|
} as const;
|
|
209
245
|
|
|
210
246
|
export const STUDIO_SET_STYLE_DESCRIPTION = [
|
|
211
|
-
"Set inline styles on
|
|
247
|
+
"Set inline styles on one element using its source-safe handle from studio_look.",
|
|
212
248
|
"Each property is a separate commit, so N properties produce N undo entries.",
|
|
213
249
|
"Position and size properties (left, top, width, height) are refused here on purpose;",
|
|
214
250
|
"they belong to the transform tools.",
|
|
215
251
|
"Returns `ok: true` with `applied` and `rejected` maps, so a partial success is visible",
|
|
216
252
|
"as a partial success rather than reported as a whole one.",
|
|
253
|
+
WRITE_RECEIPT_DESCRIPTION,
|
|
217
254
|
].join(" ");
|
|
255
|
+
|
|
256
|
+
function preDispatchFailure<T extends object>(
|
|
257
|
+
operation: "set-text" | "set-style",
|
|
258
|
+
failure: ToolFailure,
|
|
259
|
+
): StudioWriteResult<T> {
|
|
260
|
+
return { ...failure, stage: "refused", operation };
|
|
261
|
+
}
|