@mosetta/editor 0.0.0-stage → 0.0.28

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 (115) hide show
  1. package/CHANGELOG.md +185 -0
  2. package/LICENSE +21 -0
  3. package/README.md +10 -2
  4. package/mosetta.json +6 -0
  5. package/package.json +35 -3
  6. package/prefabs/workbench/prefab.json +11 -0
  7. package/prefabs/workbench/versions/1.json +120 -0
  8. package/prefabs/workbench/versions/2.json +13 -0
  9. package/prefabs/workbench/versions/3.json +19 -0
  10. package/prefabs/workbench/versions/4.json +29 -0
  11. package/prefabs/workbench/versions/5.json +29 -0
  12. package/tiles/canvas/canvas.component.tsx +2038 -0
  13. package/tiles/canvas/canvas.css +973 -0
  14. package/tiles/canvas/canvasTools.ts +66 -0
  15. package/tiles/canvas/chromeNodes.ts +35 -0
  16. package/tiles/canvas/ghosts.ts +33 -0
  17. package/tiles/canvas/insertPopover.tsx +56 -0
  18. package/tiles/canvas/popoverPlacement.ts +104 -0
  19. package/tiles/canvas/popoverSize.ts +87 -0
  20. package/tiles/canvas/providerChrome.tsx +191 -0
  21. package/tiles/canvas/scrollWatch.ts +43 -0
  22. package/tiles/canvas/settingsPopover.tsx +257 -0
  23. package/tiles/canvas/settingsTabs.ts +78 -0
  24. package/tiles/canvas/toolPanel.tsx +86 -0
  25. package/tiles/content/autoBind.ts +61 -0
  26. package/tiles/content/blockPage.css +390 -0
  27. package/tiles/content/blockPage.tsx +379 -0
  28. package/tiles/content/blockPicker.css +215 -0
  29. package/tiles/content/blockPicker.tsx +501 -0
  30. package/tiles/content/blockUpgrades.ts +145 -0
  31. package/tiles/content/blockVariants.ts +185 -0
  32. package/tiles/content/blockVocabulary.ts +77 -0
  33. package/tiles/content/catalogVersions.ts +106 -0
  34. package/tiles/content/componentPreview.css +82 -0
  35. package/tiles/content/componentPreview.tsx +249 -0
  36. package/tiles/content/content.server.ts +593 -0
  37. package/tiles/content/contentCommon.ts +133 -0
  38. package/tiles/content/disconnected.ts +56 -0
  39. package/tiles/content/distributions.css +256 -0
  40. package/tiles/content/distributions.tsx +271 -0
  41. package/tiles/content/editingAutosave.ts +125 -0
  42. package/tiles/content/editingClipboard.ts +185 -0
  43. package/tiles/content/editingConflicts.ts +223 -0
  44. package/tiles/content/editingHotkeys.ts +59 -0
  45. package/tiles/content/editingNotices.ts +105 -0
  46. package/tiles/content/formRoot.ts +18 -0
  47. package/tiles/content/fuzzySearch.ts +121 -0
  48. package/tiles/content/mediaUpload.ts +55 -0
  49. package/tiles/content/moduleSiblings.ts +9 -0
  50. package/tiles/content/mountedTree.ts +136 -0
  51. package/tiles/content/nodeSettings.css +483 -0
  52. package/tiles/content/nodeSettings.tsx +963 -0
  53. package/tiles/content/pasteText.ts +131 -0
  54. package/tiles/content/prefabFields.tsx +158 -0
  55. package/tiles/content/prefabParts.ts +105 -0
  56. package/tiles/content/previewEnvironment.ts +46 -0
  57. package/tiles/content/projectConfig.ts +46 -0
  58. package/tiles/content/projectPrefabs.ts +57 -0
  59. package/tiles/content/publishModal.css +109 -0
  60. package/tiles/content/publishModal.tsx +92 -0
  61. package/tiles/content/refsEditing.ts +412 -0
  62. package/tiles/content/scaledStage.ts +63 -0
  63. package/tiles/content/searchField.css +32 -0
  64. package/tiles/content/searchField.tsx +32 -0
  65. package/tiles/content/settingsDraft.ts +178 -0
  66. package/tiles/content/settingsIssues.tsx +124 -0
  67. package/tiles/content/themeScope.ts +72 -0
  68. package/tiles/content/themeVariables.ts +47 -0
  69. package/tiles/content/tooltipPlacement.ts +17 -0
  70. package/tiles/content/tooltips.tsx +131 -0
  71. package/tiles/content/treeMounter.ts +33 -0
  72. package/tiles/content/updatesModal.css +124 -0
  73. package/tiles/content/updatesModal.tsx +110 -0
  74. package/tiles/content/useCtrlHeld.ts +24 -0
  75. package/tiles/content/useEditingSession.ts +115 -0
  76. package/tiles/content/useMountedTree.ts +47 -0
  77. package/tiles/content/workbenchScale.css +27 -0
  78. package/tiles/hostFallback/hostFallback.component.tsx +32 -0
  79. package/tiles/hostFallback/hostFallback.css +12 -0
  80. package/tiles/hostFallback/hostFallback.editor.tsx +10 -0
  81. package/tiles/page/page.component.tsx +7 -0
  82. package/tiles/page/page.script.tsx +5 -0
  83. package/tiles/pageModules/pageModules.component.tsx +740 -0
  84. package/tiles/pageModules/pageModules.css +383 -0
  85. package/tiles/pageModules/pageModules.icon.svg +1 -0
  86. package/tiles/pageModules/servedPage.ts +465 -0
  87. package/tiles/pagesPanel/pagesPanel.component.tsx +1645 -0
  88. package/tiles/pagesPanel/pagesPanel.css +713 -0
  89. package/tiles/pagesPanel/pagesPanel.editor.tsx +23 -0
  90. package/tiles/pagesPanel/pagesSearch.ts +194 -0
  91. package/tiles/pagesPanel/routeTree.ts +98 -0
  92. package/tiles/previewBridge/landing.ts +66 -0
  93. package/tiles/previewBridge/previewAgent.ts +457 -0
  94. package/tiles/previewBridge/previewBridge.component.tsx +137 -0
  95. package/tiles/previewBridge/previewBridge.editor.tsx +10 -0
  96. package/tiles/previewBridge/previewBridgeCommon.ts +172 -0
  97. package/tiles/previewBridge/standIn.ts +22 -0
  98. package/tiles/treeView/treeView.component.tsx +514 -0
  99. package/tiles/treeView/treeView.css +303 -0
  100. package/tiles/treeView/treeView.editor.tsx +12 -0
  101. package/tiles/workbench/assistant.css +401 -0
  102. package/tiles/workbench/assistant.tsx +516 -0
  103. package/tiles/workbench/assistantChat.ts +165 -0
  104. package/tiles/workbench/dictation.ts +354 -0
  105. package/tiles/workbench/projectInfo.ts +72 -0
  106. package/tiles/workbench/recent.ts +67 -0
  107. package/tiles/workbench/workbench.component.tsx +476 -0
  108. package/tiles/workbench/workbench.css +376 -0
  109. package/tiles/workbench/workbenchAddress.ts +202 -0
  110. package/tiles/workbench/workspace.ts +775 -0
  111. package/tiles/workbenchEditor/workbenchEditor.component.tsx +461 -0
  112. package/tiles/workbenchEditor/workbenchEditor.css +370 -0
  113. package/tiles/workbenchHome/workbenchHome.component.tsx +196 -0
  114. package/tiles/workbenchHome/workbenchHome.css +271 -0
  115. package/tsconfig.json +3 -0
