@hyperframes/studio 0.8.24 → 0.8.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{hyperframes-player-CTQCs_KG.js → hyperframes-player-CTP_00ix.js} +1 -1
- package/dist/assets/{index-okdE01ky.js → index-Bjb4YqS_.js} +1 -1
- package/dist/assets/{index-Bq3M0sjr.js → index-DSk_ALFr.js} +218 -218
- package/dist/assets/{index-9WJFcXTP.js → index-Di-8Jxqc.js} +1 -1
- package/dist/assets/index-DnRfAiK2.css +1 -0
- package/dist/{chunk-6BT6DTB4.js → chunk-A7S5SGNA.js} +2 -2
- package/dist/chunk-A7S5SGNA.js.map +1 -0
- package/dist/{domEditingLayers-URA7BLWE.js → domEditingLayers-JMMVOGTO.js} +2 -2
- package/dist/index.d.ts +4 -0
- package/dist/index.html +2 -2
- package/dist/index.js +6206 -5011
- package/dist/index.js.map +1 -1
- package/package.json +8 -7
- package/src/components/editor/TopologyLens.test.tsx +294 -0
- package/src/components/editor/TopologyLens.tsx +206 -0
- package/src/components/editor/domEditOverlayGeometry.ts +14 -0
- package/src/components/editor/domEditOverlayGeometryBatch.test.ts +18 -0
- package/src/components/editor/domEditingLayers.test.ts +9 -0
- package/src/components/editor/domEditingLayers.ts +8 -8
- package/src/components/editor/topologyLensGeometry.test.ts +44 -0
- package/src/components/editor/topologyLensGeometry.ts +119 -0
- package/src/components/editor/topologyLensState.test.ts +116 -0
- package/src/components/editor/topologyLensState.ts +72 -0
- package/src/components/nle/PreviewOverlays.topologyLens.test.tsx +126 -0
- package/src/components/nle/PreviewOverlays.tsx +24 -18
- package/src/components/sidebar/CompositionsTab.drag.test.tsx +16 -0
- package/src/components/sidebar/CompositionsTab.tsx +10 -3
- package/src/components/ui/HyperframesLoader.tsx +3 -48
- package/src/components/ui/HyperframesMark.tsx +53 -0
- package/src/contexts/DomEditContext.tsx +12 -0
- package/src/hooks/domEditCommitRunner.test.ts +59 -0
- package/src/hooks/domEditCommitRunner.ts +55 -20
- package/src/hooks/domEditCommitTypes.ts +8 -1
- package/src/hooks/domEditTextCommitPlan.ts +47 -0
- package/src/hooks/useDomEditAttributeCommits.ts +4 -1
- package/src/hooks/useDomEditCommits.test.tsx +207 -8
- package/src/hooks/useDomEditCommits.ts +54 -14
- package/src/hooks/useDomEditPositionPatchCommit.test.tsx +3 -4
- package/src/hooks/useDomEditPositionPatchCommit.ts +27 -29
- package/src/hooks/useDomEditSession.ts +6 -2
- package/src/hooks/useDomEditTextCommits.test.tsx +86 -2
- package/src/hooks/useDomEditTextCommits.ts +64 -86
- package/src/hooks/useDomEditWiring.ts +1 -1
- package/src/hooks/useDomSelection.ts +2 -0
- package/src/hooks/useDomSelectionTypes.ts +2 -0
- package/src/hooks/useExternalFileChangeCoordinator.test.tsx +71 -4
- package/src/hooks/useExternalFileChangeCoordinator.ts +18 -9
- package/src/hooks/useGsapAnimationFetchFallback.test.ts +13 -1
- package/src/hooks/useGsapAnimationFetchFallback.ts +7 -3
- package/src/hooks/useGsapAnimationOps.test.tsx +101 -0
- package/src/hooks/useGsapAnimationOps.ts +4 -4
- package/src/hooks/useGsapAwareEditing.test.tsx +30 -2
- package/src/hooks/useGsapAwareEditing.ts +31 -10
- package/src/hooks/useGsapSelectionHandlers.test.tsx +3 -1
- package/src/hooks/useGsapSelectionHandlers.ts +21 -11
- package/src/hooks/useRenderClipContent.test.ts +27 -0
- package/src/hooks/useRenderClipContent.ts +5 -0
- package/src/hooks/useStudioExternalFileChanges.ts +5 -0
- package/src/player/components/CompositionThumbnail.test.ts +66 -1
- package/src/player/components/CompositionThumbnail.tsx +6 -0
- package/src/player/store/playerStore.test.ts +24 -0
- package/src/player/store/thumbnailSlice.ts +6 -0
- package/src/styles/studio.css +179 -0
- package/src/utils/domEditSaveQueue.test.ts +5 -3
- package/src/utils/domEditSaveQueue.ts +6 -1
- package/src/webmcp/StudioAgentTools.test.ts +70 -0
- package/src/webmcp/StudioAgentTools.tsx +89 -21
- package/src/webmcp/handles.test.ts +172 -15
- package/src/webmcp/handles.ts +152 -35
- package/src/webmcp/tools/animationTools.test.ts +241 -60
- package/src/webmcp/tools/animationTools.ts +191 -104
- package/src/webmcp/tools/contentTools.test.ts +103 -41
- package/src/webmcp/tools/contentTools.ts +149 -105
- package/src/webmcp/tools/inspectTools.test.ts +47 -5
- package/src/webmcp/tools/inspectTools.ts +59 -19
- package/src/webmcp/tools/lookTools.test.ts +185 -38
- package/src/webmcp/tools/lookTools.ts +134 -21
- package/src/webmcp/tools/selectionTools.test.ts +105 -9
- package/src/webmcp/tools/selectionTools.ts +34 -19
- package/src/webmcp/tools/targetedWriteTools.test.ts +125 -0
- package/src/webmcp/tools/transformTools.test.ts +140 -54
- package/src/webmcp/tools/transformTools.ts +215 -56
- package/src/webmcp/useStudioAgentTools.test.tsx +310 -10
- package/src/webmcp/useStudioAgentTools.ts +27 -15
- package/src/webmcp/webmcpTestUtils.ts +37 -0
- package/src/webmcp/writeCoordinator.test.ts +375 -0
- package/src/webmcp/writeCoordinator.ts +473 -0
- package/dist/assets/index-yGhfxxoL.css +0 -1
- package/dist/chunk-6BT6DTB4.js.map +0 -1
- /package/dist/{domEditingLayers-URA7BLWE.js.map → domEditingLayers-JMMVOGTO.js.map} +0 -0
|
@@ -73,7 +73,7 @@ describe("buildCompositionThumbnailUrl", () => {
|
|
|
73
73
|
origin: "http://localhost:3000",
|
|
74
74
|
}),
|
|
75
75
|
).toBe(
|
|
76
|
-
"http://localhost:3000/api/projects/demo/thumbnail/index.html?t=2.00&v=v3&selector=.card&selectorIndex=2",
|
|
76
|
+
"http://localhost:3000/api/projects/demo/thumbnail/index.html?t=2.00&v=v3&revision=0&selector=.card&selectorIndex=2",
|
|
77
77
|
);
|
|
78
78
|
});
|
|
79
79
|
|
|
@@ -91,6 +91,16 @@ describe("buildCompositionThumbnailUrl", () => {
|
|
|
91
91
|
"output=storyboard",
|
|
92
92
|
);
|
|
93
93
|
});
|
|
94
|
+
|
|
95
|
+
it("includes the persisted content revision in the cache identity", () => {
|
|
96
|
+
const url = buildCompositionThumbnailUrl({
|
|
97
|
+
previewUrl: "/api/projects/demo/preview",
|
|
98
|
+
origin: "http://localhost:3000",
|
|
99
|
+
contentRevision: 7,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
expect(new URL(url).searchParams.get("revision")).toBe("7");
|
|
103
|
+
});
|
|
94
104
|
});
|
|
95
105
|
|
|
96
106
|
describe("CompositionThumbnail", () => {
|
|
@@ -148,4 +158,59 @@ describe("CompositionThumbnail", () => {
|
|
|
148
158
|
expect(probe.src).toBe("");
|
|
149
159
|
expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:composition-thumbnail");
|
|
150
160
|
});
|
|
161
|
+
|
|
162
|
+
it("releases the old request and ignores its late result when persisted content changes", async () => {
|
|
163
|
+
const signals: AbortSignal[] = [];
|
|
164
|
+
const resolveFetches: Array<(response: Response) => void> = [];
|
|
165
|
+
globalThis.fetch = vi.fn((_url, init) => {
|
|
166
|
+
signals.push(init?.signal as AbortSignal);
|
|
167
|
+
return new Promise<Response>((resolve) => resolveFetches.push(resolve));
|
|
168
|
+
});
|
|
169
|
+
root = createRoot(host);
|
|
170
|
+
|
|
171
|
+
await act(async () => {
|
|
172
|
+
root!.render(
|
|
173
|
+
React.createElement(CompositionThumbnail, {
|
|
174
|
+
previewUrl: "/api/projects/demo/preview",
|
|
175
|
+
label: "",
|
|
176
|
+
labelColor: "#fff",
|
|
177
|
+
projectId: "demo",
|
|
178
|
+
contentRevision: 0,
|
|
179
|
+
}),
|
|
180
|
+
);
|
|
181
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
182
|
+
});
|
|
183
|
+
await act(async () => {
|
|
184
|
+
root!.render(
|
|
185
|
+
React.createElement(CompositionThumbnail, {
|
|
186
|
+
previewUrl: "/api/projects/demo/preview",
|
|
187
|
+
label: "",
|
|
188
|
+
labelColor: "#fff",
|
|
189
|
+
projectId: "demo",
|
|
190
|
+
contentRevision: 1,
|
|
191
|
+
}),
|
|
192
|
+
);
|
|
193
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
expect(globalThis.fetch).toHaveBeenCalledTimes(2);
|
|
197
|
+
expect(signals[0]?.aborted).toBe(true);
|
|
198
|
+
expect(signals[1]?.aborted).toBe(false);
|
|
199
|
+
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[1]?.[0]).toContain(
|
|
200
|
+
"revision=1",
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
await act(async () => {
|
|
204
|
+
resolveFetches[0]?.(new Response(new Blob(["stale"]), { status: 200 }));
|
|
205
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
206
|
+
});
|
|
207
|
+
expect(MockImage.instances).toHaveLength(0);
|
|
208
|
+
|
|
209
|
+
await act(async () => {
|
|
210
|
+
resolveFetches[1]?.(new Response(new Blob(["fresh"]), { status: 200 }));
|
|
211
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
212
|
+
});
|
|
213
|
+
expect(MockImage.instances).toHaveLength(1);
|
|
214
|
+
expect(MockImage.instances[0]?.src).toBe("blob:composition-thumbnail");
|
|
215
|
+
});
|
|
151
216
|
});
|
|
@@ -17,6 +17,7 @@ interface CompositionThumbnailProps {
|
|
|
17
17
|
height?: number;
|
|
18
18
|
projectId?: string;
|
|
19
19
|
sessionEpoch?: number;
|
|
20
|
+
contentRevision?: number;
|
|
20
21
|
priority?: ThumbnailPriority;
|
|
21
22
|
rich?: boolean;
|
|
22
23
|
}
|
|
@@ -32,6 +33,7 @@ export function buildCompositionThumbnailUrl({
|
|
|
32
33
|
selectorIndex,
|
|
33
34
|
origin,
|
|
34
35
|
output,
|
|
36
|
+
contentRevision = 0,
|
|
35
37
|
}: {
|
|
36
38
|
previewUrl: string;
|
|
37
39
|
seekTime?: number;
|
|
@@ -46,6 +48,7 @@ export function buildCompositionThumbnailUrl({
|
|
|
46
48
|
* high-density review size; `"source"` uses the composition's own dimensions.
|
|
47
49
|
*/
|
|
48
50
|
output?: "source" | "storyboard";
|
|
51
|
+
contentRevision?: number;
|
|
49
52
|
}): string {
|
|
50
53
|
const thumbnailBase = previewUrl
|
|
51
54
|
.replace("/preview/comp/", "/thumbnail/")
|
|
@@ -53,6 +56,7 @@ export function buildCompositionThumbnailUrl({
|
|
|
53
56
|
const thumbnailUrl = new URL(thumbnailBase, origin);
|
|
54
57
|
thumbnailUrl.searchParams.set("t", (seekTime + duration / 2).toFixed(2));
|
|
55
58
|
thumbnailUrl.searchParams.set("v", THUMBNAIL_URL_VERSION);
|
|
59
|
+
thumbnailUrl.searchParams.set("revision", String(contentRevision));
|
|
56
60
|
if (output) thumbnailUrl.searchParams.set("output", output);
|
|
57
61
|
if (selector) {
|
|
58
62
|
thumbnailUrl.searchParams.set("selector", selector);
|
|
@@ -96,6 +100,7 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
|
|
|
96
100
|
duration = 5,
|
|
97
101
|
projectId = previewUrl,
|
|
98
102
|
sessionEpoch = 0,
|
|
103
|
+
contentRevision = 0,
|
|
99
104
|
priority = "visible",
|
|
100
105
|
}: CompositionThumbnailProps) {
|
|
101
106
|
const [containerWidth, setContainerWidth] = useState(0);
|
|
@@ -107,6 +112,7 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
|
|
|
107
112
|
selector,
|
|
108
113
|
selectorIndex,
|
|
109
114
|
origin: window.location.origin,
|
|
115
|
+
contentRevision,
|
|
110
116
|
});
|
|
111
117
|
const request = useMemo(
|
|
112
118
|
() => ({
|
|
@@ -30,6 +30,30 @@ describe("usePlayerStore", () => {
|
|
|
30
30
|
});
|
|
31
31
|
});
|
|
32
32
|
|
|
33
|
+
describe("thumbnail content revision", () => {
|
|
34
|
+
it("advances once per accepted persisted file event", () => {
|
|
35
|
+
const before = usePlayerStore.getState().thumbnailContentRevision;
|
|
36
|
+
|
|
37
|
+
usePlayerStore.getState().bumpThumbnailContentRevision();
|
|
38
|
+
|
|
39
|
+
expect(usePlayerStore.getState().thumbnailContentRevision).toBe(before + 1);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("remains monotonic across soft resets while project identity stays with the session epoch", () => {
|
|
43
|
+
const store = usePlayerStore.getState();
|
|
44
|
+
store.beginTimelineSession("project-a");
|
|
45
|
+
store.bumpThumbnailContentRevision();
|
|
46
|
+
const revision = usePlayerStore.getState().thumbnailContentRevision;
|
|
47
|
+
const firstEpoch = usePlayerStore.getState().timelineSessionEpoch;
|
|
48
|
+
|
|
49
|
+
store.reset();
|
|
50
|
+
expect(usePlayerStore.getState().thumbnailContentRevision).toBe(revision);
|
|
51
|
+
store.beginTimelineSession("project-b");
|
|
52
|
+
expect(usePlayerStore.getState().thumbnailContentRevision).toBe(revision);
|
|
53
|
+
expect(usePlayerStore.getState().timelineSessionEpoch).toBe(firstEpoch + 1);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
33
57
|
describe("expandedClipIds", () => {
|
|
34
58
|
it("toggles clip membership", () => {
|
|
35
59
|
const store = usePlayerStore.getState();
|
|
@@ -4,15 +4,21 @@ import { defaultThumbnailMode, type ThumbnailMode } from "../lib/thumbnailPolicy
|
|
|
4
4
|
|
|
5
5
|
export interface ThumbnailSlice {
|
|
6
6
|
thumbnailMode: ThumbnailMode;
|
|
7
|
+
/** Monotonic identity for persisted project content shown by mounted thumbnails. */
|
|
8
|
+
thumbnailContentRevision: number;
|
|
7
9
|
setThumbnailMode: (mode: ThumbnailMode) => void;
|
|
10
|
+
bumpThumbnailContentRevision: () => void;
|
|
8
11
|
}
|
|
9
12
|
|
|
10
13
|
export function createThumbnailSlice(set: StoreApi<ThumbnailSlice>["setState"]): ThumbnailSlice {
|
|
11
14
|
return {
|
|
12
15
|
thumbnailMode: defaultThumbnailMode(readStudioUiPreferences().thumbnailMode),
|
|
16
|
+
thumbnailContentRevision: 0,
|
|
13
17
|
setThumbnailMode: (mode) => {
|
|
14
18
|
writeStudioUiPreferences({ thumbnailMode: mode });
|
|
15
19
|
set({ thumbnailMode: mode });
|
|
16
20
|
},
|
|
21
|
+
bumpThumbnailContentRevision: () =>
|
|
22
|
+
set((state) => ({ thumbnailContentRevision: state.thumbnailContentRevision + 1 })),
|
|
17
23
|
};
|
|
18
24
|
}
|
package/src/styles/studio.css
CHANGED
|
@@ -618,6 +618,185 @@ body {
|
|
|
618
618
|
}
|
|
619
619
|
}
|
|
620
620
|
|
|
621
|
+
/* Receipt-driven WebMCP Topology Lens. Every animation is one-shot Studio
|
|
622
|
+
chrome; none of these nodes enter the composition iframe or rendered output. */
|
|
623
|
+
.hf-topology-contour {
|
|
624
|
+
border: 1px solid rgba(60, 230, 172, 0.62);
|
|
625
|
+
box-shadow:
|
|
626
|
+
0 0 0 1px rgba(6, 227, 250, 0.08),
|
|
627
|
+
inset 0 0 18px rgba(60, 230, 172, 0.035);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
.hf-topology-field::before,
|
|
631
|
+
.hf-topology-contour::before,
|
|
632
|
+
.hf-topology-target::before {
|
|
633
|
+
content: attr(data-topology-node);
|
|
634
|
+
position: absolute;
|
|
635
|
+
top: 2px;
|
|
636
|
+
left: 3px;
|
|
637
|
+
z-index: 2;
|
|
638
|
+
padding: 1px 3px;
|
|
639
|
+
border-radius: 2px;
|
|
640
|
+
background: rgba(5, 15, 12, 0.86);
|
|
641
|
+
color: rgb(90, 247, 193);
|
|
642
|
+
font:
|
|
643
|
+
600 7px/1.3 ui-monospace,
|
|
644
|
+
SFMono-Regular,
|
|
645
|
+
Menlo,
|
|
646
|
+
Monaco,
|
|
647
|
+
Consolas,
|
|
648
|
+
monospace;
|
|
649
|
+
letter-spacing: 0.04em;
|
|
650
|
+
text-transform: lowercase;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
.hf-topology-field {
|
|
654
|
+
z-index: 0;
|
|
655
|
+
border: 1px solid rgba(60, 230, 172, 0.28);
|
|
656
|
+
background: linear-gradient(
|
|
657
|
+
90deg,
|
|
658
|
+
rgba(60, 230, 172, 0.025),
|
|
659
|
+
rgba(60, 230, 172, 0.1) 68%,
|
|
660
|
+
rgba(6, 227, 250, 0.06)
|
|
661
|
+
);
|
|
662
|
+
box-shadow: inset 0 0 28px rgba(60, 230, 172, 0.05);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
.hf-topology-target {
|
|
666
|
+
border: 1px solid rgba(60, 230, 172, 0.82);
|
|
667
|
+
box-shadow:
|
|
668
|
+
0 0 0 1px rgba(60, 230, 172, 0.14),
|
|
669
|
+
0 0 18px rgba(6, 227, 250, 0.09);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
.hf-topology-scan {
|
|
673
|
+
background: linear-gradient(
|
|
674
|
+
90deg,
|
|
675
|
+
transparent,
|
|
676
|
+
rgba(60, 230, 172, 0.16) 55%,
|
|
677
|
+
rgba(230, 255, 247, 0.9)
|
|
678
|
+
);
|
|
679
|
+
clip-path: polygon(0 0, 100% 0, 72% 100%, 0 100%);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
.hf-topology-seal {
|
|
683
|
+
filter: drop-shadow(0 0 6px rgba(60, 230, 172, 0.36));
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
687
|
+
.hf-topology-contour {
|
|
688
|
+
animation: hf-topology-reveal 176ms cubic-bezier(0.77, 0, 0.175, 1) both;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
.hf-topology-field[data-topology-phase="acquiring"] {
|
|
692
|
+
animation: hf-topology-reveal 240ms cubic-bezier(0.77, 0, 0.175, 1) both;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
.hf-topology-scan {
|
|
696
|
+
animation: hf-topology-scan 224ms cubic-bezier(0.77, 0, 0.175, 1) both;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
.hf-topology-target[data-topology-phase="acquiring"] {
|
|
700
|
+
animation: hf-topology-localize 224ms cubic-bezier(0.23, 1, 0.32, 1) 72ms both;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
.hf-topology-target[data-topology-phase="localizing"] {
|
|
704
|
+
animation: hf-topology-localize 176ms cubic-bezier(0.23, 1, 0.32, 1) both;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
.hf-topology-target[data-topology-phase="sealing"] {
|
|
708
|
+
animation: hf-topology-localize 144ms cubic-bezier(0.23, 1, 0.32, 1) both;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
.hf-topology-seal {
|
|
712
|
+
animation: hf-topology-seal 224ms cubic-bezier(0.23, 1, 0.32, 1) both;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
.hf-topology-seal path:first-of-type {
|
|
716
|
+
animation: hf-topology-seal-left 224ms cubic-bezier(0.23, 1, 0.32, 1) both;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
.hf-topology-seal path:nth-of-type(2) {
|
|
720
|
+
animation: hf-topology-seal-right 224ms cubic-bezier(0.23, 1, 0.32, 1) both;
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
@keyframes hf-topology-reveal {
|
|
725
|
+
from {
|
|
726
|
+
opacity: 0;
|
|
727
|
+
clip-path: inset(0 100% 0 0);
|
|
728
|
+
}
|
|
729
|
+
to {
|
|
730
|
+
opacity: 1;
|
|
731
|
+
clip-path: inset(0);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
@keyframes hf-topology-scan {
|
|
736
|
+
from {
|
|
737
|
+
opacity: 0;
|
|
738
|
+
transform: translate3d(-100%, 0, 0);
|
|
739
|
+
}
|
|
740
|
+
16% {
|
|
741
|
+
opacity: 1;
|
|
742
|
+
}
|
|
743
|
+
to {
|
|
744
|
+
opacity: 0;
|
|
745
|
+
transform: translate3d(400%, 0, 0);
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
@keyframes hf-topology-localize {
|
|
750
|
+
from {
|
|
751
|
+
opacity: 0;
|
|
752
|
+
transform: scaleX(0.76);
|
|
753
|
+
}
|
|
754
|
+
to {
|
|
755
|
+
opacity: 1;
|
|
756
|
+
transform: scaleX(1);
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
@keyframes hf-topology-seal {
|
|
761
|
+
from {
|
|
762
|
+
opacity: 0;
|
|
763
|
+
transform: scale(0.88);
|
|
764
|
+
}
|
|
765
|
+
to {
|
|
766
|
+
opacity: 1;
|
|
767
|
+
transform: scale(1);
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
@keyframes hf-topology-seal-left {
|
|
772
|
+
from {
|
|
773
|
+
transform: translate3d(-9px, 0, 0);
|
|
774
|
+
}
|
|
775
|
+
to {
|
|
776
|
+
transform: translate3d(0, 0, 0);
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
@keyframes hf-topology-seal-right {
|
|
781
|
+
from {
|
|
782
|
+
transform: translate3d(9px, 0, 0);
|
|
783
|
+
}
|
|
784
|
+
to {
|
|
785
|
+
transform: translate3d(0, 0, 0);
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
@media (prefers-reduced-motion: reduce) {
|
|
790
|
+
.hf-topology-contour,
|
|
791
|
+
.hf-topology-field,
|
|
792
|
+
.hf-topology-target,
|
|
793
|
+
.hf-topology-seal,
|
|
794
|
+
.hf-topology-seal path {
|
|
795
|
+
animation: none;
|
|
796
|
+
transform: none;
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
|
|
621
800
|
@keyframes hf-fx-preset-wave {
|
|
622
801
|
0%,
|
|
623
802
|
100% {
|
|
@@ -51,7 +51,7 @@ describe("dom edit save queue", () => {
|
|
|
51
51
|
queue.destroy();
|
|
52
52
|
});
|
|
53
53
|
|
|
54
|
-
it("
|
|
54
|
+
it("stops already queued work when the preceding save opens the breaker", async () => {
|
|
55
55
|
const onOpen = vi.fn();
|
|
56
56
|
const onReset = vi.fn();
|
|
57
57
|
const queue = createDomEditSaveQueue({
|
|
@@ -72,13 +72,15 @@ describe("dom edit save queue", () => {
|
|
|
72
72
|
resolveFirstStarted?.();
|
|
73
73
|
}),
|
|
74
74
|
);
|
|
75
|
-
const
|
|
75
|
+
const secondSave = vi.fn(async () => undefined);
|
|
76
|
+
const second = queue.enqueue(secondSave);
|
|
76
77
|
|
|
77
78
|
await firstStarted;
|
|
78
79
|
expect(rejectFirst).toBeTypeOf("function");
|
|
79
80
|
rejectFirst?.(new StudioSaveHttpError("Server down", 503));
|
|
80
81
|
await expect(first).rejects.toThrow("Server down");
|
|
81
|
-
await expect(second).
|
|
82
|
+
await expect(second).rejects.toThrow("Auto-save is paused");
|
|
83
|
+
expect(secondSave).not.toHaveBeenCalled();
|
|
82
84
|
|
|
83
85
|
expect(onOpen).toHaveBeenCalledOnce();
|
|
84
86
|
expect(onReset).not.toHaveBeenCalled();
|
|
@@ -81,7 +81,12 @@ export function createDomEditSaveQueue(options: DomEditSaveQueueOptions = {}): D
|
|
|
81
81
|
return {
|
|
82
82
|
enqueue(save) {
|
|
83
83
|
if (breakerOpen) return Promise.reject(new DomEditSaveQueueOpenError());
|
|
84
|
-
const queued = tail
|
|
84
|
+
const queued = tail
|
|
85
|
+
.catch(() => undefined)
|
|
86
|
+
.then(() => {
|
|
87
|
+
if (breakerOpen) throw new DomEditSaveQueueOpenError();
|
|
88
|
+
return run(save);
|
|
89
|
+
});
|
|
85
90
|
tail = queued.then(
|
|
86
91
|
() => undefined,
|
|
87
92
|
() => undefined,
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
4
|
+
import { vi } from "vitest";
|
|
5
|
+
import { readLiveSelectionBox, resizeSelectionFromAgent } from "./StudioAgentTools";
|
|
6
|
+
|
|
7
|
+
function rect(width: number, height: number): DOMRect {
|
|
8
|
+
return { x: 10, y: 20, width, height } as DOMRect;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe("readLiveSelectionBox", () => {
|
|
12
|
+
it("measures the replacement preview node after a commit reload", () => {
|
|
13
|
+
const oldElement = document.createElement("h1");
|
|
14
|
+
oldElement.id = "headline";
|
|
15
|
+
oldElement.getBoundingClientRect = () => rect(300, 50);
|
|
16
|
+
const selection = {
|
|
17
|
+
id: "headline",
|
|
18
|
+
sourceFile: "index.html",
|
|
19
|
+
element: oldElement,
|
|
20
|
+
} as DomEditSelection;
|
|
21
|
+
|
|
22
|
+
const liveDocument = document.implementation.createHTMLDocument();
|
|
23
|
+
const replacement = liveDocument.createElement("h1");
|
|
24
|
+
replacement.id = "headline";
|
|
25
|
+
replacement.getBoundingClientRect = () => rect(280, 60);
|
|
26
|
+
liveDocument.body.append(replacement);
|
|
27
|
+
|
|
28
|
+
expect(readLiveSelectionBox(liveDocument, selection, "index.html")).toEqual({
|
|
29
|
+
x: 10,
|
|
30
|
+
y: 20,
|
|
31
|
+
width: 280,
|
|
32
|
+
height: 60,
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("does not hide a stale target behind its detached old node", () => {
|
|
37
|
+
const oldElement = document.createElement("h1");
|
|
38
|
+
oldElement.id = "headline";
|
|
39
|
+
const selection = {
|
|
40
|
+
id: "headline",
|
|
41
|
+
sourceFile: "index.html",
|
|
42
|
+
element: oldElement,
|
|
43
|
+
} as DomEditSelection;
|
|
44
|
+
const liveDocument = document.implementation.createHTMLDocument();
|
|
45
|
+
|
|
46
|
+
expect(() => readLiveSelectionBox(liveDocument, selection, "index.html")).toThrow(
|
|
47
|
+
"target is missing",
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("resizeSelectionFromAgent", () => {
|
|
53
|
+
it("applies the same live draft a pointer gesture shows before persistence", async () => {
|
|
54
|
+
const element = document.createElement("div");
|
|
55
|
+
element.style.width = "300px";
|
|
56
|
+
element.style.height = "50px";
|
|
57
|
+
const selection = { element } as DomEditSelection;
|
|
58
|
+
const commit = vi.fn(async (_selection, _next, _offset, restore: () => void) => {
|
|
59
|
+
expect(element.style.width).toBe("280px");
|
|
60
|
+
expect(element.style.height).toBe("60px");
|
|
61
|
+
restore();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
await resizeSelectionFromAgent(selection, { width: 280, height: 60 }, commit);
|
|
65
|
+
|
|
66
|
+
expect(commit).toHaveBeenCalledOnce();
|
|
67
|
+
expect(element.style.width).toBe("300px");
|
|
68
|
+
expect(element.style.height).toBe("50px");
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -1,9 +1,49 @@
|
|
|
1
|
-
import { useCallback, useMemo } from "react";
|
|
1
|
+
import { useCallback, useEffect, useMemo } from "react";
|
|
2
2
|
import { useDomEditActionsContext, useDomEditSelectionContext } from "../contexts/DomEditContext";
|
|
3
3
|
import { useStudioShellContext } from "../contexts/StudioContext";
|
|
4
4
|
import { usePlayerStore } from "../player";
|
|
5
5
|
import { useStudioAgentTools, type StudioAgentToolsDeps } from "./useStudioAgentTools";
|
|
6
|
-
import type
|
|
6
|
+
import { collectStudioLookScene, type StudioLookSnapshot } from "./tools/lookTools";
|
|
7
|
+
import { studioEditLifecycle } from "./writeCoordinator";
|
|
8
|
+
import { findElementForSelection } from "../components/editor/domEditingElement";
|
|
9
|
+
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
10
|
+
import {
|
|
11
|
+
applyStudioBoxSizeDraft,
|
|
12
|
+
captureStudioBoxSize,
|
|
13
|
+
restoreStudioBoxSize,
|
|
14
|
+
} from "../components/editor/manualEdits";
|
|
15
|
+
|
|
16
|
+
export function readLiveSelectionBox(
|
|
17
|
+
doc: Document | null | undefined,
|
|
18
|
+
selection: DomEditSelection,
|
|
19
|
+
activeCompositionPath: string | null,
|
|
20
|
+
) {
|
|
21
|
+
const liveElement = doc
|
|
22
|
+
? findElementForSelection(doc, selection, activeCompositionPath)
|
|
23
|
+
: selection.element;
|
|
24
|
+
if (!liveElement) throw new Error("the target is missing from the current Studio preview");
|
|
25
|
+
const rect = liveElement.getBoundingClientRect();
|
|
26
|
+
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type ResizeCommit = (
|
|
30
|
+
selection: DomEditSelection,
|
|
31
|
+
next: { width: number; height: number },
|
|
32
|
+
offset?: { x: number; y: number },
|
|
33
|
+
restore?: () => void,
|
|
34
|
+
) => Promise<void>;
|
|
35
|
+
|
|
36
|
+
export function resizeSelectionFromAgent(
|
|
37
|
+
selection: DomEditSelection,
|
|
38
|
+
next: { width: number; height: number },
|
|
39
|
+
commit: ResizeCommit,
|
|
40
|
+
): Promise<void> {
|
|
41
|
+
const previous = captureStudioBoxSize(selection.element);
|
|
42
|
+
applyStudioBoxSizeDraft(selection.element, next);
|
|
43
|
+
return commit(selection, next, undefined, () => {
|
|
44
|
+
restoreStudioBoxSize(selection.element, previous);
|
|
45
|
+
});
|
|
46
|
+
}
|
|
7
47
|
|
|
8
48
|
/**
|
|
9
49
|
* Mounts Studio's WebMCP tool surface. Renders nothing.
|
|
@@ -20,6 +60,7 @@ export function StudioAgentTools() {
|
|
|
20
60
|
const { projectId, activeCompPath, editHistory, writeBlockedReason } = useStudioShellContext();
|
|
21
61
|
const {
|
|
22
62
|
domEditSelection,
|
|
63
|
+
activeGroupElement,
|
|
23
64
|
selectedGsapAnimations,
|
|
24
65
|
gsapMultipleTimelines,
|
|
25
66
|
gsapUnsupportedTimelinePattern,
|
|
@@ -28,8 +69,8 @@ export function StudioAgentTools() {
|
|
|
28
69
|
previewIframeRef,
|
|
29
70
|
buildDomSelectionFromTarget,
|
|
30
71
|
applyDomSelection,
|
|
31
|
-
|
|
32
|
-
|
|
72
|
+
handleDomTextCommitForSelection,
|
|
73
|
+
handleDomStyleCommitForSelection,
|
|
33
74
|
handleDomPathOffsetCommit,
|
|
34
75
|
handleDomBoxSizeCommit,
|
|
35
76
|
handleDomRotationCommit,
|
|
@@ -37,8 +78,14 @@ export function StudioAgentTools() {
|
|
|
37
78
|
handleGsapUpdateMeta,
|
|
38
79
|
handleGsapAddKeyframeBatch,
|
|
39
80
|
handleGsapDeleteAnimation,
|
|
81
|
+
getGsapAnimationsForSelection,
|
|
40
82
|
} = useDomEditActionsContext();
|
|
41
83
|
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
studioEditLifecycle.activateProject(projectId);
|
|
86
|
+
return () => studioEditLifecycle.reset();
|
|
87
|
+
}, [projectId]);
|
|
88
|
+
|
|
42
89
|
const getSnapshot = useCallback((): StudioLookSnapshot => {
|
|
43
90
|
const player = usePlayerStore.getState();
|
|
44
91
|
return {
|
|
@@ -48,6 +95,11 @@ export function StudioAgentTools() {
|
|
|
48
95
|
duration: player.duration,
|
|
49
96
|
isPlaying: player.isPlaying,
|
|
50
97
|
elements: player.elements,
|
|
98
|
+
scene: collectStudioLookScene(
|
|
99
|
+
previewIframeRef.current?.contentDocument ?? null,
|
|
100
|
+
activeCompPath,
|
|
101
|
+
activeGroupElement,
|
|
102
|
+
),
|
|
51
103
|
selection: domEditSelection,
|
|
52
104
|
selectionAnimationCount: selectedGsapAnimations.length,
|
|
53
105
|
history: {
|
|
@@ -57,13 +109,21 @@ export function StudioAgentTools() {
|
|
|
57
109
|
redoLabel: editHistory.redoLabel ?? null,
|
|
58
110
|
},
|
|
59
111
|
};
|
|
60
|
-
}, [
|
|
112
|
+
}, [
|
|
113
|
+
projectId,
|
|
114
|
+
activeCompPath,
|
|
115
|
+
domEditSelection,
|
|
116
|
+
activeGroupElement,
|
|
117
|
+
selectedGsapAnimations,
|
|
118
|
+
editHistory,
|
|
119
|
+
previewIframeRef,
|
|
120
|
+
]);
|
|
61
121
|
|
|
62
122
|
const deps = useMemo<StudioAgentToolsDeps>(
|
|
63
123
|
() => ({
|
|
64
124
|
getSnapshot,
|
|
65
125
|
getPreviewDocument: () => previewIframeRef.current?.contentDocument ?? null,
|
|
66
|
-
buildSelection: (element) => buildDomSelectionFromTarget(element),
|
|
126
|
+
buildSelection: (element) => buildDomSelectionFromTarget(element, { exactTarget: true }),
|
|
67
127
|
applySelection: (selection) => applyDomSelection(selection, { revealPanel: true }),
|
|
68
128
|
requestSeek: (time) => usePlayerStore.getState().requestSeek(time),
|
|
69
129
|
readPlayhead: () => {
|
|
@@ -90,22 +150,29 @@ export function StudioAgentTools() {
|
|
|
90
150
|
wait: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
91
151
|
getCurrentSelection: () => domEditSelection,
|
|
92
152
|
getWriteBlockedReason: () => writeBlockedReason,
|
|
93
|
-
setText: (value, fieldKey) =>
|
|
94
|
-
|
|
153
|
+
setText: (selection, value, fieldKey) =>
|
|
154
|
+
handleDomTextCommitForSelection(selection, value, fieldKey),
|
|
155
|
+
setStyle: (selection, property, value) =>
|
|
156
|
+
handleDomStyleCommitForSelection(selection, property, value),
|
|
95
157
|
// Measured, not authored: the tool compares this before and after to
|
|
96
|
-
// tell a real change from a handler that did nothing and resolved.
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
158
|
+
// tell a real change from a handler that did nothing and resolved. A
|
|
159
|
+
// successful commit may replace the preview document, so re-resolve the
|
|
160
|
+
// source-safe selection instead of measuring its detached old node.
|
|
161
|
+
readBox: (selection) =>
|
|
162
|
+
readLiveSelectionBox(previewIframeRef.current?.contentDocument, selection, activeCompPath),
|
|
101
163
|
moveTo: (selection, next) => handleDomPathOffsetCommit(selection, next),
|
|
102
|
-
resizeTo: (selection, next) =>
|
|
164
|
+
resizeTo: (selection, next) =>
|
|
165
|
+
resizeSelectionFromAgent(selection, next, handleDomBoxSizeCommit),
|
|
103
166
|
rotateTo: (selection, next) => handleDomRotationCommit(selection, next),
|
|
104
|
-
addAnimation: (method) => handleGsapAddAnimation(method),
|
|
105
|
-
updateAnimation: (animationId, updates) =>
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
167
|
+
addAnimation: (selection, method) => handleGsapAddAnimation(method, selection),
|
|
168
|
+
updateAnimation: (selection, animationId, updates) =>
|
|
169
|
+
handleGsapUpdateMeta(animationId, updates, selection),
|
|
170
|
+
addKeyframe: (selection, animationId, percent, properties) =>
|
|
171
|
+
handleGsapAddKeyframeBatch(animationId, percent, properties, undefined, selection),
|
|
172
|
+
deleteAnimation: (selection, animationId) =>
|
|
173
|
+
handleGsapDeleteAnimation(animationId, selection),
|
|
174
|
+
getAnimationsForSelection: async (selection) =>
|
|
175
|
+
await getGsapAnimationsForSelection(selection),
|
|
109
176
|
getGsapDiagnostics: () => ({
|
|
110
177
|
animations: selectedGsapAnimations,
|
|
111
178
|
multipleTimelines: gsapMultipleTimelines,
|
|
@@ -120,8 +187,8 @@ export function StudioAgentTools() {
|
|
|
120
187
|
projectId,
|
|
121
188
|
activeCompPath,
|
|
122
189
|
writeBlockedReason,
|
|
123
|
-
|
|
124
|
-
|
|
190
|
+
handleDomTextCommitForSelection,
|
|
191
|
+
handleDomStyleCommitForSelection,
|
|
125
192
|
handleDomPathOffsetCommit,
|
|
126
193
|
handleDomBoxSizeCommit,
|
|
127
194
|
handleDomRotationCommit,
|
|
@@ -129,6 +196,7 @@ export function StudioAgentTools() {
|
|
|
129
196
|
handleGsapUpdateMeta,
|
|
130
197
|
handleGsapAddKeyframeBatch,
|
|
131
198
|
handleGsapDeleteAnimation,
|
|
199
|
+
getGsapAnimationsForSelection,
|
|
132
200
|
domEditSelection,
|
|
133
201
|
selectedGsapAnimations,
|
|
134
202
|
gsapMultipleTimelines,
|