@hyperframes/studio 0.8.23 → 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.
Files changed (90) hide show
  1. package/dist/assets/{hyperframes-player-oZ6g4tHJ.js → hyperframes-player-CTP_00ix.js} +1 -1
  2. package/dist/assets/{index-UyvIa_cE.js → index-Bjb4YqS_.js} +1 -1
  3. package/dist/assets/{index-Tiu74FPg.js → index-DSk_ALFr.js} +218 -218
  4. package/dist/assets/{index-DW-H7Y2b.js → index-Di-8Jxqc.js} +1 -1
  5. package/dist/assets/index-DnRfAiK2.css +1 -0
  6. package/dist/{chunk-6BT6DTB4.js → chunk-A7S5SGNA.js} +2 -2
  7. package/dist/chunk-A7S5SGNA.js.map +1 -0
  8. package/dist/{domEditingLayers-URA7BLWE.js → domEditingLayers-JMMVOGTO.js} +2 -2
  9. package/dist/index.d.ts +4 -0
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +6206 -5011
  12. package/dist/index.js.map +1 -1
  13. package/package.json +8 -7
  14. package/src/components/editor/TopologyLens.test.tsx +294 -0
  15. package/src/components/editor/TopologyLens.tsx +206 -0
  16. package/src/components/editor/domEditOverlayGeometry.ts +14 -0
  17. package/src/components/editor/domEditOverlayGeometryBatch.test.ts +18 -0
  18. package/src/components/editor/domEditingLayers.test.ts +9 -0
  19. package/src/components/editor/domEditingLayers.ts +8 -8
  20. package/src/components/editor/topologyLensGeometry.test.ts +44 -0
  21. package/src/components/editor/topologyLensGeometry.ts +119 -0
  22. package/src/components/editor/topologyLensState.test.ts +116 -0
  23. package/src/components/editor/topologyLensState.ts +72 -0
  24. package/src/components/nle/PreviewOverlays.topologyLens.test.tsx +126 -0
  25. package/src/components/nle/PreviewOverlays.tsx +24 -18
  26. package/src/components/sidebar/CompositionsTab.drag.test.tsx +16 -0
  27. package/src/components/sidebar/CompositionsTab.tsx +10 -3
  28. package/src/components/ui/HyperframesLoader.tsx +3 -48
  29. package/src/components/ui/HyperframesMark.tsx +53 -0
  30. package/src/contexts/DomEditContext.tsx +12 -0
  31. package/src/hooks/domEditCommitRunner.test.ts +59 -0
  32. package/src/hooks/domEditCommitRunner.ts +55 -20
  33. package/src/hooks/domEditCommitTypes.ts +8 -1
  34. package/src/hooks/domEditTextCommitPlan.ts +47 -0
  35. package/src/hooks/useDomEditAttributeCommits.ts +4 -1
  36. package/src/hooks/useDomEditCommits.test.tsx +207 -8
  37. package/src/hooks/useDomEditCommits.ts +54 -14
  38. package/src/hooks/useDomEditPositionPatchCommit.test.tsx +3 -4
  39. package/src/hooks/useDomEditPositionPatchCommit.ts +27 -29
  40. package/src/hooks/useDomEditSession.ts +6 -2
  41. package/src/hooks/useDomEditTextCommits.test.tsx +86 -2
  42. package/src/hooks/useDomEditTextCommits.ts +64 -86
  43. package/src/hooks/useDomEditWiring.ts +1 -1
  44. package/src/hooks/useDomSelection.ts +2 -0
  45. package/src/hooks/useDomSelectionTypes.ts +2 -0
  46. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +71 -4
  47. package/src/hooks/useExternalFileChangeCoordinator.ts +18 -9
  48. package/src/hooks/useGsapAnimationFetchFallback.test.ts +13 -1
  49. package/src/hooks/useGsapAnimationFetchFallback.ts +7 -3
  50. package/src/hooks/useGsapAnimationOps.test.tsx +101 -0
  51. package/src/hooks/useGsapAnimationOps.ts +4 -4
  52. package/src/hooks/useGsapAwareEditing.test.tsx +30 -2
  53. package/src/hooks/useGsapAwareEditing.ts +31 -10
  54. package/src/hooks/useGsapSelectionHandlers.test.tsx +3 -1
  55. package/src/hooks/useGsapSelectionHandlers.ts +21 -11
  56. package/src/hooks/useRenderClipContent.test.ts +27 -0
  57. package/src/hooks/useRenderClipContent.ts +5 -0
  58. package/src/hooks/useStudioExternalFileChanges.ts +5 -0
  59. package/src/player/components/CompositionThumbnail.test.ts +66 -1
  60. package/src/player/components/CompositionThumbnail.tsx +6 -0
  61. package/src/player/store/playerStore.test.ts +24 -0
  62. package/src/player/store/thumbnailSlice.ts +6 -0
  63. package/src/styles/studio.css +179 -0
  64. package/src/utils/domEditSaveQueue.test.ts +5 -3
  65. package/src/utils/domEditSaveQueue.ts +6 -1
  66. package/src/webmcp/StudioAgentTools.test.ts +70 -0
  67. package/src/webmcp/StudioAgentTools.tsx +89 -21
  68. package/src/webmcp/handles.test.ts +172 -15
  69. package/src/webmcp/handles.ts +152 -35
  70. package/src/webmcp/tools/animationTools.test.ts +241 -60
  71. package/src/webmcp/tools/animationTools.ts +191 -104
  72. package/src/webmcp/tools/contentTools.test.ts +103 -41
  73. package/src/webmcp/tools/contentTools.ts +149 -105
  74. package/src/webmcp/tools/inspectTools.test.ts +47 -5
  75. package/src/webmcp/tools/inspectTools.ts +59 -19
  76. package/src/webmcp/tools/lookTools.test.ts +185 -38
  77. package/src/webmcp/tools/lookTools.ts +134 -21
  78. package/src/webmcp/tools/selectionTools.test.ts +105 -9
  79. package/src/webmcp/tools/selectionTools.ts +34 -19
  80. package/src/webmcp/tools/targetedWriteTools.test.ts +125 -0
  81. package/src/webmcp/tools/transformTools.test.ts +140 -54
  82. package/src/webmcp/tools/transformTools.ts +215 -56
  83. package/src/webmcp/useStudioAgentTools.test.tsx +310 -10
  84. package/src/webmcp/useStudioAgentTools.ts +27 -15
  85. package/src/webmcp/webmcpTestUtils.ts +37 -0
  86. package/src/webmcp/writeCoordinator.test.ts +375 -0
  87. package/src/webmcp/writeCoordinator.ts +473 -0
  88. package/dist/assets/index-yGhfxxoL.css +0 -1
  89. package/dist/chunk-6BT6DTB4.js.map +0 -1
  90. /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({ scheme: "sel", value: ".card", index: 0 });
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();
@@ -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
- if (address.hfId) return `hf${SEPARATOR}${address.hfId}`;
39
- if (address.domId) return `dom${SEPARATOR}${address.domId}`;
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(element: TimelineElement): ElementAddress {
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(target: PatchTarget): ElementAddress {
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") return { scheme, value: rest, index: 0 };
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) return { scheme, value: rest, index: 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) return { scheme, value: rest, index: 0 };
85
- return { scheme, value: rest.slice(0, indexAt), index };
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
- if (parsed.scheme === "dom") return asHtmlElement(doc, doc.getElementById(parsed.value));
100
- if (parsed.scheme === "hf") {
101
- return asHtmlElement(doc, doc.querySelector(`[data-hf-id="${cssEscape(parsed.value)}"]`));
102
- }
103
-
104
- let matches: NodeListOf<Element>;
105
- try {
106
- matches = doc.querySelectorAll(parsed.value);
107
- } catch {
108
- // A selector minted from a previous document can be invalid in this one.
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
- function cssEscape(value: string): string {
115
- // ponytail: happy-dom and jsdom don't always ship CSS.escape; quoting the two
116
- // characters that can break out of an attribute selector covers this use.
117
- return typeof CSS?.escape === "function" ? CSS.escape(value) : value.replace(/["\\]/g, "\\$&");
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
- * `instanceof HTMLElement` is checked against the OWNING document's realm.
122
- * The preview lives in an iframe, so Studio's own `HTMLElement` is a different
123
- * constructor and the naive check fails on every real preview element.
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
- function asHtmlElement(doc: Document, node: Element | null): HTMLElement | null {
126
- if (!node) return null;
127
- const ctor = doc.defaultView?.HTMLElement;
128
- return ctor && node instanceof ctor ? node : null;
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
  }