@@ -0,0 +1,457 @@
1
+ // The editor's AGENT inside the preview realm. Draws nothing (the overlay lives in the editor's
2
+ // own document, where it cannot collide with the application's UI) — it measures, hit-tests, and
3
+ // reports over the channel described in previewBridgeCommon.
4
+ //
5
+ // Why an agent is needed at all: geometry and hit-testing only exist inside the iframe. The editor
6
+ // knows the page TREE; the iframe knows where that tree landed on screen.
7
+ //
8
+ // While armed it also SWALLOWS the application's pointer handling: inside the editor a click on a
9
+ // block means "select this block", not "follow the link". Suppression happens in the capture phase
10
+ // at the document root, above react's root container, so the application's handlers never run —
11
+ // nothing to undo afterwards.
12
+ import { NODE_ID_ATTRIBUTE, previewSlots, SLOT_ATTRIBUTE, slotKey } from '@mosetta/react';
13
+ import type { IBlueprintNode, IBlueprint } from '@mosetta/core';
14
+ import { expandUnitsFrom, isOutletReference, unitOwnerOf } from '@mosetta/content/units';
15
+ import type { IBlueprintNodeDoc } from '@mosetta/content/units';
16
+ import {
17
+ ILandingPlace,
18
+ INodeBox,
19
+ isPreviewPortOffer,
20
+ ISlotBox,
21
+ PREVIEW_HELLO,
22
+ PREVIEW_PROTOCOL_VERSION,
23
+ STAND_IN_ATTRIBUTE,
24
+ TPreviewCommand,
25
+ TPreviewEvent,
26
+ } from './previewBridgeCommon';
27
+
28
+ const NODE_SELECTOR = `[${NODE_ID_ATTRIBUTE}]`;
29
+
30
+ // the greeting repeats until the embedder answers: the agent may well mount before the editor
31
+ // side is listening (a reloaded iframe races the overlay's own effects)
32
+ const HELLO_INTERVAL_MS = 300;
33
+ const HELLO_ATTEMPTS = 20;
34
+
35
+ // events the agent eats while armed. Pointer and touch events stay untouched on purpose —
36
+ // swallowing them would take native scrolling and hovering with them.
37
+ const SUPPRESSED_EVENTS = ['click', 'auxclick', 'dblclick', 'mousedown', 'mouseup', 'submit'] as const;
38
+
39
+ /** how long after the last scroll event the page is considered still, and worth measuring again */
40
+ const SCROLL_SETTLE_MS = 150;
41
+
42
+ // a block of an expanded PREFAB carries `<prefabNode>/<inner>` as its id: to the editor it IS the
43
+ // prefab node — hovering or picking the prefab's blocks addresses the prefab, never its insides
44
+ const ownerOf = (id: string): string => unitOwnerOf(id) ?? id;
45
+
46
+ function nodeIdOf(target: EventTarget | null): string | null {
47
+ const host = target instanceof Element ? target.closest(NODE_SELECTOR) : null;
48
+ const id = host?.getAttribute(NODE_ID_ATTRIBUTE) ?? null;
49
+ return id === null ? null : ownerOf(id);
50
+ }
51
+
52
+ /**
53
+ * …and WHICH of the prefab's own blocks it was — its expanded id, for the editor to show it
54
+ * (read-only); null over the page's own block. A prefab's outlet is the page's place, not a block
55
+ * of the prefab: the search goes on to the block around it.
56
+ */
57
+ function partOf(target: EventTarget | null): string | null {
58
+ let host = target instanceof Element ? target.closest(NODE_SELECTOR) : null;
59
+ while (host) {
60
+ const id = host.getAttribute(NODE_ID_ATTRIBUTE) ?? '';
61
+ if (unitOwnerOf(id) === null) return null;
62
+ if (!isOutletReference(host.className)) return id;
63
+ host = host.parentElement?.closest(NODE_SELECTOR) ?? null;
64
+ }
65
+ return null;
66
+ }
67
+
68
+ function depthOf(element: Element): number {
69
+ let depth = 0;
70
+ for (let parent = element.parentElement; parent; parent = parent.parentElement) {
71
+ if (parent.hasAttribute(NODE_ID_ATTRIBUTE)) depth++;
72
+ }
73
+ return depth;
74
+ }
75
+
76
+ /**
77
+ * Where a node's contents landed. The scope host is `display: contents`, so it generates no box of
78
+ * its own — the union of what it CONTAINS is measured instead; an element that does produce a box
79
+ * still answers directly.
80
+ */
81
+ function boxOf(element: Element, range: Range): INodeBox | null {
82
+ const id = element.getAttribute(NODE_ID_ATTRIBUTE);
83
+ if (!id) return null;
84
+ range.selectNodeContents(element);
85
+ let rect = range.getBoundingClientRect();
86
+ if (!rect.width && !rect.height) rect = element.getBoundingClientRect();
87
+ if (!rect.width && !rect.height) return null; // renders nothing — nothing to outline
88
+ return {
89
+ id,
90
+ // a stand-in (standIn.ts) is the agent's own module, but to the editor it is the block it
91
+ // stands for — the outline names that one
92
+ reference: element.firstElementChild?.getAttribute(STAND_IN_ATTRIBUTE) ?? element.className.trim(),
93
+ depth: depthOf(element),
94
+ x: rect.x,
95
+ y: rect.y,
96
+ width: rect.width,
97
+ height: rect.height,
98
+ };
99
+ }
100
+
101
+ /**
102
+ * The tree to render after a live update. The editor's document knows nothing of the agent — the
103
+ * node was attached on the way out, by the render runtime — so an incoming tree would quietly
104
+ * unmount the very module that received it. What the realm already holds wins twice here: the
105
+ * agent's own node is carried over, and so is everything the boot envelope added on top of the
106
+ * document (the catalog slice, the host base, the ssr stamp), which no editor ever sees.
107
+ */
108
+ export function withAgentNode(current: IBlueprint, next: IBlueprint, agentReference: string): IBlueprint {
109
+ const arriving = new Set((next.children ?? []).map((child) => child.id));
110
+ const mine = (current.children ?? []).filter(
111
+ (child) => child.reference === agentReference && !arriving.has(child.id),
112
+ );
113
+ const children: IBlueprintNode[] = mine.length ? [...(next.children ?? []), ...mine] : (next.children ?? []);
114
+ // prefabs the realm already shows expand from their rendered blocks — the editor's document
115
+ // names them raw, and a raw prefab node has no module to render
116
+ const merged = { ...current, ...next, children } as unknown as IBlueprintNodeDoc;
117
+ return expandUnitsFrom(current, merged) as unknown as IBlueprint;
118
+ }
119
+
120
+ export interface IPreviewAgentOptions {
121
+ /** what a live tree update does in this realm — in practice the page manager's setCurrentPage */
122
+ readonly applyPage?: (page: IBlueprint) => void;
123
+ /** where a block is about to go, or nowhere — the landing space follows (withLanding) */
124
+ readonly applyLanding?: (at: ILandingPlace | null) => void;
125
+ /** what new provider views do in this realm — in practice the application's views store */
126
+ readonly applyViews?: (views: Readonly<Record<string, unknown>>) => void;
127
+ }
128
+
129
+ export class PreviewAgent {
130
+ private port: MessagePort | null = null;
131
+
132
+ private armed = true;
133
+
134
+ private helloTimer: ReturnType<typeof setInterval> | null = null;
135
+
136
+ private frame: number | null = null;
137
+
138
+ private hovered: string | null = null;
139
+
140
+ /** the prefab's own block under the pointer, when it is over one */
141
+ private hoveredPart: string | null = null;
142
+
143
+ private hoveredCtrl = false;
144
+
145
+ /** the pointer's latest position, sent at most once a frame */
146
+ private pointer: { x: number; y: number } | null = null;
147
+
148
+ private pointerFrame: number | null = null;
149
+
150
+ private readonly range = document.createRange();
151
+
152
+ private resizeObserver: ResizeObserver | null = null;
153
+
154
+ /** elements currently under the size observer — see watchSizes */
155
+ private readonly watched = new Set<Element>();
156
+
157
+ private settleTimer: ReturnType<typeof setTimeout> | null = null;
158
+
159
+ private readonly mutationObserver = new MutationObserver(() => this.schedule());
160
+
161
+ private unwatchSlots: (() => void) | null = null;
162
+
163
+ constructor(private readonly options: IPreviewAgentOptions = {}) {}
164
+
165
+ start(): void {
166
+ // not embedded → nobody to talk to (a preview URL opened directly, for instance)
167
+ if (window.parent === window) return;
168
+ window.addEventListener('message', this.onWindowMessage);
169
+ let attempts = 0;
170
+ const greet = (): void => {
171
+ if (this.port || attempts++ >= HELLO_ATTEMPTS) return this.stopGreeting();
172
+ // the greeting carries no page data, so an unknown embedder learns nothing from it; the
173
+ // channel that follows is what actually opens the conversation
174
+ window.parent.postMessage({ mosetta: PREVIEW_HELLO, protocol: PREVIEW_PROTOCOL_VERSION }, '*');
175
+ };
176
+ greet();
177
+ this.helloTimer = setInterval(greet, HELLO_INTERVAL_MS);
178
+ }
179
+
180
+ stop(): void {
181
+ this.stopGreeting();
182
+ window.removeEventListener('message', this.onWindowMessage);
183
+ this.unwatchSlots?.();
184
+ this.unwatchSlots = null;
185
+ this.detachDocument();
186
+ if (this.frame !== null) cancelAnimationFrame(this.frame);
187
+ if (this.pointerFrame !== null) cancelAnimationFrame(this.pointerFrame);
188
+ this.port?.close();
189
+ this.port = null;
190
+ }
191
+
192
+ private stopGreeting(): void {
193
+ if (this.helloTimer !== null) clearInterval(this.helloTimer);
194
+ this.helloTimer = null;
195
+ }
196
+
197
+ private readonly onWindowMessage = (event: MessageEvent): void => {
198
+ if (this.port || !isPreviewPortOffer(event.data)) return;
199
+ const port = event.ports[0];
200
+ if (!port) return;
201
+ this.port = port;
202
+ this.stopGreeting();
203
+ port.onmessage = (message: MessageEvent) => this.onCommand(message.data as TPreviewCommand);
204
+ port.start();
205
+ this.attachDocument();
206
+ // arming happens HERE and not a moment earlier: markers appearing during the first client
207
+ // render would disagree with the server's HTML and break hydration. The channel being open
208
+ // means the app is mounted.
209
+ this.unwatchSlots = previewSlots.subscribe(this.schedule);
210
+ previewSlots.arm();
211
+ this.schedule();
212
+ };
213
+
214
+ private onCommand(command: TPreviewCommand): void {
215
+ if (command.t === 'armed') this.armed = command.armed;
216
+ else if (command.t === 'measure') this.schedule();
217
+ else if (command.t === 'views') {
218
+ this.options.applyViews?.(command.views);
219
+ this.schedule();
220
+ } else if (command.t === 'page') {
221
+ // the re-render lands after this turn, so the measurement worth reporting is the one the
222
+ // mutation observer takes then; this one keeps a layout-only change from going unnoticed
223
+ this.options.applyPage?.(command.page);
224
+ this.schedule();
225
+ } else if (command.t === 'landing') {
226
+ this.options.applyLanding?.(command.at);
227
+ this.schedule();
228
+ } else if (command.t === 'reveal') {
229
+ // scanned rather than selected: node ids come from a content document — any string at all,
230
+ // and a selector built out of one is a quoting accident waiting to happen
231
+ for (const element of document.querySelectorAll(NODE_SELECTOR)) {
232
+ if (element.getAttribute(NODE_ID_ATTRIBUTE) !== command.nodeId) continue;
233
+ element.firstElementChild?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
234
+ break;
235
+ }
236
+ }
237
+ }
238
+
239
+ private attachDocument(): void {
240
+ for (const type of SUPPRESSED_EVENTS) {
241
+ document.addEventListener(type, this.onSuppressed, true);
242
+ }
243
+ document.addEventListener('pointermove', this.onPointerMove, { capture: true, passive: true });
244
+ // on the ROOT element and without capture: leaving any nested element would otherwise read as
245
+ // leaving the preview altogether
246
+ document.documentElement.addEventListener('pointerleave', this.onPointerLeave, { passive: true });
247
+ // anything that can move a box: the viewport, scrolling of ANY container (capture — scroll
248
+ // does not bubble), layout changes, and late-arriving images and fonts
249
+ window.addEventListener('resize', this.schedule, { passive: true });
250
+ window.addEventListener('load', this.schedule, { passive: true });
251
+ document.addEventListener('scroll', this.onScroll, { capture: true, passive: true });
252
+ if (typeof ResizeObserver !== 'undefined') {
253
+ this.resizeObserver = new ResizeObserver(() => this.schedule());
254
+ this.resizeObserver.observe(document.documentElement);
255
+ }
256
+ this.mutationObserver.observe(document.documentElement, {
257
+ subtree: true, childList: true, attributes: true, characterData: true,
258
+ });
259
+ }
260
+
261
+ private detachDocument(): void {
262
+ for (const type of SUPPRESSED_EVENTS) {
263
+ document.removeEventListener(type, this.onSuppressed, true);
264
+ }
265
+ document.removeEventListener('pointermove', this.onPointerMove, true);
266
+ document.documentElement.removeEventListener('pointerleave', this.onPointerLeave);
267
+ window.removeEventListener('resize', this.schedule);
268
+ window.removeEventListener('load', this.schedule);
269
+ document.removeEventListener('scroll', this.onScroll, true);
270
+ if (this.settleTimer !== null) clearTimeout(this.settleTimer);
271
+ this.settleTimer = null;
272
+ this.resizeObserver?.disconnect();
273
+ this.resizeObserver = null;
274
+ this.watched.clear();
275
+ this.mutationObserver.disconnect();
276
+ }
277
+
278
+ private readonly onSuppressed = (event: Event): void => {
279
+ if (!this.armed) return;
280
+ event.preventDefault();
281
+ event.stopPropagation();
282
+ if (event.type === 'click') {
283
+ const { ctrlKey, metaKey } = event as MouseEvent;
284
+ this.send({ t: 'pick', nodeId: nodeIdOf(event.target), part: partOf(event.target), ctrl: ctrlKey || metaKey });
285
+ }
286
+ };
287
+
288
+ private readonly onPointerMove = (event: Event): void => {
289
+ if (!this.armed) return;
290
+ const { clientX, clientY } = event as PointerEvent;
291
+ this.pointer = { x: clientX, y: clientY };
292
+ // the position goes out once a frame, however many moves arrive in it
293
+ this.pointerFrame ??= requestAnimationFrame(() => {
294
+ this.pointerFrame = null;
295
+ if (this.pointer) this.send({ t: 'pointer', ...this.pointer, scroll: { x: window.scrollX, y: window.scrollY } });
296
+ });
297
+ const nodeId = nodeIdOf(event.target);
298
+ const part = partOf(event.target);
299
+ const { ctrlKey, metaKey } = event as PointerEvent;
300
+ const ctrl = ctrlKey || metaKey;
301
+ // the modifier changing is worth a report too — the first move after Ctrl goes down re-sends
302
+ // the same node with the new colouring (a STATIONARY press is the editor realm's to notice:
303
+ // its key listeners cover that, since suppressed mousedowns keep focus on the editor side)
304
+ if (nodeId === this.hovered && part === this.hoveredPart && ctrl === this.hoveredCtrl) return;
305
+ this.hovered = nodeId;
306
+ this.hoveredPart = part;
307
+ this.hoveredCtrl = ctrl;
308
+ this.send({ t: 'hover', nodeId, part, ctrl });
309
+ };
310
+
311
+ private readonly onPointerLeave = (): void => {
312
+ if (this.hovered === null) return;
313
+ this.hovered = null;
314
+ this.hoveredPart = null;
315
+ this.hoveredCtrl = false;
316
+ this.send({ t: 'hover', nodeId: null });
317
+ };
318
+
319
+ /**
320
+ * The page scrolled. Nothing about the layout changed — every block moved by exactly the same
321
+ * amount — so the editor is told the new POSITION and slides its overlay by the difference. A
322
+ * full re-measurement here is what made the outlines trail the page by a visible step.
323
+ *
324
+ * An inner scroller is a different matter: it moves its own contents and nothing else, so that
325
+ * one is measured as usual. And once the page settles, one honest measurement follows anyway —
326
+ * anything fixed or sticky did NOT move with the rest.
327
+ */
328
+ private readonly onScroll = (event: Event): void => {
329
+ const target = event.target;
330
+ const isPage = target === document || target === document.scrollingElement || target === document.documentElement;
331
+ if (!isPage) {
332
+ this.schedule();
333
+ return;
334
+ }
335
+ this.send({ t: 'scroll', x: window.scrollX, y: window.scrollY });
336
+ if (this.settleTimer !== null) clearTimeout(this.settleTimer);
337
+ this.settleTimer = setTimeout(this.schedule, SCROLL_SETTLE_MS);
338
+ };
339
+
340
+ /** one measurement per frame however many signals arrive */
341
+ private readonly schedule = (): void => {
342
+ if (this.frame !== null) return;
343
+ this.frame = requestAnimationFrame(() => {
344
+ this.frame = null;
345
+ this.measure();
346
+ });
347
+ };
348
+
349
+ private measure(): void {
350
+ // A measurement taken while the frame has no viewport is not a measurement: every rect comes
351
+ // back zero, blocks vanish from the report and the editor's outlines go with them. That is
352
+ // what a hidden or mid-relayout iframe looks like — the editor re-asks once it is on screen.
353
+ if (!window.innerWidth || !window.innerHeight) return;
354
+ const boxes: INodeBox[] = [];
355
+ // a prefab's blocks report ONE box — their union — under the prefab node's id
356
+ const unions = new Map<string, INodeBox>();
357
+ // every prefab whose blocks stand in the DOM — a box or not — is one the realm can re-expand
358
+ const units = new Set<string>();
359
+ // …and each of those blocks on its own, for the editor to frame one picked to be seen
360
+ const parts: INodeBox[] = [];
361
+ for (const element of document.querySelectorAll(NODE_SELECTOR)) {
362
+ const shownOwner = unitOwnerOf(element.getAttribute(NODE_ID_ATTRIBUTE) ?? '');
363
+ if (shownOwner !== null) units.add(shownOwner);
364
+ const box = boxOf(element, this.range);
365
+ if (!box) continue;
366
+ const owner = unitOwnerOf(box.id);
367
+ if (owner === null) {
368
+ boxes.push(box);
369
+ continue;
370
+ }
371
+ if (!isOutletReference(element.className)) parts.push(box);
372
+ const known = unions.get(owner);
373
+ if (!known) {
374
+ unions.set(owner, { ...box, id: owner, reference: 'prefab' });
375
+ } else {
376
+ const right = Math.max(known.x + known.width, box.x + box.width);
377
+ const bottom = Math.max(known.y + known.height, box.y + box.height);
378
+ const x = Math.min(known.x, box.x);
379
+ const y = Math.min(known.y, box.y);
380
+ unions.set(owner, { ...known, x, y, width: right - x, height: bottom - y, depth: Math.min(known.depth, box.depth) });
381
+ }
382
+ }
383
+ boxes.push(...unions.values());
384
+ this.send({
385
+ t: 'geometry',
386
+ boxes,
387
+ units: [...units],
388
+ parts,
389
+ slots: this.measureSlots(),
390
+ viewport: { width: window.innerWidth, height: window.innerHeight },
391
+ scroll: { x: window.scrollX, y: window.scrollY },
392
+ });
393
+ }
394
+
395
+ /**
396
+ * The insertion points, from the slots registered RIGHT NOW. A declaration whose marker is not in
397
+ * the document is dropped: it belongs to a render that has already been replaced, and an
398
+ * insertion point nobody can see is worse than none.
399
+ */
400
+ private measureSlots(): ISlotBox[] {
401
+ const markers = new Map<string, Element>();
402
+ for (const element of document.querySelectorAll(`[${SLOT_ATTRIBUTE}]`)) {
403
+ markers.set(element.getAttribute(SLOT_ATTRIBUTE)!, element);
404
+ }
405
+ const slots: ISlotBox[] = [];
406
+ const containers = new Set<Element>();
407
+ for (const raw of previewSlots.list()) {
408
+ const marker = markers.get(slotKey(raw.ownerId, raw.slot));
409
+ if (!marker) continue;
410
+ // inside an expanded layout only the OUTLET is a place for the page's blocks — and that
411
+ // place belongs to the layout node (its children are what the outlet shows)
412
+ const owner = unitOwnerOf(raw.ownerId);
413
+ let declaration = raw;
414
+ if (owner !== null) {
415
+ const host = marker.closest(NODE_SELECTOR);
416
+ if (!host || !isOutletReference(host.className)) continue;
417
+ declaration = { ...raw, ownerId: owner };
418
+ }
419
+ if (marker.parentElement) containers.add(marker.parentElement);
420
+ const point = marker.getBoundingClientRect();
421
+ // the marker is sizeless — for an empty slot the area worth highlighting is what encloses it
422
+ const parent = marker.parentElement?.getBoundingClientRect();
423
+ slots.push({
424
+ ...declaration,
425
+ x: point.x,
426
+ y: point.y,
427
+ area: parent ? { x: parent.x, y: parent.y, width: parent.width, height: parent.height } : null,
428
+ });
429
+ }
430
+ this.watchSizes(containers);
431
+ return slots;
432
+ }
433
+
434
+ /**
435
+ * Keep an eye on the containers a slot lives in. A container grows the moment a block lands in
436
+ * it, and that growth is often pure layout — no mutation to notice, no scroll, nothing the other
437
+ * signals would catch. Only NEW elements are observed: re-observing on every measurement would
438
+ * make the observer fire on its own output, forever.
439
+ */
440
+ private watchSizes(containers: Set<Element>): void {
441
+ if (!this.resizeObserver) return;
442
+ for (const element of containers) {
443
+ if (this.watched.has(element)) continue;
444
+ this.watched.add(element);
445
+ this.resizeObserver.observe(element);
446
+ }
447
+ for (const element of this.watched) {
448
+ if (containers.has(element)) continue; // gone from the page, or its slot no longer rendered
449
+ this.resizeObserver.unobserve(element);
450
+ this.watched.delete(element);
451
+ }
452
+ }
453
+
454
+ private send(event: TPreviewEvent): void {
455
+ this.port?.postMessage(event);
456
+ }
457
+ }
@@ -0,0 +1,137 @@
1
+ // The editor's agent inside the preview realm, as a MODULE: the render runtime attaches this node
2
+ // under the page root when the canvas asks for an editable preview (see the agent itself in
3
+ // previewAgent.ts). It renders nothing — everything visible is drawn by the editor's overlay, in
4
+ // the editor's own document.
5
+ //
6
+ // A component facet, not a page-level script, on purpose: the agent then arrives, loads and dies
7
+ // through the very protocol it serves — one code path for every module of a page.
8
+ import { defineComponentSide, markInternalTile } from '@mosetta/react';
9
+ import { useComponent, usePreviewType, PALETTE_PREVIEW, useSettings, parseReference } from '@mosetta/react';
10
+ import { useDi } from '@mosetta/react';
11
+ import { PAGE_MANAGER_TOKEN, REF_VIEWS_TOKEN } from '@mosetta/runtime/client';
12
+ import { useEffect, useRef } from 'react';
13
+ import { PreviewAgent, withAgentNode } from './previewAgent';
14
+ import { withLanding } from './landing';
15
+ import { withStandIns } from './standIn';
16
+ import { previewBridgeIdOf, STAND_IN_ATTRIBUTE } from './previewBridgeCommon';
17
+ import type { ILandingPlace } from './previewBridgeCommon';
18
+
19
+ /**
20
+ * What a SERVICE module shows as its preview: not its representation (it has none), but what it
21
+ * is for — the rule for every distribution component without a face.
22
+ */
23
+ function BridgeAbout() {
24
+ return (
25
+ <div style={{ padding: '4px 2px', maxWidth: '260px', color: '#71717a', fontSize: '13px', lineHeight: 1.5 }}>
26
+ Service module: the editor’s agent inside the live page preview. It connects the page realm
27
+ to the workbench overlay — geometry, selection, live tree updates — and draws nothing itself.
28
+ </div>
29
+ );
30
+ }
31
+
32
+ function BridgeLive({ disconnected }: { disconnected: readonly string[] }) {
33
+ // the page MANAGER is how a new tree reaches a mounted application: the editor sends the tree
34
+ // it holds, this puts it where the root component reads from, and react does the rest
35
+ const pageManager = useDi(PAGE_MANAGER_TOKEN);
36
+ // the editor's views of reference providers land in the store every application is born with
37
+ const views = useDi(REF_VIEWS_TOKEN);
38
+ // this module's own full name — the node to carry over every incoming tree
39
+ const self = previewBridgeIdOf(useComponent().reference);
40
+ // where a block is about to go: every tree that arrives meanwhile gets the landing space too
41
+ const landing = useRef<ILandingPlace | null>(null);
42
+
43
+ useEffect(() => {
44
+ const agent = new PreviewAgent({
45
+ // the editor's tree names a disconnected distribution's blocks as they are: here they stand
46
+ // in, as in the server's first render (standIn.ts)
47
+ applyPage: (page) => pageManager.setCurrentPage(
48
+ withLanding(withStandIns(withAgentNode(pageManager.getCurrentPage(), page, self), disconnected, self), landing.current, self),
49
+ ),
50
+ applyLanding: (at) => {
51
+ landing.current = at;
52
+ pageManager.setCurrentPage(withLanding(pageManager.getCurrentPage(), at, self));
53
+ },
54
+ applyViews: (next) => views.set(next),
55
+ });
56
+ agent.start();
57
+ return () => agent.stop();
58
+ }, [pageManager, views, self, disconnected]);
59
+ return null;
60
+ }
61
+
62
+ /**
63
+ * A STAND-IN for a block whose distribution the project disconnected (standIn.ts): the site shows
64
+ * nothing there, the editor shows this — the block's name and why it is not on the site. Which
65
+ * distribution it was, by its title, the editor's settings say (the realm knows no titles).
66
+ */
67
+ function StandIn({ reference }: { reference: string }) {
68
+ let name = reference;
69
+ try {
70
+ ({ name } = parseReference(reference));
71
+ } catch {
72
+ // a reference that is no module's (a prefab's): shown as it is
73
+ }
74
+ return (
75
+ <div {...{ [STAND_IN_ATTRIBUTE]: reference }}
76
+ style={{
77
+ display: 'flex',
78
+ flexDirection: 'column',
79
+ gap: '4px',
80
+ boxSizing: 'border-box',
81
+ minHeight: '64px',
82
+ padding: '12px 16px',
83
+ border: '1px dashed #a1a1aa',
84
+ borderRadius: '6px',
85
+ background: 'repeating-linear-gradient(135deg, #fafafa 0 8px, #f4f4f5 8px 16px)',
86
+ color: '#52525b',
87
+ font: '13px/1.45 ui-sans-serif, system-ui, sans-serif',
88
+ justifyContent: 'center',
89
+ }}>
90
+ <strong style={{ color: '#27272a', fontWeight: 600 }}>{name}</strong>
91
+ <span>Its tile palette is disconnected from this project — the site does not show this block.</span>
92
+ </div>
93
+ );
94
+ }
95
+
96
+ /**
97
+ * The LANDING SPACE: a little emptiness where a block is about to go, so the editor can frame the
98
+ * very spot (the overlay draws on it, this draws nothing). It does not know the layout around it,
99
+ * and does not need to: a block in flow takes the whole width, a flex row gives it its least width
100
+ * and stretches it to the row's height, a column or a grid stretches it across.
101
+ */
102
+ function LandingSpace() {
103
+ return (
104
+ <div data-mosetta-landing
105
+ aria-hidden="true"
106
+ style={{
107
+ display: 'block',
108
+ boxSizing: 'border-box',
109
+ minWidth: '96px',
110
+ minHeight: '48px',
111
+ flex: '0 0 auto',
112
+ alignSelf: 'stretch',
113
+ justifySelf: 'stretch',
114
+ pointerEvents: 'none',
115
+ }} />
116
+ );
117
+ }
118
+
119
+ const NONE: readonly string[] = [];
120
+
121
+ // By the preview's TYPE, never the flag: a FULL-APP preview (the canvas realm) is exactly where
122
+ // this module WORKS — branching on useIsPreviewMode() would turn the agent into a description
123
+ // card at its own workplace. Only a palette specimen means there is no realm to serve.
124
+ function BridgeComponent() {
125
+ const { landing, standIn, disconnected } = useSettings<{ landing?: boolean; standIn?: string; disconnected?: readonly string[] }>();
126
+ const type = usePreviewType();
127
+ if (landing) return <LandingSpace />;
128
+ if (standIn) return <StandIn reference={standIn} />;
129
+ return type === PALETTE_PREVIEW ? <BridgeAbout /> : <BridgeLive disconnected={disconnected ?? NONE} />;
130
+ }
131
+
132
+ export default markInternalTile(defineComponentSide({
133
+ Component: BridgeComponent,
134
+ defaultSettings() {
135
+ return {};
136
+ },
137
+ }));
@@ -0,0 +1,10 @@
1
+ import { defineEditorSide } from '@mosetta/react';
2
+
3
+ export default defineEditorSide({
4
+ // a service module previews as a card that says what it is FOR (the component branches on the
5
+ // host's preview contract) — its working self renders nothing at all
6
+ preview: 'self',
7
+ Component() {
8
+ return <div data-no-settings>This block has no settings.</div>;
9
+ },
10
+ });