@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
|
@@ -5,23 +5,15 @@ import {
|
|
|
5
5
|
mintElementHandle,
|
|
6
6
|
parseElementHandle,
|
|
7
7
|
resolveElementHandle,
|
|
8
|
+
resolveLiveHandleSelection,
|
|
8
9
|
timelineElementAddress,
|
|
9
10
|
} from "./handles";
|
|
11
|
+
import { previewDoc } from "./webmcpTestUtils";
|
|
10
12
|
|
|
11
13
|
function timelineElement(overrides: Partial<TimelineElement>): TimelineElement {
|
|
12
14
|
return { id: "synthetic-id", tag: "div", start: 0, duration: 1, track: 0, ...overrides };
|
|
13
15
|
}
|
|
14
16
|
|
|
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
17
|
describe("mintElementHandle", () => {
|
|
26
18
|
it("prefers data-hf-id, the stable patch target", () => {
|
|
27
19
|
const handle = mintElementHandle(
|
|
@@ -29,7 +21,7 @@ describe("mintElementHandle", () => {
|
|
|
29
21
|
timelineElement({ hfId: "abc123", domId: "headline", selector: ".title" }),
|
|
30
22
|
),
|
|
31
23
|
);
|
|
32
|
-
expect(handle).toBe("hf:abc123");
|
|
24
|
+
expect(handle).toBe("hf:v1:index.html:index.html:abc123");
|
|
33
25
|
});
|
|
34
26
|
|
|
35
27
|
it("falls back to the DOM id when there is no hf id", () => {
|
|
@@ -37,7 +29,7 @@ describe("mintElementHandle", () => {
|
|
|
37
29
|
mintElementHandle(
|
|
38
30
|
timelineElementAddress(timelineElement({ domId: "headline", selector: ".title" })),
|
|
39
31
|
),
|
|
40
|
-
).toBe("dom:headline");
|
|
32
|
+
).toBe("dom:v1:index.html:index.html:headline");
|
|
41
33
|
});
|
|
42
34
|
|
|
43
35
|
it("falls back to a selector with its occurrence index", () => {
|
|
@@ -45,12 +37,12 @@ describe("mintElementHandle", () => {
|
|
|
45
37
|
mintElementHandle(
|
|
46
38
|
timelineElementAddress(timelineElement({ selector: ".card", selectorIndex: 2 })),
|
|
47
39
|
),
|
|
48
|
-
).toBe("sel:.card#2");
|
|
40
|
+
).toBe("sel:v1:index.html:index.html:.card#2");
|
|
49
41
|
});
|
|
50
42
|
|
|
51
43
|
it("defaults a missing occurrence index to the first match", () => {
|
|
52
44
|
expect(mintElementHandle(timelineElementAddress(timelineElement({ selector: ".card" })))).toBe(
|
|
53
|
-
"sel:.card#0",
|
|
45
|
+
"sel:v1:index.html:index.html:.card#0",
|
|
54
46
|
);
|
|
55
47
|
});
|
|
56
48
|
|
|
@@ -60,17 +52,110 @@ describe("mintElementHandle", () => {
|
|
|
60
52
|
});
|
|
61
53
|
});
|
|
62
54
|
|
|
55
|
+
describe("resolveLiveHandleSelection", () => {
|
|
56
|
+
it("reports a transient change when the replacement preview has not rebuilt the target", async () => {
|
|
57
|
+
const firstDoc = previewDoc('<div id="headline">before</div>');
|
|
58
|
+
const replacementDoc = previewDoc("<main>reloading</main>");
|
|
59
|
+
let currentDoc = firstDoc;
|
|
60
|
+
|
|
61
|
+
const result = await resolveLiveHandleSelection(
|
|
62
|
+
() => currentDoc,
|
|
63
|
+
"dom:headline",
|
|
64
|
+
async (element) => {
|
|
65
|
+
currentDoc = replacementDoc;
|
|
66
|
+
return { element };
|
|
67
|
+
},
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
expect(result).toEqual({ status: "changed" });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("reports a stable missing target as not found", async () => {
|
|
74
|
+
const doc = previewDoc("<main>settled</main>");
|
|
75
|
+
|
|
76
|
+
const result = await resolveLiveHandleSelection(
|
|
77
|
+
() => doc,
|
|
78
|
+
"dom:headline",
|
|
79
|
+
async (element) => ({ element }),
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
expect(result).toEqual({ status: "not-found" });
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
63
86
|
describe("parseElementHandle", () => {
|
|
87
|
+
it("round-trips source ownership and the active composition", () => {
|
|
88
|
+
expect(
|
|
89
|
+
parseElementHandle(
|
|
90
|
+
mintElementHandle({
|
|
91
|
+
domId: "headline:hero",
|
|
92
|
+
sourceFile: "compositions/hero.html",
|
|
93
|
+
activeCompositionPath: "index.html",
|
|
94
|
+
})!,
|
|
95
|
+
),
|
|
96
|
+
).toEqual({
|
|
97
|
+
scheme: "dom",
|
|
98
|
+
version: 1,
|
|
99
|
+
value: "headline:hero",
|
|
100
|
+
index: 0,
|
|
101
|
+
sourceFile: "compositions/hero.html",
|
|
102
|
+
activeCompositionPath: "index.html",
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("encodes path, selector, colon, and hash delimiters without ambiguity", () => {
|
|
107
|
+
const handle = mintElementHandle({
|
|
108
|
+
selector: '#hero[data-label="a:b"] > .card:nth-child(2)',
|
|
109
|
+
selectorIndex: 3,
|
|
110
|
+
sourceFile: "compositions/a:b#hero.html",
|
|
111
|
+
activeCompositionPath: "scenes/root:wide.html",
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
expect(parseElementHandle(handle!)).toEqual({
|
|
115
|
+
scheme: "sel",
|
|
116
|
+
version: 1,
|
|
117
|
+
value: '#hero[data-label="a:b"] > .card:nth-child(2)',
|
|
118
|
+
index: 3,
|
|
119
|
+
sourceFile: "compositions/a:b#hero.html",
|
|
120
|
+
activeCompositionPath: "scenes/root:wide.html",
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
64
124
|
it("splits the index off the LAST hash, so id selectors survive", () => {
|
|
65
125
|
expect(parseElementHandle("sel:#card > .title#3")).toEqual({
|
|
66
126
|
scheme: "sel",
|
|
127
|
+
version: 0,
|
|
67
128
|
value: "#card > .title",
|
|
68
129
|
index: 3,
|
|
69
130
|
});
|
|
70
131
|
});
|
|
71
132
|
|
|
72
133
|
it("treats a selector with no index as the first match", () => {
|
|
73
|
-
expect(parseElementHandle("sel:.card")).toEqual({
|
|
134
|
+
expect(parseElementHandle("sel:.card")).toEqual({
|
|
135
|
+
scheme: "sel",
|
|
136
|
+
version: 0,
|
|
137
|
+
value: ".card",
|
|
138
|
+
index: 0,
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("round-trips the project identity in a writable v2 handle", () => {
|
|
143
|
+
const handle = mintElementHandle({
|
|
144
|
+
projectId: "project:demo",
|
|
145
|
+
domId: "headline",
|
|
146
|
+
sourceFile: "index.html",
|
|
147
|
+
activeCompositionPath: "index.html",
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
expect(parseElementHandle(handle!)).toEqual({
|
|
151
|
+
scheme: "dom",
|
|
152
|
+
version: 2,
|
|
153
|
+
projectId: "project:demo",
|
|
154
|
+
value: "headline",
|
|
155
|
+
sourceFile: "index.html",
|
|
156
|
+
activeCompositionPath: "index.html",
|
|
157
|
+
index: 0,
|
|
158
|
+
});
|
|
74
159
|
});
|
|
75
160
|
|
|
76
161
|
it("rejects an unknown scheme", () => {
|
|
@@ -106,6 +191,78 @@ describe("resolveElementHandle", () => {
|
|
|
106
191
|
expect(resolved instanceof HTMLElement).toBe(false);
|
|
107
192
|
});
|
|
108
193
|
|
|
194
|
+
it("distinguishes duplicate authored ids by source file", () => {
|
|
195
|
+
const doc = previewDoc(
|
|
196
|
+
`<main data-composition-id="root" data-composition-file="index.html">
|
|
197
|
+
<div id="duplicate">root</div>
|
|
198
|
+
<section data-composition-id="nested" data-composition-file="compositions/nested.html">
|
|
199
|
+
<div id="duplicate">nested</div>
|
|
200
|
+
</section>
|
|
201
|
+
</main>`,
|
|
202
|
+
);
|
|
203
|
+
const rootHandle = mintElementHandle({
|
|
204
|
+
domId: "duplicate",
|
|
205
|
+
sourceFile: "index.html",
|
|
206
|
+
activeCompositionPath: "index.html",
|
|
207
|
+
});
|
|
208
|
+
const nestedHandle = mintElementHandle({
|
|
209
|
+
domId: "duplicate",
|
|
210
|
+
sourceFile: "compositions/nested.html",
|
|
211
|
+
activeCompositionPath: "index.html",
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
expect(rootHandle).not.toBe(nestedHandle);
|
|
215
|
+
expect(resolveElementHandle(doc, rootHandle!)?.textContent).toBe("root");
|
|
216
|
+
expect(resolveElementHandle(doc, nestedHandle!)?.textContent).toBe("nested");
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("keeps selector occurrence indexes scoped to their source file", () => {
|
|
220
|
+
const doc = previewDoc(
|
|
221
|
+
`<main data-composition-id="root" data-composition-file="index.html">
|
|
222
|
+
<div class="card">root first</div><div class="card">root second</div>
|
|
223
|
+
<section data-composition-id="nested" data-composition-file="compositions/nested.html">
|
|
224
|
+
<div class="card">nested first</div><div class="card">nested second</div>
|
|
225
|
+
</section>
|
|
226
|
+
</main>`,
|
|
227
|
+
);
|
|
228
|
+
const rootSecond = mintElementHandle({
|
|
229
|
+
selector: ".card",
|
|
230
|
+
selectorIndex: 1,
|
|
231
|
+
sourceFile: "index.html",
|
|
232
|
+
activeCompositionPath: "index.html",
|
|
233
|
+
});
|
|
234
|
+
const nestedSecond = mintElementHandle({
|
|
235
|
+
selector: ".card",
|
|
236
|
+
selectorIndex: 1,
|
|
237
|
+
sourceFile: "compositions/nested.html",
|
|
238
|
+
activeCompositionPath: "index.html",
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
expect(resolveElementHandle(doc, rootSecond!)?.textContent).toBe("root second");
|
|
242
|
+
expect(resolveElementHandle(doc, nestedSecond!)?.textContent).toBe("nested second");
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("re-resolves a scoped handle against the live document after reload", () => {
|
|
246
|
+
const handle = mintElementHandle({
|
|
247
|
+
domId: "headline",
|
|
248
|
+
sourceFile: "index.html",
|
|
249
|
+
activeCompositionPath: "index.html",
|
|
250
|
+
});
|
|
251
|
+
const firstDoc = previewDoc(
|
|
252
|
+
'<main data-composition-id="root" data-composition-file="index.html"><div id="headline">before</div></main>',
|
|
253
|
+
);
|
|
254
|
+
const first = resolveElementHandle(firstDoc, handle!);
|
|
255
|
+
const reloadedDoc = previewDoc(
|
|
256
|
+
'<main data-composition-id="root" data-composition-file="index.html"><div id="headline">after</div></main>',
|
|
257
|
+
);
|
|
258
|
+
const reloaded = resolveElementHandle(reloadedDoc, handle!);
|
|
259
|
+
|
|
260
|
+
expect(first?.textContent).toBe("before");
|
|
261
|
+
expect(reloaded?.textContent).toBe("after");
|
|
262
|
+
expect(reloaded).not.toBe(first);
|
|
263
|
+
expect(reloaded?.ownerDocument).toBe(reloadedDoc);
|
|
264
|
+
});
|
|
265
|
+
|
|
109
266
|
it("returns null for a handle that no longer matches", () => {
|
|
110
267
|
const doc = previewDoc('<div id="headline">A</div>');
|
|
111
268
|
expect(resolveElementHandle(doc, "dom:deleted")).toBeNull();
|
package/src/webmcp/handles.ts
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import type { TimelineElement } from "../player/store/timelineElement";
|
|
16
|
+
import { findElementForSelection } from "../components/editor/domEditingElement";
|
|
16
17
|
import type { PatchTarget } from "../utils/sourcePatcher";
|
|
17
18
|
|
|
18
19
|
const SEPARATOR = ":";
|
|
@@ -24,10 +25,13 @@ const INDEX_SEPARATOR = "#";
|
|
|
24
25
|
* knowing about either.
|
|
25
26
|
*/
|
|
26
27
|
export interface ElementAddress {
|
|
28
|
+
projectId?: string | null;
|
|
27
29
|
hfId?: string;
|
|
28
30
|
domId?: string | null;
|
|
29
31
|
selector?: string;
|
|
30
32
|
selectorIndex?: number;
|
|
33
|
+
sourceFile?: string;
|
|
34
|
+
activeCompositionPath?: string | null;
|
|
31
35
|
}
|
|
32
36
|
|
|
33
37
|
/**
|
|
@@ -35,37 +39,108 @@ export interface ElementAddress {
|
|
|
35
39
|
* `data-hf-id` survives edits that renumber or reorder; a bare selector does not.
|
|
36
40
|
*/
|
|
37
41
|
export function mintElementHandle(address: ElementAddress): string | null {
|
|
38
|
-
|
|
39
|
-
|
|
42
|
+
const scoped = (value: string) => {
|
|
43
|
+
if (!address.sourceFile || !address.activeCompositionPath) return value;
|
|
44
|
+
if (address.projectId) {
|
|
45
|
+
return [
|
|
46
|
+
"v2",
|
|
47
|
+
encodeURIComponent(address.projectId),
|
|
48
|
+
encodeURIComponent(address.activeCompositionPath),
|
|
49
|
+
encodeURIComponent(address.sourceFile),
|
|
50
|
+
encodeURIComponent(value),
|
|
51
|
+
].join(SEPARATOR);
|
|
52
|
+
}
|
|
53
|
+
return [
|
|
54
|
+
"v1",
|
|
55
|
+
encodeURIComponent(address.activeCompositionPath),
|
|
56
|
+
encodeURIComponent(address.sourceFile),
|
|
57
|
+
encodeURIComponent(value),
|
|
58
|
+
].join(SEPARATOR);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
if (address.hfId) return `hf${SEPARATOR}${scoped(address.hfId)}`;
|
|
62
|
+
if (address.domId) return `dom${SEPARATOR}${scoped(address.domId)}`;
|
|
40
63
|
if (address.selector) {
|
|
41
64
|
const index = address.selectorIndex ?? 0;
|
|
42
|
-
return `sel${SEPARATOR}${address.selector}${INDEX_SEPARATOR}${index}`;
|
|
65
|
+
return `sel${SEPARATOR}${scoped(address.selector)}${INDEX_SEPARATOR}${index}`;
|
|
43
66
|
}
|
|
44
67
|
return null;
|
|
45
68
|
}
|
|
46
69
|
|
|
47
|
-
export function timelineElementAddress(
|
|
70
|
+
export function timelineElementAddress(
|
|
71
|
+
element: TimelineElement,
|
|
72
|
+
activeCompositionPath: string | null = "index.html",
|
|
73
|
+
projectId?: string | null,
|
|
74
|
+
): ElementAddress {
|
|
75
|
+
const rootFile = activeCompositionPath ?? "index.html";
|
|
48
76
|
return {
|
|
77
|
+
projectId,
|
|
49
78
|
hfId: element.hfId,
|
|
50
79
|
domId: element.domId,
|
|
51
80
|
selector: element.selector,
|
|
52
81
|
selectorIndex: element.selectorIndex,
|
|
82
|
+
sourceFile: element.sourceFile ?? rootFile,
|
|
83
|
+
activeCompositionPath: rootFile,
|
|
53
84
|
};
|
|
54
85
|
}
|
|
55
86
|
|
|
56
|
-
export function patchTargetAddress(
|
|
87
|
+
export function patchTargetAddress(
|
|
88
|
+
target: PatchTarget & { sourceFile?: string },
|
|
89
|
+
activeCompositionPath?: string | null,
|
|
90
|
+
projectId?: string | null,
|
|
91
|
+
): ElementAddress {
|
|
57
92
|
return {
|
|
93
|
+
projectId,
|
|
58
94
|
hfId: target.hfId,
|
|
59
95
|
domId: target.id,
|
|
60
96
|
selector: target.selector,
|
|
61
97
|
selectorIndex: target.selectorIndex,
|
|
98
|
+
sourceFile: target.sourceFile,
|
|
99
|
+
activeCompositionPath,
|
|
62
100
|
};
|
|
63
101
|
}
|
|
64
102
|
|
|
65
|
-
interface ParsedHandle {
|
|
103
|
+
export interface ParsedHandle {
|
|
66
104
|
scheme: "hf" | "dom" | "sel";
|
|
105
|
+
version: 0 | 1 | 2;
|
|
67
106
|
value: string;
|
|
68
107
|
index: number;
|
|
108
|
+
projectId?: string;
|
|
109
|
+
sourceFile?: string;
|
|
110
|
+
activeCompositionPath?: string;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function decodeScopedParts(rest: string, version: "v1" | "v2", count: number) {
|
|
114
|
+
const parts = rest.split(SEPARATOR);
|
|
115
|
+
if (parts[0] !== version || parts.length !== count + 1) return null;
|
|
116
|
+
return parts.slice(1).map(decodeHandlePart);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function parseV1ScopedValue(rest: string): Omit<ParsedHandle, "scheme" | "index"> | null {
|
|
120
|
+
const [activeCompositionPath, sourceFile, value] = decodeScopedParts(rest, "v1", 3) ?? [];
|
|
121
|
+
if (!activeCompositionPath || !sourceFile || !value) return null;
|
|
122
|
+
return { version: 1, value, sourceFile, activeCompositionPath };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function parseV2ScopedValue(rest: string): Omit<ParsedHandle, "scheme" | "index"> | null {
|
|
126
|
+
const [projectId, activeCompositionPath, sourceFile, value] =
|
|
127
|
+
decodeScopedParts(rest, "v2", 4) ?? [];
|
|
128
|
+
if (!projectId || !activeCompositionPath || !sourceFile || !value) return null;
|
|
129
|
+
return { version: 2, projectId, value, sourceFile, activeCompositionPath };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function parseScopedValue(rest: string): Omit<ParsedHandle, "scheme" | "index"> | null {
|
|
133
|
+
if (rest.startsWith(`v1${SEPARATOR}`)) return parseV1ScopedValue(rest);
|
|
134
|
+
if (rest.startsWith(`v2${SEPARATOR}`)) return parseV2ScopedValue(rest);
|
|
135
|
+
return { value: rest, version: 0 };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function decodeHandlePart(value: string): string | null {
|
|
139
|
+
try {
|
|
140
|
+
return decodeURIComponent(value) || null;
|
|
141
|
+
} catch {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
69
144
|
}
|
|
70
145
|
|
|
71
146
|
export function parseElementHandle(handle: string): ParsedHandle | null {
|
|
@@ -74,15 +149,25 @@ export function parseElementHandle(handle: string): ParsedHandle | null {
|
|
|
74
149
|
const scheme = handle.slice(0, separatorAt);
|
|
75
150
|
const rest = handle.slice(separatorAt + 1);
|
|
76
151
|
if (!rest) return null;
|
|
77
|
-
if (scheme === "hf" || scheme === "dom")
|
|
152
|
+
if (scheme === "hf" || scheme === "dom") {
|
|
153
|
+
const scoped = parseScopedValue(rest);
|
|
154
|
+
return scoped ? { scheme, ...scoped, index: 0 } : null;
|
|
155
|
+
}
|
|
78
156
|
if (scheme !== "sel") return null;
|
|
79
157
|
|
|
80
158
|
// Only the LAST `#` splits the index off: CSS selectors contain `#` themselves.
|
|
81
159
|
const indexAt = rest.lastIndexOf(INDEX_SEPARATOR);
|
|
82
|
-
if (indexAt <= 0)
|
|
160
|
+
if (indexAt <= 0) {
|
|
161
|
+
const scoped = parseScopedValue(rest);
|
|
162
|
+
return scoped ? { scheme, ...scoped, index: 0 } : null;
|
|
163
|
+
}
|
|
83
164
|
const index = Number(rest.slice(indexAt + 1));
|
|
84
|
-
if (!Number.isInteger(index) || index < 0)
|
|
85
|
-
|
|
165
|
+
if (!Number.isInteger(index) || index < 0) {
|
|
166
|
+
const scoped = parseScopedValue(rest);
|
|
167
|
+
return scoped ? { scheme, ...scoped, index: 0 } : null;
|
|
168
|
+
}
|
|
169
|
+
const scoped = parseScopedValue(rest.slice(0, indexAt));
|
|
170
|
+
return scoped ? { scheme, ...scoped, index } : null;
|
|
86
171
|
}
|
|
87
172
|
|
|
88
173
|
/**
|
|
@@ -95,35 +180,67 @@ export function parseElementHandle(handle: string): ParsedHandle | null {
|
|
|
95
180
|
export function resolveElementHandle(doc: Document, handle: string): HTMLElement | null {
|
|
96
181
|
const parsed = parseElementHandle(handle);
|
|
97
182
|
if (!parsed) return null;
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
return null;
|
|
110
|
-
}
|
|
111
|
-
return asHtmlElement(doc, matches.item(parsed.index));
|
|
183
|
+
return findElementForSelection(
|
|
184
|
+
doc,
|
|
185
|
+
{
|
|
186
|
+
hfId: parsed.scheme === "hf" ? parsed.value : undefined,
|
|
187
|
+
id: parsed.scheme === "dom" ? parsed.value : undefined,
|
|
188
|
+
selector: parsed.scheme === "sel" ? parsed.value : undefined,
|
|
189
|
+
selectorIndex: parsed.scheme === "sel" ? parsed.index : undefined,
|
|
190
|
+
sourceFile: parsed.sourceFile,
|
|
191
|
+
},
|
|
192
|
+
parsed.activeCompositionPath ?? null,
|
|
193
|
+
);
|
|
112
194
|
}
|
|
113
195
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
196
|
+
/** Legacy read handles remain valid, but a scoped handle must name the active project. */
|
|
197
|
+
export function elementHandleMatchesProject(handle: string, projectId: string | null): boolean {
|
|
198
|
+
const parsed = parseElementHandle(handle);
|
|
199
|
+
if (!parsed) return false;
|
|
200
|
+
return parsed.version !== 2 || (!!projectId && parsed.projectId === projectId);
|
|
118
201
|
}
|
|
119
202
|
|
|
203
|
+
export type LiveHandleResolution<T extends { element: HTMLElement }> =
|
|
204
|
+
| { status: "ready"; selection: T }
|
|
205
|
+
| { status: "preview-unavailable" }
|
|
206
|
+
| { status: "not-found" }
|
|
207
|
+
| { status: "unsupported" }
|
|
208
|
+
| { status: "changed" };
|
|
209
|
+
|
|
120
210
|
/**
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
211
|
+
* Resolve and build a selection across one possible preview reload.
|
|
212
|
+
*
|
|
213
|
+
* Building a selection can await a source probe. A thumbnail refresh may
|
|
214
|
+
* replace the preview document during that await, leaving an otherwise valid
|
|
215
|
+
* selection attached to the old document with a truthful-looking 0x0 box.
|
|
216
|
+
* Reacquire once from the current document, then refuse if it moves again.
|
|
124
217
|
*/
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
218
|
+
export async function resolveLiveHandleSelection<T extends { element: HTMLElement }>(
|
|
219
|
+
getPreviewDocument: () => Document | null,
|
|
220
|
+
handle: string,
|
|
221
|
+
buildSelection: (element: HTMLElement) => Promise<T | null>,
|
|
222
|
+
): Promise<LiveHandleResolution<T>> {
|
|
223
|
+
let reloadObserved = false;
|
|
224
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
225
|
+
const doc = getPreviewDocument();
|
|
226
|
+
if (!doc) return { status: "preview-unavailable" };
|
|
227
|
+
const element = resolveElementHandle(doc, handle);
|
|
228
|
+
if (!element) return { status: reloadObserved ? "changed" : "not-found" };
|
|
229
|
+
|
|
230
|
+
const selection = await buildSelection(element);
|
|
231
|
+
const currentDoc = getPreviewDocument();
|
|
232
|
+
const previewChanged =
|
|
233
|
+
currentDoc !== doc ||
|
|
234
|
+
element.ownerDocument !== doc ||
|
|
235
|
+
!element.isConnected ||
|
|
236
|
+
(selection !== null &&
|
|
237
|
+
(selection.element.ownerDocument !== doc || !selection.element.isConnected));
|
|
238
|
+
if (previewChanged) {
|
|
239
|
+
reloadObserved = true;
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
if (!selection) return { status: "unsupported" };
|
|
243
|
+
return { status: "ready", selection };
|
|
244
|
+
}
|
|
245
|
+
return { status: "changed" };
|
|
129
246
|
}
|