@hyperframes/studio 0.8.16 → 0.8.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{hyperframes-player-iAIHATIw.js → hyperframes-player-DzRNJZAz.js} +1 -1
- package/dist/assets/{index-Cf-mbMRL.js → index-B4qse6wy.js} +1 -1
- package/dist/assets/index-BX3KHhGX.js +71 -0
- package/dist/assets/{index-D8o3ZIo2.js → index-F-PUkOVc.js} +128 -128
- package/dist/assets/{index-YmetcS6L.js → index-SGl0bb71.js} +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.html +1 -1
- package/dist/index.js +1640 -1282
- 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/components/nle/useCompositionStack.test.tsx +69 -0
- package/src/components/nle/useCompositionStack.ts +8 -2
- 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.18",
|
|
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/core": "0.8.
|
|
51
|
-
"@hyperframes/parsers": "0.8.
|
|
52
|
-
"@hyperframes/player": "0.8.
|
|
53
|
-
"@hyperframes/
|
|
54
|
-
"@hyperframes/
|
|
51
|
+
"@hyperframes/core": "0.8.18",
|
|
52
|
+
"@hyperframes/parsers": "0.8.18",
|
|
53
|
+
"@hyperframes/player": "0.8.18",
|
|
54
|
+
"@hyperframes/sdk": "0.8.18",
|
|
55
|
+
"@hyperframes/studio-server": "0.8.18"
|
|
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.18"
|
|
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({
|
|
@@ -42,3 +42,72 @@ describe("useCompositionStack — project scoping", () => {
|
|
|
42
42
|
});
|
|
43
43
|
}
|
|
44
44
|
});
|
|
45
|
+
|
|
46
|
+
describe("useCompositionStack — activating a composition by path", () => {
|
|
47
|
+
afterEach(() => {
|
|
48
|
+
document.body.innerHTML = "";
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
function mountStack(activeCompositionPath: string | null) {
|
|
52
|
+
const host = document.createElement("div");
|
|
53
|
+
document.body.append(host);
|
|
54
|
+
const root = createRoot(host);
|
|
55
|
+
const seen: { stack: ReturnType<typeof useCompositionStack>["compositionStack"] } = {
|
|
56
|
+
stack: [],
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
function Harness(props: { activeCompositionPath: string | null }) {
|
|
60
|
+
seen.stack = useCompositionStack({ projectId: "p", ...props }).compositionStack;
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return { root, seen, Harness, activeCompositionPath };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// The root stays on the master level; everything else pushes a second level.
|
|
68
|
+
for (const path of [
|
|
69
|
+
"compositions/scene-a.html",
|
|
70
|
+
"parts/part-1.html",
|
|
71
|
+
"chapter-2.html",
|
|
72
|
+
"a/b/c/deep.html",
|
|
73
|
+
]) {
|
|
74
|
+
it(`pushes a level for ${path}`, async () => {
|
|
75
|
+
const { root, seen, Harness } = mountStack(path);
|
|
76
|
+
|
|
77
|
+
await act(async () => {
|
|
78
|
+
root.render(<Harness activeCompositionPath={path} />);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
expect(seen.stack).toHaveLength(2);
|
|
82
|
+
expect(seen.stack[1]?.id).toBe(path);
|
|
83
|
+
expect(seen.stack[1]?.previewUrl).toBe(`/api/projects/p/preview/comp/${path}`);
|
|
84
|
+
|
|
85
|
+
act(() => root.unmount());
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
it("labels a non-compositions/ path without mangling it", async () => {
|
|
90
|
+
const { root, seen, Harness } = mountStack("parts/part-1.html");
|
|
91
|
+
|
|
92
|
+
await act(async () => {
|
|
93
|
+
root.render(<Harness activeCompositionPath="parts/part-1.html" />);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
expect(seen.stack[1]?.label).toBe("parts/part-1");
|
|
97
|
+
|
|
98
|
+
act(() => root.unmount());
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("keeps the master alone for the root composition", async () => {
|
|
102
|
+
const { root, seen, Harness } = mountStack("index.html");
|
|
103
|
+
|
|
104
|
+
await act(async () => {
|
|
105
|
+
root.render(<Harness activeCompositionPath="index.html" />);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
expect(seen.stack).toHaveLength(1);
|
|
109
|
+
expect(seen.stack[0]?.id).toBe("master");
|
|
110
|
+
|
|
111
|
+
act(() => root.unmount());
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -108,7 +108,13 @@ export function useCompositionStack({
|
|
|
108
108
|
if (activeCompositionPath === "index.html") {
|
|
109
109
|
usePlayerStore.getState().setElements([]);
|
|
110
110
|
updateCompositionStack([master]);
|
|
111
|
-
} else if (activeCompositionPath
|
|
111
|
+
} else if (activeCompositionPath) {
|
|
112
|
+
// Any composition file that isn't the root, wherever it lives. Gating
|
|
113
|
+
// this on a `compositions/` prefix meant a project laying its comps out
|
|
114
|
+
// anywhere else (`parts/part-1.html`, generated multi-part builds) hit
|
|
115
|
+
// no branch at all: the stack kept the master mounted while the Comps
|
|
116
|
+
// panel highlighted the row, so the canvas and timeline stayed on
|
|
117
|
+
// index.html and edits landed in the root file.
|
|
112
118
|
const label = activeCompositionPath.replace(/^compositions\//, "").replace(/\.html$/, "");
|
|
113
119
|
const previewUrl = `/api/projects/${projectId}/preview/comp/${encodePreviewPath(activeCompositionPath)}`;
|
|
114
120
|
usePlayerStore.getState().setElements([]);
|
|
@@ -116,7 +122,7 @@ export function useCompositionStack({
|
|
|
116
122
|
if (prev[prev.length - 1]?.id === activeCompositionPath) return prev;
|
|
117
123
|
return [master, { id: activeCompositionPath, label, previewUrl }];
|
|
118
124
|
});
|
|
119
|
-
} else
|
|
125
|
+
} else {
|
|
120
126
|
usePlayerStore.getState().setElements([]);
|
|
121
127
|
updateCompositionStack([master]);
|
|
122
128
|
}
|
|
@@ -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
|
+
}
|