@hyperframes/studio 0.8.15 → 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-DejBDgXB.js +459 -0
- package/dist/assets/index-BX3KHhGX.js +71 -0
- package/dist/assets/{index-DKoZIpw1.js → index-CU6o8PuW.js} +180 -180
- package/dist/assets/{index-CVtYYTnA.js → index-CklNVmi3.js} +1 -1
- package/dist/assets/{index-CtDg2lB7.js → index-uu4Zd3BU.js} +1 -1
- package/dist/index.d.ts +9 -13
- package/dist/index.html +1 -1
- package/dist/index.js +1660 -1284
- 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/sidebar/AudioRow.tsx +13 -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/useDomSelection.test.ts +48 -0
- package/src/hooks/useDomSelection.ts +26 -82
- package/src/hooks/useDomSelectionTypes.ts +82 -0
- 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/hooks/usePanelLayout.test.ts +38 -0
- package/src/hooks/usePanelLayout.ts +21 -6
- package/src/player/lib/playbackTypes.ts +3 -12
- package/src/player/lib/runtimeProtocol.test.ts +1 -0
- 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/dist/assets/hyperframes-player-BlIp1Y-b.js +0 -459
|
@@ -6,6 +6,8 @@ import { useElementLifecycleOps } from "./useElementLifecycleOps";
|
|
|
6
6
|
import { makeLifecycleOpsParams } from "./elementLifecycleOpsTestUtils";
|
|
7
7
|
import { mountReactHarness, makeSelection } from "./domSelectionTestHarness";
|
|
8
8
|
|
|
9
|
+
Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
|
|
10
|
+
|
|
9
11
|
function selectionFor(id: string) {
|
|
10
12
|
const el = document.createElement("div");
|
|
11
13
|
el.id = id;
|
|
@@ -13,28 +15,51 @@ function selectionFor(id: string) {
|
|
|
13
15
|
return { ...makeSelection(id, el), sourceFile: "index.html" };
|
|
14
16
|
}
|
|
15
17
|
|
|
18
|
+
function mountDeleteOps(overrides: Partial<Parameters<typeof useElementLifecycleOps>[0]> = {}) {
|
|
19
|
+
const captured: { ops: ReturnType<typeof useElementLifecycleOps> | null } = { ops: null };
|
|
20
|
+
function Probe() {
|
|
21
|
+
captured.ops = useElementLifecycleOps(
|
|
22
|
+
makeLifecycleOpsParams({
|
|
23
|
+
commitDomEditPatchBatches: vi.fn(async () => ({ ok: true }) as never),
|
|
24
|
+
...overrides,
|
|
25
|
+
}),
|
|
26
|
+
);
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
mountReactHarness(<Probe />);
|
|
30
|
+
if (!captured.ops) throw new Error("hook did not initialize");
|
|
31
|
+
return captured.ops;
|
|
32
|
+
}
|
|
33
|
+
|
|
16
34
|
describe("useElementLifecycleOps — deleting a canvas multi-selection", () => {
|
|
17
35
|
const removed: string[] = [];
|
|
18
36
|
const requests: string[] = [];
|
|
19
37
|
let changes = true;
|
|
38
|
+
let removeOk = true;
|
|
20
39
|
|
|
21
40
|
beforeEach(() => {
|
|
22
41
|
removed.length = 0;
|
|
23
42
|
requests.length = 0;
|
|
24
43
|
changes = true;
|
|
44
|
+
removeOk = true;
|
|
25
45
|
vi.stubGlobal(
|
|
26
46
|
"fetch",
|
|
27
47
|
vi.fn(async (url: string, init?: RequestInit) => {
|
|
28
|
-
|
|
48
|
+
const requestUrl = String(url);
|
|
49
|
+
requests.push(requestUrl);
|
|
29
50
|
const body = JSON.parse(String(init?.body ?? "{}")) as {
|
|
30
51
|
targets?: { id?: string; selector?: string }[];
|
|
31
52
|
};
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
53
|
+
const keys = (body.targets ?? [])
|
|
54
|
+
.map((target) => target.id ?? target.selector)
|
|
55
|
+
.filter((key): key is string => key !== undefined);
|
|
56
|
+
removed.push(...keys);
|
|
57
|
+
const isRemove = requestUrl.includes("/file-mutations/remove-elements/");
|
|
58
|
+
const status = isRemove && !removeOk ? 500 : 200;
|
|
36
59
|
return {
|
|
37
|
-
ok:
|
|
60
|
+
ok: status === 200,
|
|
61
|
+
status,
|
|
62
|
+
text: async () => (status === 200 ? "" : "server said no"),
|
|
38
63
|
json: async () => ({ changed: changes, content: "<html></html>" }),
|
|
39
64
|
} as unknown as Response;
|
|
40
65
|
}),
|
|
@@ -48,21 +73,12 @@ describe("useElementLifecycleOps — deleting a canvas multi-selection", () => {
|
|
|
48
73
|
it("removes every selected element, not just the first", async () => {
|
|
49
74
|
// The reported bug: select several elements on the canvas, press Delete, and
|
|
50
75
|
// one disappears while the rest stay — still drawn as selected.
|
|
51
|
-
|
|
52
|
-
function Probe() {
|
|
53
|
-
ops = useElementLifecycleOps(
|
|
54
|
-
makeLifecycleOpsParams({
|
|
55
|
-
commitDomEditPatchBatches: vi.fn(async () => ({ ok: true }) as never),
|
|
56
|
-
projectIdRef: { current: "p1" },
|
|
57
|
-
}),
|
|
58
|
-
);
|
|
59
|
-
return null;
|
|
60
|
-
}
|
|
61
|
-
mountReactHarness(<Probe />);
|
|
76
|
+
const ops = mountDeleteOps({ projectIdRef: { current: "p1" } });
|
|
62
77
|
|
|
63
78
|
const selections = ["a", "b", "c"].map(selectionFor);
|
|
79
|
+
let outcome: unknown;
|
|
64
80
|
await act(async () => {
|
|
65
|
-
await ops
|
|
81
|
+
outcome = await ops.handleDomEditElementsDelete(selections);
|
|
66
82
|
});
|
|
67
83
|
|
|
68
84
|
// The defect: only the first was ever removed.
|
|
@@ -70,6 +86,49 @@ describe("useElementLifecycleOps — deleting a canvas multi-selection", () => {
|
|
|
70
86
|
// And one request for the selection, not one per member: a canvas selection
|
|
71
87
|
// runs to hundreds, and a round trip each made Delete look like a no-op.
|
|
72
88
|
expect(requests.filter((url) => url.includes("remove-elements"))).toHaveLength(1);
|
|
89
|
+
expect(outcome).toEqual({ ok: true });
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("reports a successful SDK delete as landed", async () => {
|
|
93
|
+
const ops = mountDeleteOps({
|
|
94
|
+
projectIdRef: { current: "p1" },
|
|
95
|
+
onTrySdkDelete: vi.fn(async () => ({ status: "committed", version: "v1" }) as const),
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const target = { ...selectionFor("a"), hfId: "hf-a" };
|
|
99
|
+
let outcome: unknown;
|
|
100
|
+
await act(async () => {
|
|
101
|
+
outcome = await ops.handleDomEditElementsDelete([target]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
expect(outcome).toEqual({ ok: true });
|
|
105
|
+
expect(requests.some((url) => url.includes("remove-elements"))).toBe(false);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("reports missing project and selection without starting a request", async () => {
|
|
109
|
+
const projectIdRef = { current: null as string | null };
|
|
110
|
+
const ops = mountDeleteOps({ projectIdRef });
|
|
111
|
+
|
|
112
|
+
await expect(ops.handleDomEditElementsDelete([selectionFor("a")])).resolves.toEqual({
|
|
113
|
+
ok: false,
|
|
114
|
+
reason: "no-project",
|
|
115
|
+
});
|
|
116
|
+
projectIdRef.current = "p1";
|
|
117
|
+
await expect(ops.handleDomEditElementsDelete([])).resolves.toEqual({
|
|
118
|
+
ok: false,
|
|
119
|
+
reason: "no-selection",
|
|
120
|
+
});
|
|
121
|
+
expect(requests).toEqual([]);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("reports an HTTP write failure instead of only toasting", async () => {
|
|
125
|
+
removeOk = false;
|
|
126
|
+
const ops = mountDeleteOps({ projectIdRef: { current: "p1" } });
|
|
127
|
+
|
|
128
|
+
await expect(ops.handleDomEditElementsDelete([selectionFor("a")])).resolves.toEqual({
|
|
129
|
+
ok: false,
|
|
130
|
+
reason: "persist-failed",
|
|
131
|
+
});
|
|
73
132
|
});
|
|
74
133
|
|
|
75
134
|
it("says so when the preview is stale instead of claiming a delete", async () => {
|
|
@@ -78,23 +137,14 @@ describe("useElementLifecycleOps — deleting a canvas multi-selection", () => {
|
|
|
78
137
|
// nothing at all, with nothing on screen to explain it.
|
|
79
138
|
changes = false;
|
|
80
139
|
const showToast = vi.fn();
|
|
81
|
-
|
|
82
|
-
function Probe() {
|
|
83
|
-
ops = useElementLifecycleOps(
|
|
84
|
-
makeLifecycleOpsParams({
|
|
85
|
-
commitDomEditPatchBatches: vi.fn(async () => ({ ok: true }) as never),
|
|
86
|
-
projectIdRef: { current: "p1" },
|
|
87
|
-
showToast,
|
|
88
|
-
}),
|
|
89
|
-
);
|
|
90
|
-
return null;
|
|
91
|
-
}
|
|
92
|
-
mountReactHarness(<Probe />);
|
|
140
|
+
const ops = mountDeleteOps({ projectIdRef: { current: "p1" }, showToast });
|
|
93
141
|
|
|
142
|
+
let outcome: unknown;
|
|
94
143
|
await act(async () => {
|
|
95
|
-
await ops
|
|
144
|
+
outcome = await ops.handleDomEditElementsDelete([selectionFor("a")]);
|
|
96
145
|
});
|
|
97
146
|
|
|
98
147
|
expect(showToast.mock.calls.flat().join(" ")).toContain("out of date");
|
|
148
|
+
expect(outcome).toEqual({ ok: false, reason: "preview-stale" });
|
|
99
149
|
});
|
|
100
150
|
});
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
type LayerRevealCommitOwnership,
|
|
21
21
|
} from "../components/editor/useLayerRevealOverride";
|
|
22
22
|
import type { CommitDomEditPatchBatches, DomEditPatchBatch } from "./domEditCommitTypes";
|
|
23
|
+
import { domEditCommitDeclined, type DomEditCommitOutcome } from "./domEditCommitRunner";
|
|
23
24
|
import { cutoverCommittedOrThrow, type CutoverResult } from "../utils/sdkCutover";
|
|
24
25
|
import { studioWriteHeaders } from "../utils/studioFileVersion";
|
|
25
26
|
|
|
@@ -87,11 +88,11 @@ export function useElementLifecycleOps({
|
|
|
87
88
|
// fallow-ignore-next-line complexity
|
|
88
89
|
const handleDomEditElementsDelete = useCallback(
|
|
89
90
|
// fallow-ignore-next-line complexity
|
|
90
|
-
async (selections: DomEditSelection[]) => {
|
|
91
|
+
async (selections: DomEditSelection[]): Promise<DomEditCommitOutcome> => {
|
|
91
92
|
const pid = projectIdRef.current;
|
|
92
|
-
if (!pid) return;
|
|
93
|
+
if (!pid) return domEditCommitDeclined("no-project");
|
|
93
94
|
const [selection] = selections;
|
|
94
|
-
if (!selection) return;
|
|
95
|
+
if (!selection) return domEditCommitDeclined("no-selection");
|
|
95
96
|
const label =
|
|
96
97
|
selections.length === 1
|
|
97
98
|
? selection.label || selection.id || selection.selector || selection.tagName
|
|
@@ -141,7 +142,7 @@ export function useElementLifecycleOps({
|
|
|
141
142
|
`Deleted ${label}. Use Undo to restore ${sameFile.length === 1 ? "it" : "them"}.`,
|
|
142
143
|
"info",
|
|
143
144
|
);
|
|
144
|
-
return;
|
|
145
|
+
return { ok: true } as const;
|
|
145
146
|
}
|
|
146
147
|
}
|
|
147
148
|
|
|
@@ -174,7 +175,8 @@ export function useElementLifecycleOps({
|
|
|
174
175
|
// matching at all means the preview is describing a document the file
|
|
175
176
|
// does not have — say so rather than reporting a delete that happened.
|
|
176
177
|
reloadPreview();
|
|
177
|
-
|
|
178
|
+
showToast("Nothing to delete, the preview was out of date. Try again.");
|
|
179
|
+
return domEditCommitDeclined("preview-stale");
|
|
178
180
|
}
|
|
179
181
|
const patchedContent =
|
|
180
182
|
typeof removeData.content === "string" ? removeData.content : originalContent;
|
|
@@ -208,9 +210,13 @@ export function useElementLifecycleOps({
|
|
|
208
210
|
`Deleted ${label}. Use Undo to restore ${sameFile.length === 1 ? "it" : "them"}.`,
|
|
209
211
|
"info",
|
|
210
212
|
);
|
|
213
|
+
return { ok: true } as const;
|
|
211
214
|
} catch (error) {
|
|
212
215
|
const message = error instanceof Error ? error.message : "Failed to delete element";
|
|
213
216
|
showToast(message);
|
|
217
|
+
// The toast is what tells the human. The returned outcome is what tells
|
|
218
|
+
// a caller that has no screen to read.
|
|
219
|
+
return domEditCommitDeclined("persist-failed");
|
|
214
220
|
}
|
|
215
221
|
},
|
|
216
222
|
[
|
|
@@ -111,7 +111,7 @@ export function useGsapSelectionHandlers({
|
|
|
111
111
|
) => Promise<void>;
|
|
112
112
|
removeAllKeyframes: (sel: DomEditSelection, animId: string) => Promise<void>;
|
|
113
113
|
|
|
114
|
-
handleDomManualEditsReset: (sel: DomEditSelection) => void
|
|
114
|
+
handleDomManualEditsReset: (sel: DomEditSelection) => Promise<void>;
|
|
115
115
|
selectedGsapAnimations: GsapAnimation[];
|
|
116
116
|
showToast: (message: string, tone?: "error" | "info") => void;
|
|
117
117
|
}) {
|
|
@@ -230,7 +230,9 @@ export function useGsapSelectionHandlers({
|
|
|
230
230
|
},
|
|
231
231
|
);
|
|
232
232
|
if (domEditSelection.element.hasAttribute("data-hf-studio-path-offset")) {
|
|
233
|
-
|
|
233
|
+
// The reset owns rollback and the position commit already owns user and
|
|
234
|
+
// telemetry reporting. This is only the fire-and-forget UI boundary.
|
|
235
|
+
void handleDomManualEditsReset(domEditSelection).catch(() => undefined);
|
|
234
236
|
}
|
|
235
237
|
},
|
|
236
238
|
[domEditSelection, addGsapAnimation, handleDomManualEditsReset, trackGsapHandlerFailure],
|
|
@@ -4,11 +4,15 @@ import React, { act } from "react";
|
|
|
4
4
|
import { createRoot } from "react-dom/client";
|
|
5
5
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
6
|
import { readStudioUiPreferences } from "../utils/studioUiPreferences";
|
|
7
|
+
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
7
8
|
import { usePanelLayout } from "./usePanelLayout";
|
|
8
9
|
|
|
9
10
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
10
11
|
|
|
12
|
+
vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
|
|
13
|
+
|
|
11
14
|
beforeEach(() => {
|
|
15
|
+
vi.mocked(trackStudioEvent).mockClear();
|
|
12
16
|
const entries = new Map<string, string>();
|
|
13
17
|
Object.defineProperty(window, "localStorage", {
|
|
14
18
|
configurable: true,
|
|
@@ -165,6 +169,29 @@ describe("usePanelLayout — right inspector panes", () => {
|
|
|
165
169
|
harness.unmount();
|
|
166
170
|
});
|
|
167
171
|
|
|
172
|
+
it("tracks only actual right-panel tab changes, including rapid repeated calls", () => {
|
|
173
|
+
const harness = renderPanelLayout();
|
|
174
|
+
|
|
175
|
+
act(() => {
|
|
176
|
+
harness.getState().setRightPanelTab("design");
|
|
177
|
+
harness.getState().setRightPanelTab("design");
|
|
178
|
+
});
|
|
179
|
+
expect(trackStudioEvent).not.toHaveBeenCalled();
|
|
180
|
+
|
|
181
|
+
act(() => {
|
|
182
|
+
harness.getState().setRightPanelTab("layers");
|
|
183
|
+
harness.getState().setRightPanelTab("layers");
|
|
184
|
+
});
|
|
185
|
+
expect(trackStudioEvent).toHaveBeenCalledOnce();
|
|
186
|
+
expect(trackStudioEvent).toHaveBeenCalledWith("tab_switch", {
|
|
187
|
+
panel: "right_panel",
|
|
188
|
+
tab: "layers",
|
|
189
|
+
});
|
|
190
|
+
expect(harness.getState().rightPanelTab).toBe("layers");
|
|
191
|
+
|
|
192
|
+
harness.unmount();
|
|
193
|
+
});
|
|
194
|
+
|
|
168
195
|
it("caps a panel relative to the window instead of at a flat 600px", () => {
|
|
169
196
|
resizeWindowTo(700);
|
|
170
197
|
const harness = renderPanelLayout();
|
|
@@ -289,6 +316,17 @@ describe("usePanelLayout — right inspector panes", () => {
|
|
|
289
316
|
const harness = renderPanelLayoutWith(usePanelLayoutFlatOn);
|
|
290
317
|
expect(harness.getState().rightInspectorPanes).toEqual({ layers: false, design: true });
|
|
291
318
|
|
|
319
|
+
// The flat inspector can change its visible pane without changing the
|
|
320
|
+
// umbrella rightPanelTab value. A later element selection must still bring
|
|
321
|
+
// Design back even though rightPanelTab already says "design".
|
|
322
|
+
act(() => harness.getState().setExclusiveRightInspectorPane("layers"));
|
|
323
|
+
expect(harness.getState()).toMatchObject({
|
|
324
|
+
rightPanelTab: "design",
|
|
325
|
+
rightInspectorPanes: { layers: true, design: false },
|
|
326
|
+
});
|
|
327
|
+
act(() => harness.getState().setRightPanelTab("design"));
|
|
328
|
+
expect(harness.getState().rightInspectorPanes).toEqual({ layers: false, design: true });
|
|
329
|
+
|
|
292
330
|
// Element-select / block-params-close / header Inspector-button callers
|
|
293
331
|
// all reach setRightPanelTab directly, not through the in-panel tab
|
|
294
332
|
// click's own setExclusiveRightInspectorPane call — this must still
|
|
@@ -56,9 +56,13 @@ export function usePanelLayout(initialState?: InitialPanelLayoutState) {
|
|
|
56
56
|
const [rightPanelTab, setRightPanelTab] = useState<RightPanelTab>(
|
|
57
57
|
initialState?.rightPanelTab ?? "design",
|
|
58
58
|
);
|
|
59
|
+
const rightPanelTabRef = useRef(rightPanelTab);
|
|
60
|
+
rightPanelTabRef.current = rightPanelTab;
|
|
59
61
|
const [rightInspectorPanes, setRightInspectorPanes] = useState<RightInspectorPanes>(() =>
|
|
60
62
|
getInitialRightInspectorPanes(initialState?.rightPanelTab),
|
|
61
63
|
);
|
|
64
|
+
const rightInspectorPanesRef = useRef(rightInspectorPanes);
|
|
65
|
+
rightInspectorPanesRef.current = rightInspectorPanes;
|
|
62
66
|
// Set when the user explicitly reopens a panel the window had auto-collapsed,
|
|
63
67
|
// so the rail cannot immediately swallow it again. Cleared once the window is
|
|
64
68
|
// wide enough that auto-collapse is no longer in play.
|
|
@@ -161,7 +165,9 @@ export function usePanelLayout(initialState?: InitialPanelLayoutState) {
|
|
|
161
165
|
|
|
162
166
|
const setRightCollapsedWithOverride = useCallback((collapsed: boolean) => {
|
|
163
167
|
setRightCollapsed(collapsed);
|
|
164
|
-
if (!collapsed)
|
|
168
|
+
if (!collapsed) {
|
|
169
|
+
setAutoCollapseOverride((prev) => (prev.right ? prev : { ...prev, right: true }));
|
|
170
|
+
}
|
|
165
171
|
}, []);
|
|
166
172
|
|
|
167
173
|
const handlePanelResizeStart = useCallback((side: PanelSide, e: React.PointerEvent) => {
|
|
@@ -192,6 +198,15 @@ export function usePanelLayout(initialState?: InitialPanelLayoutState) {
|
|
|
192
198
|
|
|
193
199
|
const trackedSetRightPanelTab = useCallback(
|
|
194
200
|
(tab: RightPanelTab) => {
|
|
201
|
+
const paneAlreadySelected =
|
|
202
|
+
tab !== "design" && tab !== "layers"
|
|
203
|
+
? true
|
|
204
|
+
: STUDIO_FLAT_INSPECTOR_ENABLED
|
|
205
|
+
? rightInspectorPanesRef.current[tab] &&
|
|
206
|
+
!rightInspectorPanesRef.current[tab === "design" ? "layers" : "design"]
|
|
207
|
+
: rightInspectorPanesRef.current[tab];
|
|
208
|
+
if (rightPanelTabRef.current === tab && paneAlreadySelected) return;
|
|
209
|
+
rightPanelTabRef.current = tab;
|
|
195
210
|
if (tab === "design" || tab === "layers") {
|
|
196
211
|
// Flat inspector: Layers always renders full-height by itself (see
|
|
197
212
|
// StudioRightPanel's render gate), so this MUST land on the same
|
|
@@ -202,11 +217,11 @@ export function usePanelLayout(initialState?: InitialPanelLayoutState) {
|
|
|
202
217
|
// inspector tab) would otherwise additively leave both panes `true`
|
|
203
218
|
// and reproduce the "both tabs highlight, only one renders" bug this
|
|
204
219
|
// still-additive branch used to cause under the flat flag.
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
);
|
|
220
|
+
const nextPanes = STUDIO_FLAT_INSPECTOR_ENABLED
|
|
221
|
+
? { design: tab === "design", layers: tab === "layers" }
|
|
222
|
+
: { ...rightInspectorPanesRef.current, [tab]: true };
|
|
223
|
+
rightInspectorPanesRef.current = nextPanes;
|
|
224
|
+
setRightInspectorPanes(nextPanes);
|
|
210
225
|
}
|
|
211
226
|
setRightPanelTab(tab);
|
|
212
227
|
trackStudioEvent("tab_switch", { panel: "right_panel", tab });
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* from here without creating circular dependencies.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import type { RuntimeTimelineClipIdentity } from "@hyperframes/core";
|
|
8
|
+
|
|
7
9
|
export interface PlaybackAdapter {
|
|
8
10
|
play: () => void;
|
|
9
11
|
pause: () => void;
|
|
@@ -32,23 +34,12 @@ export interface TimelineLike {
|
|
|
32
34
|
isActive: () => boolean;
|
|
33
35
|
}
|
|
34
36
|
|
|
35
|
-
export interface ClipManifestClip {
|
|
36
|
-
id: string | null;
|
|
37
|
-
label: string;
|
|
38
|
-
start: number;
|
|
39
|
-
duration: number;
|
|
40
|
-
track: number;
|
|
37
|
+
export interface ClipManifestClip extends RuntimeTimelineClipIdentity {
|
|
41
38
|
zIndex?: number;
|
|
42
39
|
stackingContextId?: string | null;
|
|
43
|
-
kind: "video" | "audio" | "image" | "element" | "composition";
|
|
44
|
-
tagName: string | null;
|
|
45
|
-
compositionId: string | null;
|
|
46
40
|
compositionAncestors?: string[];
|
|
47
|
-
parentCompositionId: string | null;
|
|
48
|
-
compositionSrc: string | null;
|
|
49
41
|
playbackStart?: number;
|
|
50
42
|
playbackRate?: number;
|
|
51
|
-
assetUrl: string | null;
|
|
52
43
|
}
|
|
53
44
|
|
|
54
45
|
export interface ClipManifest {
|
|
@@ -34,6 +34,14 @@ export interface StudioUiPreferences {
|
|
|
34
34
|
timelineZoomMode?: "fit" | "manual";
|
|
35
35
|
/** Manual timeline zoom percent, paired with `timelineZoomMode: "manual"`. */
|
|
36
36
|
timelineManualZoomPercent?: number;
|
|
37
|
+
/**
|
|
38
|
+
* Expose Studio's editing capabilities to an agentic browser as WebMCP tools.
|
|
39
|
+
* Absent means on: the browser still gates every actual call behind its own
|
|
40
|
+
* permission prompt, so "registered" is not "reachable without consent".
|
|
41
|
+
* Changes take effect on the next Studio reload because registration is
|
|
42
|
+
* intentionally scoped to one mount.
|
|
43
|
+
*/
|
|
44
|
+
agentToolsEnabled?: boolean;
|
|
37
45
|
}
|
|
38
46
|
|
|
39
47
|
const STUDIO_UI_PREFERENCES_KEY = "hf-studio-ui-preferences";
|
|
@@ -140,6 +148,9 @@ function readStorage(storage: Storage | null): StudioUiPreferences {
|
|
|
140
148
|
) {
|
|
141
149
|
preferences.timelineManualZoomPercent = parsed.timelineManualZoomPercent;
|
|
142
150
|
}
|
|
151
|
+
if (typeof parsed.agentToolsEnabled === "boolean") {
|
|
152
|
+
preferences.agentToolsEnabled = parsed.agentToolsEnabled;
|
|
153
|
+
}
|
|
143
154
|
return preferences;
|
|
144
155
|
} catch {
|
|
145
156
|
return {};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { useCallback } from "react";
|
|
2
|
+
import { useDomEditSelectionContext } from "../contexts/DomEditContext";
|
|
3
|
+
import { useStudioShellContext } from "../contexts/StudioContext";
|
|
4
|
+
import { usePlayerStore } from "../player";
|
|
5
|
+
import { useStudioAgentTools } from "./useStudioAgentTools";
|
|
6
|
+
import type { StudioLookSnapshot } from "./tools/lookTools";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Mounts Studio's WebMCP tool surface. Renders nothing.
|
|
10
|
+
*
|
|
11
|
+
* Lives inside `EditorShell` rather than `App` for two reasons: the DomEdit
|
|
12
|
+
* contexts are only readable below `DomEditProvider`, which `App` renders, and
|
|
13
|
+
* `App.tsx` sits three lines under the 600-line cap.
|
|
14
|
+
*
|
|
15
|
+
* The player store is read IMPERATIVELY through `getState()` inside the
|
|
16
|
+
* snapshot callback rather than subscribed to. Subscribing to `currentTime`
|
|
17
|
+
* would re-render this component on every animation frame during playback for
|
|
18
|
+
* a value nothing here displays.
|
|
19
|
+
*/
|
|
20
|
+
export function StudioAgentTools() {
|
|
21
|
+
const { projectId, activeCompPath, editHistory } = useStudioShellContext();
|
|
22
|
+
const { domEditSelection, selectedGsapAnimations } = useDomEditSelectionContext();
|
|
23
|
+
|
|
24
|
+
const getSnapshot = useCallback((): StudioLookSnapshot => {
|
|
25
|
+
const player = usePlayerStore.getState();
|
|
26
|
+
return {
|
|
27
|
+
projectId,
|
|
28
|
+
compositionPath: activeCompPath,
|
|
29
|
+
currentTime: player.currentTime,
|
|
30
|
+
duration: player.duration,
|
|
31
|
+
isPlaying: player.isPlaying,
|
|
32
|
+
elements: player.elements,
|
|
33
|
+
selection: domEditSelection,
|
|
34
|
+
selectionAnimationCount: selectedGsapAnimations.length,
|
|
35
|
+
history: {
|
|
36
|
+
canUndo: editHistory.canUndo,
|
|
37
|
+
canRedo: editHistory.canRedo,
|
|
38
|
+
undoLabel: editHistory.undoLabel ?? null,
|
|
39
|
+
redoLabel: editHistory.redoLabel ?? null,
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}, [projectId, activeCompPath, domEditSelection, selectedGsapAnimations, editHistory]);
|
|
43
|
+
|
|
44
|
+
useStudioAgentTools({ getSnapshot });
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import type { TimelineElement } from "../player/store/timelineElement";
|
|
4
|
+
import {
|
|
5
|
+
mintElementHandle,
|
|
6
|
+
parseElementHandle,
|
|
7
|
+
resolveElementHandle,
|
|
8
|
+
timelineElementAddress,
|
|
9
|
+
} from "./handles";
|
|
10
|
+
|
|
11
|
+
function timelineElement(overrides: Partial<TimelineElement>): TimelineElement {
|
|
12
|
+
return { id: "synthetic-id", tag: "div", start: 0, duration: 1, track: 0, ...overrides };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** A separate document, standing in for the preview iframe's realm. */
|
|
16
|
+
function previewDoc(html: string): Document {
|
|
17
|
+
const iframe = document.createElement("iframe");
|
|
18
|
+
document.body.append(iframe);
|
|
19
|
+
const doc = iframe.contentDocument;
|
|
20
|
+
if (!doc) throw new Error("expected iframe document");
|
|
21
|
+
doc.body.innerHTML = html;
|
|
22
|
+
return doc;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe("mintElementHandle", () => {
|
|
26
|
+
it("prefers data-hf-id, the stable patch target", () => {
|
|
27
|
+
const handle = mintElementHandle(
|
|
28
|
+
timelineElementAddress(
|
|
29
|
+
timelineElement({ hfId: "abc123", domId: "headline", selector: ".title" }),
|
|
30
|
+
),
|
|
31
|
+
);
|
|
32
|
+
expect(handle).toBe("hf:abc123");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("falls back to the DOM id when there is no hf id", () => {
|
|
36
|
+
expect(
|
|
37
|
+
mintElementHandle(
|
|
38
|
+
timelineElementAddress(timelineElement({ domId: "headline", selector: ".title" })),
|
|
39
|
+
),
|
|
40
|
+
).toBe("dom:headline");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("falls back to a selector with its occurrence index", () => {
|
|
44
|
+
expect(
|
|
45
|
+
mintElementHandle(
|
|
46
|
+
timelineElementAddress(timelineElement({ selector: ".card", selectorIndex: 2 })),
|
|
47
|
+
),
|
|
48
|
+
).toBe("sel:.card#2");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("defaults a missing occurrence index to the first match", () => {
|
|
52
|
+
expect(mintElementHandle(timelineElementAddress(timelineElement({ selector: ".card" })))).toBe(
|
|
53
|
+
"sel:.card#0",
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("returns null when the element carries no way to address it", () => {
|
|
58
|
+
// The synthesised `id` is deliberately NOT used: it cannot resolve.
|
|
59
|
+
expect(mintElementHandle(timelineElementAddress(timelineElement({})))).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("parseElementHandle", () => {
|
|
64
|
+
it("splits the index off the LAST hash, so id selectors survive", () => {
|
|
65
|
+
expect(parseElementHandle("sel:#card > .title#3")).toEqual({
|
|
66
|
+
scheme: "sel",
|
|
67
|
+
value: "#card > .title",
|
|
68
|
+
index: 3,
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("treats a selector with no index as the first match", () => {
|
|
73
|
+
expect(parseElementHandle("sel:.card")).toEqual({ scheme: "sel", value: ".card", index: 0 });
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("rejects an unknown scheme", () => {
|
|
77
|
+
expect(parseElementHandle("xpath://div")).toBeNull();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("rejects a handle with no value", () => {
|
|
81
|
+
expect(parseElementHandle("dom:")).toBeNull();
|
|
82
|
+
expect(parseElementHandle("")).toBeNull();
|
|
83
|
+
expect(parseElementHandle(":headline")).toBeNull();
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("resolveElementHandle", () => {
|
|
88
|
+
it("round-trips every handle scheme a read can mint", () => {
|
|
89
|
+
const doc = previewDoc(
|
|
90
|
+
`<div id="headline" data-hf-id="abc123">A</div>
|
|
91
|
+
<div class="card">first</div>
|
|
92
|
+
<div class="card">second</div>`,
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
expect(resolveElementHandle(doc, "hf:abc123")?.id).toBe("headline");
|
|
96
|
+
expect(resolveElementHandle(doc, "dom:headline")?.id).toBe("headline");
|
|
97
|
+
expect(resolveElementHandle(doc, "sel:.card#1")?.textContent).toBe("second");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("resolves across realms, where a naive instanceof check fails", () => {
|
|
101
|
+
const doc = previewDoc('<div id="headline">A</div>');
|
|
102
|
+
const resolved = resolveElementHandle(doc, "dom:headline");
|
|
103
|
+
|
|
104
|
+
expect(resolved).not.toBeNull();
|
|
105
|
+
// The preview element is NOT an instance of Studio's own HTMLElement.
|
|
106
|
+
expect(resolved instanceof HTMLElement).toBe(false);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("returns null for a handle that no longer matches", () => {
|
|
110
|
+
const doc = previewDoc('<div id="headline">A</div>');
|
|
111
|
+
expect(resolveElementHandle(doc, "dom:deleted")).toBeNull();
|
|
112
|
+
expect(resolveElementHandle(doc, "hf:missing")).toBeNull();
|
|
113
|
+
expect(resolveElementHandle(doc, "sel:.card#0")).toBeNull();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("returns null for an out-of-range occurrence rather than the wrong element", () => {
|
|
117
|
+
const doc = previewDoc('<div class="card">only</div>');
|
|
118
|
+
expect(resolveElementHandle(doc, "sel:.card#4")).toBeNull();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("returns null for a selector that is invalid in this document", () => {
|
|
122
|
+
const doc = previewDoc('<div class="card">only</div>');
|
|
123
|
+
expect(resolveElementHandle(doc, "sel:>>>broken#0")).toBeNull();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("returns null for a malformed handle", () => {
|
|
127
|
+
const doc = previewDoc('<div id="headline">A</div>');
|
|
128
|
+
expect(resolveElementHandle(doc, "nonsense")).toBeNull();
|
|
129
|
+
});
|
|
130
|
+
});
|