@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.17",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
"@codemirror/state": "^6.6.0",
|
|
41
41
|
"@codemirror/theme-one-dark": "^6.1.2",
|
|
42
42
|
"@codemirror/view": "6.40.0",
|
|
43
|
+
"@mcp-b/global": "^5.0.1",
|
|
43
44
|
"@phosphor-icons/react": "^2.1.10",
|
|
44
45
|
"@tanstack/react-virtual": "^3.14.6",
|
|
45
46
|
"bpm-detective": "^2.0.5",
|
|
@@ -47,11 +48,11 @@
|
|
|
47
48
|
"gsap": "^3.13.0",
|
|
48
49
|
"marked": "^14.1.4",
|
|
49
50
|
"mediabunny": "^1.45.3",
|
|
50
|
-
"@hyperframes/
|
|
51
|
-
"@hyperframes/parsers": "0.8.
|
|
52
|
-
"@hyperframes/
|
|
53
|
-
"@hyperframes/
|
|
54
|
-
"@hyperframes/sdk": "0.8.
|
|
51
|
+
"@hyperframes/player": "0.8.17",
|
|
52
|
+
"@hyperframes/parsers": "0.8.17",
|
|
53
|
+
"@hyperframes/studio-server": "0.8.17",
|
|
54
|
+
"@hyperframes/core": "0.8.17",
|
|
55
|
+
"@hyperframes/sdk": "0.8.17"
|
|
55
56
|
},
|
|
56
57
|
"devDependencies": {
|
|
57
58
|
"@types/react": "19",
|
|
@@ -68,7 +69,7 @@
|
|
|
68
69
|
"vite": "^6.4.2",
|
|
69
70
|
"vitest": "^3.2.4",
|
|
70
71
|
"zustand": "^5.0.0",
|
|
71
|
-
"@hyperframes/producer": "0.8.
|
|
72
|
+
"@hyperframes/producer": "0.8.17"
|
|
72
73
|
},
|
|
73
74
|
"peerDependencies": {
|
|
74
75
|
"react": "19",
|
|
@@ -15,6 +15,7 @@ import { usePlayerStore, type TimelineElement } from "../player";
|
|
|
15
15
|
import type { BlockPreviewInfo } from "./sidebar/BlocksTab";
|
|
16
16
|
import type { GestureRecordingState } from "./editor/GestureRecordControl";
|
|
17
17
|
import { useTimelineSelectionPreviewSync } from "../hooks/useTimelineSelectionPreviewSync";
|
|
18
|
+
import { StudioAgentTools } from "../webmcp/StudioAgentTools";
|
|
18
19
|
|
|
19
20
|
type RenderClipContent = (
|
|
20
21
|
element: TimelineElement,
|
|
@@ -262,6 +263,9 @@ function EditorShellBody({
|
|
|
262
263
|
onKeyDown={handleKeyDown}
|
|
263
264
|
tabIndex={-1}
|
|
264
265
|
>
|
|
266
|
+
{/* Renders nothing; exposes Studio's state to an agentic browser. Mounted
|
|
267
|
+
here rather than in App because it needs the DomEdit contexts. */}
|
|
268
|
+
<StudioAgentTools />
|
|
265
269
|
{/* Top row: [left | preview | right] — outer padding + the 8px resize
|
|
266
270
|
seams give the panels CapCut-style separation on the dark canvas. */}
|
|
267
271
|
<div className="flex flex-row flex-1 min-h-0 px-px pt-px">
|
|
@@ -35,7 +35,7 @@ function makeEl(id: string, clip: string): HTMLElement {
|
|
|
35
35
|
|
|
36
36
|
function render(
|
|
37
37
|
el: HTMLElement,
|
|
38
|
-
onStyleCommit: (property: string, value: string) => Promise<
|
|
38
|
+
onStyleCommit: (property: string, value: string) => Promise<unknown> | void = () => undefined,
|
|
39
39
|
): { root: Root; rerender: (next: HTMLElement) => void } {
|
|
40
40
|
const host = document.createElement("div");
|
|
41
41
|
document.body.append(host);
|
|
@@ -28,7 +28,7 @@ interface CropGestureState {
|
|
|
28
28
|
interface DomEditCropHandlesProps {
|
|
29
29
|
selection: DomEditSelection;
|
|
30
30
|
overlayRect: OverlayRect;
|
|
31
|
-
onStyleCommit?: (property: string, value: string) => Promise<
|
|
31
|
+
onStyleCommit?: (property: string, value: string) => Promise<unknown> | void;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
// Hit-strip size (px) for an edge crop handle: THICKNESS extends outward from
|
|
@@ -83,7 +83,7 @@ interface DomEditOverlayProps {
|
|
|
83
83
|
restore?: () => void,
|
|
84
84
|
) => Promise<void> | void;
|
|
85
85
|
onRotationCommit: (selection: DomEditSelection, next: { angle: number }) => Promise<void> | void;
|
|
86
|
-
onStyleCommit?: (property: string, value: string) => Promise<
|
|
86
|
+
onStyleCommit?: (property: string, value: string) => Promise<unknown> | void;
|
|
87
87
|
gridVisible?: boolean;
|
|
88
88
|
gridSpacing?: number;
|
|
89
89
|
recordingState?: GestureRecordingState;
|
|
@@ -123,7 +123,7 @@ interface DomEditSelectionChromeProps {
|
|
|
123
123
|
groupSelectionCount: number;
|
|
124
124
|
blockedMoveRef: RefObject<BlockedMoveState | null>;
|
|
125
125
|
gestures: GestureHandlers;
|
|
126
|
-
onStyleCommit?: (property: string, value: string) => Promise<
|
|
126
|
+
onStyleCommit?: (property: string, value: string) => Promise<unknown> | void;
|
|
127
127
|
onBoxMouseDown: (e: React.MouseEvent) => void;
|
|
128
128
|
onBoxClick: (event: React.MouseEvent<HTMLDivElement>) => void;
|
|
129
129
|
/** The canvas' text-editing session: what opens one, and whether one is open. */
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
2
|
|
|
3
3
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { DomEditSaveQueueOpenError } from "../../utils/domEditSaveQueue";
|
|
4
5
|
import type { DomEditSelection } from "./domEditing";
|
|
5
6
|
import type { GestureState, UseDomEditOverlayGesturesOptions } from "./domEditOverlayGestures";
|
|
6
7
|
|
|
@@ -75,7 +76,9 @@ interface CommitCall {
|
|
|
75
76
|
offset: { x: number; y: number } | undefined;
|
|
76
77
|
}
|
|
77
78
|
|
|
78
|
-
function buildHarness(
|
|
79
|
+
function buildHarness(
|
|
80
|
+
onBoxSizeCommit?: UseDomEditOverlayGesturesOptions["onBoxSizeCommitRef"]["current"],
|
|
81
|
+
) {
|
|
79
82
|
const element = document.createElement("div");
|
|
80
83
|
document.body.append(element);
|
|
81
84
|
|
|
@@ -136,9 +139,12 @@ function buildHarness() {
|
|
|
136
139
|
onManualDragStartRef: ref(() => {}),
|
|
137
140
|
onPathOffsetCommitRef: ref(() => {}),
|
|
138
141
|
onGroupPathOffsetCommitRef: ref(() => {}),
|
|
139
|
-
onBoxSizeCommitRef: ref(
|
|
140
|
-
|
|
141
|
-
|
|
142
|
+
onBoxSizeCommitRef: ref(
|
|
143
|
+
onBoxSizeCommit ??
|
|
144
|
+
((_s, size, offset) => {
|
|
145
|
+
commits.push({ size, offset });
|
|
146
|
+
}),
|
|
147
|
+
),
|
|
142
148
|
onRotationCommitRef: ref(() => {}),
|
|
143
149
|
onCanvasPointerMoveRef: ref(() => Promise.resolve(null)),
|
|
144
150
|
onCanvasMouseDown: () => {},
|
|
@@ -172,6 +178,15 @@ function evt(clientX: number, clientY: number) {
|
|
|
172
178
|
} as unknown as React.PointerEvent<HTMLDivElement>;
|
|
173
179
|
}
|
|
174
180
|
|
|
181
|
+
async function finishResize(handlers: ReturnType<typeof createDomEditOverlayGestureHandlers>) {
|
|
182
|
+
handlers.startGesture("resize", evt(ORIGIN_CENTER.x + 100, ORIGIN_CENTER.y), {
|
|
183
|
+
resizeHandle: "se",
|
|
184
|
+
});
|
|
185
|
+
handlers.onPointerMove(evt(ORIGIN_CENTER.x + 150, ORIGIN_CENTER.y));
|
|
186
|
+
handlers.onPointerUp(evt(ORIGIN_CENTER.x + 150, ORIGIN_CENTER.y));
|
|
187
|
+
await Promise.resolve();
|
|
188
|
+
}
|
|
189
|
+
|
|
175
190
|
afterEach(() => {
|
|
176
191
|
document.body.innerHTML = "";
|
|
177
192
|
});
|
|
@@ -211,4 +226,23 @@ describe("anchored corner resize — the release commit feeds the center-pin off
|
|
|
211
226
|
expect(offset.x).toBeCloseTo(-(size.width - ORIGIN.width) / 2, 0);
|
|
212
227
|
expect(offset.y).toBeCloseTo(-(size.height - ORIGIN.height) / 2, 0);
|
|
213
228
|
});
|
|
229
|
+
|
|
230
|
+
it("does not log a paused save queue as an ordinary resize failure", async () => {
|
|
231
|
+
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
232
|
+
const { handlers } = buildHarness(() => Promise.reject(new DomEditSaveQueueOpenError()));
|
|
233
|
+
|
|
234
|
+
await finishResize(handlers);
|
|
235
|
+
|
|
236
|
+
expect(consoleError).not.toHaveBeenCalled();
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("still logs an ordinary resize failure", async () => {
|
|
240
|
+
const failure = new Error("save failed");
|
|
241
|
+
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
242
|
+
const { handlers } = buildHarness(() => Promise.reject(failure));
|
|
243
|
+
|
|
244
|
+
await finishResize(handlers);
|
|
245
|
+
|
|
246
|
+
expect(consoleError).toHaveBeenCalledWith("resize commit failed", failure);
|
|
247
|
+
});
|
|
214
248
|
});
|
|
@@ -21,7 +21,7 @@ export function CommitField({
|
|
|
21
21
|
liveCommit?: boolean;
|
|
22
22
|
align?: "left" | "right";
|
|
23
23
|
onPreview?: (nextValue: string) => void;
|
|
24
|
-
onCommit: (nextValue: string) => void | Promise<
|
|
24
|
+
onCommit: (nextValue: string) => void | Promise<unknown>;
|
|
25
25
|
}) {
|
|
26
26
|
const [draft, setDraft] = useState(value);
|
|
27
27
|
const valueRef = useRef(value);
|
|
@@ -181,7 +181,7 @@ export function LayoutZIndexRow({
|
|
|
181
181
|
onSetStyle,
|
|
182
182
|
}: {
|
|
183
183
|
styles: Record<string, string>;
|
|
184
|
-
onSetStyle: (prop: string, value: string) => void | Promise<
|
|
184
|
+
onSetStyle: (prop: string, value: string) => void | Promise<unknown>;
|
|
185
185
|
}) {
|
|
186
186
|
const zIndex = String(parseInt(styles["z-index"] || "auto", 10) || 0);
|
|
187
187
|
return (
|
|
@@ -200,7 +200,7 @@ export function LayoutFlexBlock({
|
|
|
200
200
|
disabled,
|
|
201
201
|
}: {
|
|
202
202
|
styles: Record<string, string>;
|
|
203
|
-
onSetStyle: (prop: string, value: string) => void | Promise<
|
|
203
|
+
onSetStyle: (prop: string, value: string) => void | Promise<unknown>;
|
|
204
204
|
disabled: boolean;
|
|
205
205
|
}) {
|
|
206
206
|
const isFlex = styles.display === "flex" || styles.display === "inline-flex";
|
|
@@ -337,7 +337,7 @@ interface FlatLayoutSectionProps
|
|
|
337
337
|
> {
|
|
338
338
|
element: DomEditSelection;
|
|
339
339
|
styles: Record<string, string>;
|
|
340
|
-
onSetStyle: (prop: string, value: string) => void | Promise<
|
|
340
|
+
onSetStyle: (prop: string, value: string) => void | Promise<unknown>;
|
|
341
341
|
disabled: boolean;
|
|
342
342
|
}
|
|
343
343
|
|
|
@@ -27,7 +27,7 @@ export function FlatMaskInsetRows({
|
|
|
27
27
|
clipPathValue: string;
|
|
28
28
|
radiusValue: number;
|
|
29
29
|
disabled: boolean;
|
|
30
|
-
onSetStyle: (prop: string, value: string) => void | Promise<
|
|
30
|
+
onSetStyle: (prop: string, value: string) => void | Promise<unknown>;
|
|
31
31
|
}) {
|
|
32
32
|
const clipPathPreset = inferClipPathPreset(clipPathValue);
|
|
33
33
|
const parsedClipInsets = parseInsetClipPathSides(clipPathValue);
|
|
@@ -38,7 +38,7 @@ export function FlatMediaSection({
|
|
|
38
38
|
projectDir: string | null;
|
|
39
39
|
element: DomEditSelection;
|
|
40
40
|
styles: Record<string, string>;
|
|
41
|
-
onSetStyle: (prop: string, value: string) => void | Promise<
|
|
41
|
+
onSetStyle: (prop: string, value: string) => void | Promise<unknown>;
|
|
42
42
|
onSetAttribute: (attr: string, value: string) => void | Promise<void>;
|
|
43
43
|
onSetHtmlAttribute: (attr: string, value: string | null) => void | Promise<void>;
|
|
44
44
|
/** A volume lane in the timeline drives the level; the slider cannot. */
|
|
@@ -35,7 +35,7 @@ export function FlatRow({
|
|
|
35
35
|
/** Renders a trailing 10px caret-down, for select-backed rows. */
|
|
36
36
|
dropdown?: boolean;
|
|
37
37
|
onPreview?: (nextValue: string) => void;
|
|
38
|
-
onCommit: (nextValue: string) => void | Promise<
|
|
38
|
+
onCommit: (nextValue: string) => void | Promise<unknown>;
|
|
39
39
|
onReset?: () => void;
|
|
40
40
|
}) {
|
|
41
41
|
const track = useTrackDesignInput();
|
|
@@ -50,7 +50,7 @@ function FlatFillFields({
|
|
|
50
50
|
element: DomEditSelection;
|
|
51
51
|
styles: Record<string, string>;
|
|
52
52
|
assets: string[];
|
|
53
|
-
onSetStyle: (prop: string, value: string) => void | Promise<
|
|
53
|
+
onSetStyle: (prop: string, value: string) => void | Promise<unknown>;
|
|
54
54
|
onPreviewStyle?: (prop: string, value: string) => void;
|
|
55
55
|
onImportAssets?: (files: FileList) => Promise<string[]>;
|
|
56
56
|
}) {
|
|
@@ -156,7 +156,7 @@ function FlatStrokeRow({
|
|
|
156
156
|
}: {
|
|
157
157
|
styles: Record<string, string>;
|
|
158
158
|
disabled: boolean;
|
|
159
|
-
onSetStyle: (prop: string, value: string) => void | Promise<
|
|
159
|
+
onSetStyle: (prop: string, value: string) => void | Promise<unknown>;
|
|
160
160
|
}) {
|
|
161
161
|
const borderWidthValue =
|
|
162
162
|
parsePxMetricValue(styles["border-width"] ?? "") ??
|
|
@@ -236,7 +236,7 @@ function FlatRadiusRow({
|
|
|
236
236
|
styles: Record<string, string>;
|
|
237
237
|
gsapBorderRadius?: { tl: number; tr: number; br: number; bl: number } | null;
|
|
238
238
|
disabled: boolean;
|
|
239
|
-
onSetStyle: (prop: string, value: string) => void | Promise<
|
|
239
|
+
onSetStyle: (prop: string, value: string) => void | Promise<unknown>;
|
|
240
240
|
}) {
|
|
241
241
|
const radiusValue = parseNumericValue(styles["border-radius"]) ?? 0;
|
|
242
242
|
const radiusTL =
|
|
@@ -286,7 +286,7 @@ function FlatShadowBlendRows({
|
|
|
286
286
|
}: {
|
|
287
287
|
styles: Record<string, string>;
|
|
288
288
|
disabled: boolean;
|
|
289
|
-
onSetStyle: (prop: string, value: string) => void | Promise<
|
|
289
|
+
onSetStyle: (prop: string, value: string) => void | Promise<unknown>;
|
|
290
290
|
}) {
|
|
291
291
|
const boxShadowPreset = inferBoxShadowPreset(styles["box-shadow"]);
|
|
292
292
|
const blendValue = styles["mix-blend-mode"] || "normal";
|
|
@@ -332,7 +332,7 @@ function FlatBlurSliders({
|
|
|
332
332
|
}: {
|
|
333
333
|
styles: Record<string, string>;
|
|
334
334
|
disabled: boolean;
|
|
335
|
-
onSetStyle: (prop: string, value: string) => void | Promise<
|
|
335
|
+
onSetStyle: (prop: string, value: string) => void | Promise<unknown>;
|
|
336
336
|
}) {
|
|
337
337
|
const filterBlurValue = getCssFilterFunctionPx(styles.filter, "blur");
|
|
338
338
|
const backdropBlurValue = getCssFilterFunctionPx(styles["backdrop-filter"], "blur");
|
|
@@ -378,7 +378,7 @@ function FlatOverflowMaskRows({
|
|
|
378
378
|
}: {
|
|
379
379
|
styles: Record<string, string>;
|
|
380
380
|
disabled: boolean;
|
|
381
|
-
onSetStyle: (prop: string, value: string) => void | Promise<
|
|
381
|
+
onSetStyle: (prop: string, value: string) => void | Promise<unknown>;
|
|
382
382
|
}) {
|
|
383
383
|
const radiusValue = parseNumericValue(styles["border-radius"]) ?? 0;
|
|
384
384
|
const clipPathValue = styles["clip-path"] || "none";
|
|
@@ -432,7 +432,7 @@ function FlatOpacitySlider({
|
|
|
432
432
|
}: {
|
|
433
433
|
styles: Record<string, string>;
|
|
434
434
|
disabled: boolean;
|
|
435
|
-
onSetStyle: (prop: string, value: string) => void | Promise<
|
|
435
|
+
onSetStyle: (prop: string, value: string) => void | Promise<unknown>;
|
|
436
436
|
}) {
|
|
437
437
|
const opacityValue = Math.round((parseNumericValue(styles.opacity) ?? 1) * 100);
|
|
438
438
|
|
|
@@ -464,7 +464,7 @@ export function FlatStyleSection({
|
|
|
464
464
|
element: DomEditSelection;
|
|
465
465
|
styles: Record<string, string>;
|
|
466
466
|
assets: string[];
|
|
467
|
-
onSetStyle: (prop: string, value: string) => void | Promise<
|
|
467
|
+
onSetStyle: (prop: string, value: string) => void | Promise<unknown>;
|
|
468
468
|
onPreviewStyle?: (prop: string, value: string) => void;
|
|
469
469
|
onImportAssets?: (files: FileList) => Promise<string[]>;
|
|
470
470
|
gsapBorderRadius?: { tl: number; tr: number; br: number; bl: number } | null;
|
|
@@ -35,7 +35,7 @@ export function MediaSection({
|
|
|
35
35
|
projectDir: string | null;
|
|
36
36
|
element: DomEditSelection;
|
|
37
37
|
styles: Record<string, string>;
|
|
38
|
-
onSetStyle: (prop: string, value: string) => void | Promise<
|
|
38
|
+
onSetStyle: (prop: string, value: string) => void | Promise<unknown>;
|
|
39
39
|
onSetAttribute: (attr: string, value: string) => void | Promise<void>;
|
|
40
40
|
onSetHtmlAttribute: (attr: string, value: string | null) => void | Promise<void>;
|
|
41
41
|
onRemoveBackground?: (
|
|
@@ -29,7 +29,7 @@ export function MetricField({
|
|
|
29
29
|
scrub?: boolean;
|
|
30
30
|
suffix?: string;
|
|
31
31
|
tooltip?: string;
|
|
32
|
-
onCommit: (nextValue: string) => void | Promise<
|
|
32
|
+
onCommit: (nextValue: string) => void | Promise<unknown>;
|
|
33
33
|
}) {
|
|
34
34
|
const track = useTrackDesignInput();
|
|
35
35
|
const scrubRef = useRef<{ startX: number; startValue: number; pointerId: number } | null>(null);
|
|
@@ -53,7 +53,7 @@ export function StyleSections({
|
|
|
53
53
|
element: DomEditSelection;
|
|
54
54
|
styles: Record<string, string>;
|
|
55
55
|
assets: string[];
|
|
56
|
-
onSetStyle: (prop: string, value: string) => void | Promise<
|
|
56
|
+
onSetStyle: (prop: string, value: string) => void | Promise<unknown>;
|
|
57
57
|
onImportAssets?: (files: FileList) => Promise<string[]>;
|
|
58
58
|
gsapBorderRadius?: { tl: number; tr: number; br: number; bl: number } | null;
|
|
59
59
|
// When true, the Flex `Section` is suppressed. The flat inspector renders
|
|
@@ -44,7 +44,7 @@ export interface PropertyPanelProps {
|
|
|
44
44
|
copiedAgentPrompt: boolean;
|
|
45
45
|
onClearSelection: () => void;
|
|
46
46
|
onUngroup?: () => void;
|
|
47
|
-
onSetStyle: (prop: string, value: string) => void | Promise<
|
|
47
|
+
onSetStyle: (prop: string, value: string) => void | Promise<unknown>;
|
|
48
48
|
onPreviewStyle?: (prop: string, value: string) => void;
|
|
49
49
|
onSetAttribute: (attr: string, value: string) => void | Promise<void>;
|
|
50
50
|
/** Commits several data-* attributes on the SAME element in ONE atomic
|
|
@@ -58,6 +58,12 @@ import {
|
|
|
58
58
|
import { logResize, logResizeMove, logResizeSettle } from "../../utils/resizeDebug";
|
|
59
59
|
import { logDrag, logDragSettle, readDragPositions } from "../../utils/dragDebug";
|
|
60
60
|
import { createGroupDragMover } from "./groupDragMove";
|
|
61
|
+
import { DomEditSaveQueueOpenError } from "../../utils/domEditSaveQueue";
|
|
62
|
+
|
|
63
|
+
function logGestureCommitFailure(message: string, error: unknown): void {
|
|
64
|
+
if (error instanceof DomEditSaveQueueOpenError) return;
|
|
65
|
+
console.error(message, error);
|
|
66
|
+
}
|
|
61
67
|
|
|
62
68
|
export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGesturesOptions) {
|
|
63
69
|
const setDraftOverlayRect = (next: OverlayRect) => {
|
|
@@ -409,7 +415,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
|
|
|
409
415
|
}
|
|
410
416
|
void Promise.resolve(opts.onRotationCommitRef.current(sel, finalRotation))
|
|
411
417
|
.catch((error) => {
|
|
412
|
-
|
|
418
|
+
logGestureCommitFailure("rotate commit failed", error);
|
|
413
419
|
if (
|
|
414
420
|
g.manualEditDragToken &&
|
|
415
421
|
isStudioManualEditGestureCurrent(sel.element, g.manualEditDragToken)
|
|
@@ -493,7 +499,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
|
|
|
493
499
|
opts.onBoxSizeCommitRef.current(sel, finalSize, finalOffset ?? undefined, restore),
|
|
494
500
|
)
|
|
495
501
|
.catch((error) => {
|
|
496
|
-
|
|
502
|
+
logGestureCommitFailure("resize commit failed", error);
|
|
497
503
|
})
|
|
498
504
|
.finally(() => {
|
|
499
505
|
if (member) endManualOffsetDragMembers([member]);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
2
|
|
|
3
|
-
function isPromiseCommit(result: void | Promise<
|
|
3
|
+
function isPromiseCommit(result: void | Promise<unknown>): result is Promise<unknown> {
|
|
4
4
|
return Boolean(result && typeof result.then === "function");
|
|
5
5
|
}
|
|
6
6
|
|
|
@@ -12,7 +12,7 @@ export function useInspectorGestureTransaction<T>({
|
|
|
12
12
|
}: {
|
|
13
13
|
sourceValue: T;
|
|
14
14
|
onPreview: (value: T) => void;
|
|
15
|
-
onCommit: (value: T) => void | Promise<
|
|
15
|
+
onCommit: (value: T) => void | Promise<unknown>;
|
|
16
16
|
}) {
|
|
17
17
|
const sourceRef = useRef(sourceValue);
|
|
18
18
|
const activeRef = useRef<{ before: T; latest: T } | null>(null);
|
|
@@ -122,7 +122,7 @@ export function useInspectorGestureDraft<T>({
|
|
|
122
122
|
}: {
|
|
123
123
|
sourceValue: T;
|
|
124
124
|
onPreview: (value: T) => void;
|
|
125
|
-
onCommit: (value: T) => void | Promise<
|
|
125
|
+
onCommit: (value: T) => void | Promise<unknown>;
|
|
126
126
|
}) {
|
|
127
127
|
const [draft, setDraft] = useState(sourceValue);
|
|
128
128
|
const transaction = useInspectorGestureTransaction({
|
|
@@ -55,3 +55,50 @@ export async function runDomEditCommit(config: DomEditCommitRunnerConfig): Promi
|
|
|
55
55
|
if (!config.shouldResync()) return;
|
|
56
56
|
await config.resync();
|
|
57
57
|
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Why a DOM edit commit did not change the file.
|
|
61
|
+
*
|
|
62
|
+
* `runDomEditCommit` resolves on persist failure by design (see its contract
|
|
63
|
+
* above), so a caller cannot learn whether the write landed by awaiting it — a
|
|
64
|
+
* failed persist and a successful one are indistinguishable. Capture and apply
|
|
65
|
+
* bugs still reject. The human path does not need to ask about handled persist
|
|
66
|
+
* failures, because `onError` already put a toast on screen. A programmatic
|
|
67
|
+
* caller has no screen, so it has to be told.
|
|
68
|
+
*/
|
|
69
|
+
export type DomEditCommitDeclineReason =
|
|
70
|
+
| "no-project"
|
|
71
|
+
| "no-selection"
|
|
72
|
+
| "geometry-property"
|
|
73
|
+
| "styles-not-editable"
|
|
74
|
+
| "not-text-editable"
|
|
75
|
+
| "preview-stale"
|
|
76
|
+
| "persist-failed";
|
|
77
|
+
|
|
78
|
+
export type DomEditCommitOutcome = { ok: true } | { ok: false; reason: DomEditCommitDeclineReason };
|
|
79
|
+
|
|
80
|
+
export function domEditCommitDeclined(reason: DomEditCommitDeclineReason): DomEditCommitOutcome {
|
|
81
|
+
return { ok: false, reason };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* `runDomEditCommit`, reporting whether the write actually landed.
|
|
86
|
+
*
|
|
87
|
+
* Owns `onSettled` to do it, and forwards to a caller-supplied one rather than
|
|
88
|
+
* dropping it. `runDomEditCommit` calls `onSettled` exactly once on both the
|
|
89
|
+
* success and the failure path, so the flag is always set by the time it
|
|
90
|
+
* resolves.
|
|
91
|
+
*/
|
|
92
|
+
export async function runReportedDomEditCommit(
|
|
93
|
+
config: DomEditCommitRunnerConfig,
|
|
94
|
+
): Promise<DomEditCommitOutcome> {
|
|
95
|
+
let landed = false;
|
|
96
|
+
await runDomEditCommit({
|
|
97
|
+
...config,
|
|
98
|
+
onSettled: (ok) => {
|
|
99
|
+
landed = ok;
|
|
100
|
+
config.onSettled?.(ok);
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
return landed ? { ok: true } : domEditCommitDeclined("persist-failed");
|
|
104
|
+
}
|
|
@@ -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
|
-
|
|
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",
|