@mosetta/editor 0.0.0-stage → 0.0.29

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 +190 -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,2038 @@
1
+ // Smart widget: the PREVIEW — an IFRAME onto the page's real path + ?previewToken, and the
2
+ // SELECTION OVERLAY drawn on top of it.
3
+ // Full realm isolation by design (the user's constructor experience: shared modules + shared
4
+ // memory inevitably collide): the iframe runs the REAL bootstrap with its own System/react/DI/
5
+ // caches, and the same URL is shareable with other observers.
6
+ //
7
+ // The edited tree reaches it LIVE, over the same channel: the preview's page manager is handed the
8
+ // new tree and the application re-renders around it. The frame is rebuilt only when a block needs
9
+ // a module the running realm never loaded — everything else, including every keystroke in the
10
+ // settings panel, arrives without a reload.
11
+ //
12
+ // The overlay lives HERE, in the editor's own document — never inside the previewed application,
13
+ // whose UI and stacking context it would otherwise have to fight. It is a pointer-transparent
14
+ // layer: hovering and clicking happen in the iframe, where the agent hit-tests the real DOM and
15
+ // reports back over a MessageChannel. Boxes arrive in the iframe's viewport coordinates, which is
16
+ // exactly this layer's coordinate space.
17
+ import { defineComponentSide, markInternalTile } from '@mosetta/react';
18
+ import { useComponent, useIsPreviewMode } from '@mosetta/react';
19
+ import { RefObject, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
20
+ import type { ReactNode } from 'react';
21
+ import { DEFAULT_SLOT, editingSessionHubOf, findNodeDoc, IInsertionPoint, IBlueprintNodeDoc, IPageDoc, unitOf } from '@mosetta/content';
22
+ import type { ContentKind } from '@mosetta/content';
23
+ import { useServerAction } from '@mosetta/react/server-action';
24
+ import { EServerActionStart } from '@mosetta/server-action/client';
25
+ import { createPageDraftToken, listPagesToken, publishPageToken } from '../content/contentCommon';
26
+ import { TILES_HOLDER_TOKEN } from '@mosetta/runtime/client';
27
+ import { PublishModal } from '../content/publishModal';
28
+ import { UpdatesModal, upgradeKey } from '../content/updatesModal';
29
+ import { upgradeChanges, upgradeGroups, useUpgrades, withLatestTools } from '../content/blockUpgrades';
30
+ import '../content/workbenchScale.css';
31
+ import './canvas.css';
32
+ import { useCanvasEditingSession } from '../content/useEditingSession';
33
+ import { useCtrlHeld } from '../content/useCtrlHeld';
34
+ import { parseBlocks, serializeBlocks } from '../content/editingClipboard';
35
+ import { blocksLabel, noticesOf } from '../content/editingNotices';
36
+ import { ensureEditorViews, ensureSettingsDraft } from '../content/settingsDraft';
37
+ import { useFeedProjectConfig } from '../content/projectConfig';
38
+ import { ChromeCard, chromeFaceOf, PageSettingsIcon, useChromeNodes } from './providerChrome';
39
+ import type { IChromeNode } from './chromeNodes';
40
+ import { SettingsPopover } from './settingsPopover';
41
+ import { EDITOR_TOOLS_TOKEN, toolLabelOf } from './canvasTools';
42
+ import { ToolPanel } from './toolPanel';
43
+ import type { IPopoverContent } from './settingsPopover';
44
+ import { InsertPopover } from './insertPopover';
45
+ import { TooltipHost } from '../content/tooltips';
46
+ import { placePopover, seamOf } from './popoverPlacement';
47
+ import { outOfSight, scrollWatch } from './scrollWatch';
48
+ import { axisAmong, TAG_HEIGHT, type TAxis } from './ghosts';
49
+ import { readPopoverSize, writePopoverSize, type IPopoverSize } from './popoverSize';
50
+ import { ensureSettingsPanels } from '../content/nodeSettings';
51
+ import { ensureRefsEditing } from '../content/refsEditing';
52
+ import { watchArrivals } from '../content/autoBind';
53
+ import { useDi, useDiOptional } from '@mosetta/react';
54
+ import { DI_CONTAINER_TOKEN } from '@mosetta/di';
55
+ import { DOCK_TAB_TOKEN } from '@mosetta/ui';
56
+ import { WORKSPACE_TOKEN } from '../workbench/workspace';
57
+ import { useFeedPrefabTrees, useProjectPrefabs } from '../content/projectPrefabs';
58
+ import { isPartId, prefabPartOf } from '../content/prefabParts';
59
+ import {
60
+ INodeBox,
61
+ isPreviewHello,
62
+ ISlotBox,
63
+ LANDING_NODE_ID,
64
+ PREVIEW_PORT,
65
+ PREVIEW_PROTOCOL_VERSION,
66
+ previewBridgeIdOf,
67
+ TPreviewCommand,
68
+ TPreviewEvent,
69
+ } from '../previewBridge/previewBridgeCommon';
70
+
71
+ const shortName = (reference: string) => /_n_(.+?)_v_/.exec(reference)?.[1] ?? reference;
72
+
73
+ const noSubscription = (): (() => void) => () => undefined;
74
+ const noState = (): null => null;
75
+
76
+ /** how a place is named on screen — the block that opened it and the slot, default one included */
77
+ const slotLabel = (owner: string, slot: string) => `${owner}#${slot}`;
78
+
79
+ /** the window's localStorage, or null where reading it throws (blocked site data, sandboxing) */
80
+ function localStorageOrNull(): Storage | null {
81
+ try {
82
+ return typeof localStorage === 'undefined' ? null : localStorage;
83
+ } catch {
84
+ return null;
85
+ }
86
+ }
87
+
88
+ /** every module a tree names — the set a realm has to hold already to be able to render it */
89
+ function referencesOf(node: IBlueprintNodeDoc | IPageDoc | null): string[] {
90
+ if (!node) return [];
91
+ return (node.children ?? []).reduce<string[]>(
92
+ (all, child) => [...all, ...referencesOf(child)],
93
+ [node.reference],
94
+ );
95
+ }
96
+
97
+ /** the ids of the PREFAB nodes a tree places — the ones a live update can only re-expand from what is shown */
98
+ function unitNodeIds(node: IBlueprintNodeDoc | IPageDoc): string[] {
99
+ return (node.children ?? []).reduce<string[]>(
100
+ (all, child) => [...all, ...(unitOf(child.reference) ? [child.id] : []), ...unitNodeIds(child)],
101
+ [],
102
+ );
103
+ }
104
+
105
+ /** the node holding `id`, or null for the root (and for an id the document does not know) */
106
+ function parentOfNode(root: IPageDoc | IBlueprintNodeDoc, id: string): IBlueprintNodeDoc | null {
107
+ for (const child of root.children ?? []) {
108
+ if (child.id === id) return root;
109
+ const deeper = parentOfNode(child, id);
110
+ if (deeper) return deeper;
111
+ }
112
+ return null;
113
+ }
114
+
115
+ // The dock's "bring this panel forward" event. Spelled out rather than imported: the dock is a
116
+ // block of another system (@mosetta/ui-kit-react) and the editor does not depend on it — the two
117
+ // meet at a window event, which is the whole point of the dock exposing one.
118
+
119
+ /** how often a panel being dragged is worth a fresh measurement of the page inside */
120
+ const RESIZE_MEASURE_MS = 120;
121
+
122
+ interface ICanvasSettings {
123
+ /**
124
+ * IGNORED since 2026-09-26: a "+" opens the canvas's own insert popover instead of bringing a
125
+ * palette panel forward. Kept in the type — pages composed before carry the value.
126
+ */
127
+ readonly paletteSlot?: string;
128
+ }
129
+
130
+ interface IPreviewLink {
131
+ /**
132
+ * The page is being scrolled: from its first move until a fresh measurement arrives (the agent
133
+ * measures once the page settles). Meanwhile what sits OUTSIDE the sliding layer — the settings
134
+ * card — stands where the page no longer is.
135
+ */
136
+ readonly scrolling: boolean;
137
+ readonly boxes: readonly INodeBox[];
138
+ readonly slots: readonly ISlotBox[];
139
+ /** the prefab nodes the realm shows expanded — null while no agent has said */
140
+ readonly units: ReadonlySet<string> | null;
141
+ /** the previewed page's own viewport — what "the right side of the page" means */
142
+ readonly viewport: { readonly width: number; readonly height: number } | null;
143
+ readonly hoveredId: string | null;
144
+ /** …and the prefab's own block under the pointer, when it is over one (prefabParts.ts) */
145
+ readonly hoveredPart: string | null;
146
+ /** the PREFABS' own blocks, each on its own — `boxes` has each prefab as one box */
147
+ readonly parts: readonly INodeBox[];
148
+ /** the Ctrl/Cmd state the hover was REPORTED with — the preview realm's side of the modifier */
149
+ readonly hoveredCtrl: boolean;
150
+ readonly connected: boolean;
151
+ readonly command: (command: TPreviewCommand) => void;
152
+ /** the frame itself — its size changes are the editor's own business, see useRemeasureOnResize */
153
+ readonly stageRef: RefObject<HTMLDivElement | null>;
154
+ /** the sliding layer INSIDE the clipping surface; scrolling moves it straight in the DOM */
155
+ readonly overlayRef: RefObject<HTMLDivElement | null>;
156
+ /** the frame itself — read every animation frame to follow its scroll without a message */
157
+ readonly frameRef: RefObject<HTMLIFrameElement | null>;
158
+ }
159
+
160
+ /**
161
+ * Geometry is only true while the frame is on screen at its final size. Panels get closed, resized
162
+ * and re-laid out around it, and none of that reaches the agent inside — so the editor watches its
163
+ * own stage and asks for a fresh measurement whenever it changes.
164
+ */
165
+ function useRemeasureOnResize(
166
+ stage: RefObject<HTMLDivElement | null>,
167
+ connected: boolean,
168
+ command: (command: TPreviewCommand) => void,
169
+ ): void {
170
+ useEffect(() => {
171
+ const element = stage.current;
172
+ if (!element || !connected || typeof ResizeObserver === 'undefined') return undefined;
173
+ // Dragging a panel edge fires this every frame, and every one of them costs the preview a full
174
+ // measurement of the page — while the browser is re-laying that page out anyway. So: one
175
+ // measurement at the start of a drag, one after it stops, nothing in between. The outlines are
176
+ // briefly out of date mid-drag, which is the moment nobody is aiming at them.
177
+ let trailing: ReturnType<typeof setTimeout> | null = null;
178
+ let last = 0;
179
+ const observer = new ResizeObserver(() => {
180
+ const now = performance.now();
181
+ if (now - last > RESIZE_MEASURE_MS) {
182
+ last = now;
183
+ command({ t: 'measure' });
184
+ }
185
+ if (trailing !== null) clearTimeout(trailing);
186
+ trailing = setTimeout(() => {
187
+ last = performance.now();
188
+ command({ t: 'measure' });
189
+ }, RESIZE_MEASURE_MS);
190
+ });
191
+ observer.observe(element);
192
+ return () => {
193
+ if (trailing !== null) clearTimeout(trailing);
194
+ observer.disconnect();
195
+ };
196
+ }, [stage, connected, command]);
197
+ }
198
+
199
+ /** no measurement after a scroll (a cross-origin preview, a stalled agent): the scroll ends after this lull */
200
+ const SCROLL_LULL_MS = 600;
201
+
202
+ /** a slot with room takes another block; a full one offers nothing */
203
+ const hasRoom = (slot: ISlotBox): boolean => slot.max === 'many' || slot.filled < slot.max;
204
+
205
+ /**
206
+ * The editor's end of the preview conversation: waits for the agent's greeting, hands it one end
207
+ * of a fresh MessageChannel, and keeps the geometry it reports. Re-runs whenever the iframe is
208
+ * replaced (a new src/key means a new realm and a new agent).
209
+ */
210
+ function usePreviewLink(
211
+ frameKey: string,
212
+ onPick: (nodeId: string | null, ctrl: boolean, part: string | null) => void,
213
+ onHover: (nodeId: string | null, part: string | null) => void,
214
+ onPointer: (x: number, y: number) => void,
215
+ ): IPreviewLink {
216
+ const [boxes, setBoxes] = useState<readonly INodeBox[]>([]);
217
+ const [parts, setParts] = useState<readonly INodeBox[]>([]);
218
+ const [slots, setSlots] = useState<readonly ISlotBox[]>([]);
219
+ // the prefab nodes the realm shows expanded; null until an agent says (or from one that cannot)
220
+ const [units, setUnits] = useState<ReadonlySet<string> | null>(null);
221
+ const [viewport, setViewport] = useState<{ width: number; height: number } | null>(null);
222
+ const [hovered, setHovered] = useState<{ id: string | null; part: string | null; ctrl: boolean }>({ id: null, part: null, ctrl: false });
223
+ const [connected, setConnected] = useState(false);
224
+ const [scrolling, setScrolling] = useState(false);
225
+ const portRef = useRef<MessagePort | null>(null);
226
+ const stageRef = useRef<HTMLDivElement | null>(null);
227
+ const frameRef = useRef<HTMLIFrameElement | null>(null);
228
+ const overlayRef = useRef<HTMLDivElement | null>(null);
229
+ // where the page stood when the boxes were measured — every scroll is read against it
230
+ const scrollBase = useRef({ x: 0, y: 0 });
231
+ // the pick handler changes with the session; the port must not be reopened for that
232
+ const pickRef = useRef(onPick);
233
+ pickRef.current = onPick;
234
+ const hoverRef = useRef(onHover);
235
+ hoverRef.current = onHover;
236
+ const pointerRef = useRef(onPointer);
237
+ pointerRef.current = onPointer;
238
+
239
+ useEffect(() => {
240
+ setBoxes([]);
241
+ setParts([]);
242
+ setSlots([]);
243
+ setViewport(null);
244
+ setHovered({ id: null, part: null, ctrl: false });
245
+ setConnected(false);
246
+ setScrolling(false);
247
+
248
+ // the page moved: scrolling until the measurement that follows it (scrollWatch.ts)
249
+ const scroll = scrollWatch(setScrolling, SCROLL_LULL_MS);
250
+
251
+ let slid = { x: 0, y: 0 };
252
+ const slide = (x: number, y: number): boolean => {
253
+ const layer = overlayRef.current;
254
+ if (!layer || (slid.x === x && slid.y === y)) return false; // nothing moved: no style to invalidate
255
+ slid = { x, y };
256
+ const { x: baseX, y: baseY } = scrollBase.current;
257
+ // written straight to the node: a react state update per scroll event would re-render every
258
+ // outline and every control, and that is precisely the cost that shows up as lag
259
+ layer.style.transform = x === baseX && y === baseY
260
+ ? ''
261
+ : `translate(${baseX - x}px, ${baseY - y}px)`;
262
+ return true;
263
+ };
264
+
265
+ /**
266
+ * The outlines have to move with the page, and a message cannot do that in time: the frame
267
+ * scrolls, its scroll event fires a frame later, the message arrives a task after that, and by
268
+ * then the page has moved again — the visible trailing.
269
+ *
270
+ * A SAME-ORIGIN frame can simply be read: once per animation frame, take its scroll position
271
+ * and slide the layer. Nothing is asked of the preview and nothing crosses the channel, so the
272
+ * overlay lands in the same frame the browser paints the scroll in. A cross-origin preview —
273
+ * a tenant's own subdomain, one day — cannot be read, and falls back to the messages below.
274
+ */
275
+ // window.requestAnimationFrame, not the bare global: this module reads and drives ANOTHER
276
+ // realm's frame, so it takes its timing from the window it was given (same lesson as building
277
+ // an event with the target's constructor)
278
+ let frameLoop = 0;
279
+ const follow = (): void => {
280
+ frameLoop = window.requestAnimationFrame(follow);
281
+ const view = frameRef.current?.contentWindow;
282
+ if (!view) return;
283
+ try {
284
+ if (slide(view.scrollX, view.scrollY)) scroll.moved();
285
+ } catch {
286
+ window.cancelAnimationFrame(frameLoop); // cross-origin: the messages take over
287
+ frameLoop = 0;
288
+ }
289
+ };
290
+ frameLoop = window.requestAnimationFrame(follow);
291
+ const onEvent = (event: TPreviewEvent): void => {
292
+ if (event.t === 'geometry') {
293
+ scrollBase.current = event.scroll;
294
+ slid = { x: NaN, y: NaN }; // the baseline moved — whatever is on the layer is stale
295
+ slide(event.scroll.x, event.scroll.y);
296
+ setBoxes(event.boxes);
297
+ setParts(event.parts ?? []);
298
+ setSlots(event.slots);
299
+ if (event.units) setUnits(new Set(event.units));
300
+ setViewport(event.viewport);
301
+ scroll.settled();
302
+ } else if (event.t === 'scroll') {
303
+ if (slide(event.x, event.y)) scroll.moved();
304
+ } else if (event.t === 'hover') {
305
+ setHovered({ id: event.nodeId, part: event.part ?? null, ctrl: event.ctrl ?? false });
306
+ hoverRef.current(event.nodeId, event.part ?? null);
307
+ } else if (event.t === 'pointer') {
308
+ // read against the measurement's own baseline — the space every box on the layer is in
309
+ pointerRef.current(event.x + event.scroll.x - scrollBase.current.x, event.y + event.scroll.y - scrollBase.current.y);
310
+ } else if (event.t === 'pick') pickRef.current(event.nodeId, event.ctrl ?? false, event.part ?? null);
311
+ };
312
+ const onGreeting = (event: MessageEvent): void => {
313
+ if (!isPreviewHello(event.data) || !event.source) return;
314
+ // ONLY a greeting from THIS canvas's own frame: hellos arrive on the shared window, and
315
+ // with several canvases mounted an unguarded handler would close its own channel and adopt
316
+ // a SIBLING's agent — one dead overlay, one drawing a stranger's geometry
317
+ if (event.source !== frameRef.current?.contentWindow) return;
318
+ // A greeting always means a FRESH agent, so it always gets a fresh channel. The iframe can
319
+ // reload without its src changing — moving it in the DOM is enough, which is exactly what a
320
+ // dock re-layout does — and holding on to the previous port would leave the editor talking
321
+ // to a realm that no longer exists.
322
+ portRef.current?.close();
323
+ const channel = new MessageChannel();
324
+ channel.port1.onmessage = (message: MessageEvent) => onEvent(message.data as TPreviewEvent);
325
+ channel.port1.start();
326
+ portRef.current = channel.port1;
327
+ // the greeting's own origin is the honest target; a sandboxed realm reports "null", which
328
+ // is not a usable targetOrigin — those get the wildcard, and the port keeps the
329
+ // conversation private either way
330
+ const targetOrigin = event.origin && event.origin !== 'null' ? event.origin : '*';
331
+ (event.source).postMessage(
332
+ { mosetta: PREVIEW_PORT, protocol: PREVIEW_PROTOCOL_VERSION },
333
+ { targetOrigin, transfer: [channel.port2] },
334
+ );
335
+ setConnected(true);
336
+ };
337
+
338
+ window.addEventListener('message', onGreeting);
339
+ return () => {
340
+ window.removeEventListener('message', onGreeting);
341
+ if (frameLoop) window.cancelAnimationFrame(frameLoop);
342
+ scroll.dispose();
343
+ portRef.current?.close();
344
+ portRef.current = null;
345
+ };
346
+ }, [frameKey]);
347
+
348
+ const command = useCallback((message: TPreviewCommand) => portRef.current?.postMessage(message), []);
349
+ useRemeasureOnResize(stageRef, connected, command);
350
+ return {
351
+ scrolling,
352
+ boxes,
353
+ units,
354
+ slots,
355
+ viewport,
356
+ hoveredId: hovered.id,
357
+ hoveredPart: hovered.part,
358
+ parts,
359
+ hoveredCtrl: hovered.ctrl,
360
+ connected,
361
+ command,
362
+ stageRef,
363
+ overlayRef,
364
+ frameRef,
365
+ };
366
+ }
367
+
368
+ /** the least room between the name over a frame's left end and the chip over its right end */
369
+ const NAME_TAG_GAP = 8;
370
+
371
+ // The INSPECTOR's vocabulary (docs/code-conventions: structural naming): every rect it draws is
372
+ // an "inspector box" — select (blue), pick (yellow — EVERY picked element wears it, the branch
373
+ // root thick and the covered ones thin), and the hover twins. The green "child box" is not here:
374
+ // that name belongs to the container frame an insert lights up (TargetFrame below).
375
+ function Outline({ box, kind, label, size, onSettings, settingsOpen, settingsLoading, onRemove, within, ghost }: {
376
+ box: INodeBox;
377
+ kind: 'hover' | 'select' | 'hover-pick' | 'pick' | 'pick-covered';
378
+ /** the block's name — dropped on a block too short to carry it without covering itself */
379
+ label?: string;
380
+ size?: boolean;
381
+ /** the block's settings, folded behind a button until asked for */
382
+ onSettings?: () => void;
383
+ settingsOpen?: boolean;
384
+ /** asked for and still loading: the button turns while the card waits unseen */
385
+ settingsLoading?: boolean;
386
+ /** given for a block that may leave the page — the root may not, and gets no button */
387
+ onRemove?: () => void;
388
+ /** the page's viewport: a block wider than it keeps its tags INSIDE, where they can be read */
389
+ within?: { readonly width: number };
390
+ /**
391
+ * The place next to the block, GLUED to its frame as if cut from one sheet with it: on top it is
392
+ * a segment of the corner strip, beside the size tag; below, a tab hanging from the bottom edge
393
+ * where the strip stands above; left and right, tabs on those edges.
394
+ */
395
+ ghost?: { readonly place: 'top' | 'bottom' | 'left' | 'right'; readonly node: ReactNode };
396
+ }) {
397
+ // a block that runs past the page's edges (a full-bleed band, an overflowing root) would carry
398
+ // its corner tags out of sight with it — they stay at the visible edge instead
399
+ const pastRight = within ? Math.max(0, box.x + box.width - within.width) : 0;
400
+ const pastLeft = Math.max(0, -box.x);
401
+ // …and the corner chip of a block NARROWER than the chip, near the page's left edge, would hang
402
+ // off that edge: it is measured, and turns to start at the block's left instead
403
+ const cornerRef = useRef<HTMLDivElement>(null);
404
+ // the NAME is always shown: on its own over the frame's left end while the strip above has room
405
+ // for it beside the chip, otherwise as the chip's first segment (a narrow or short block alike) —
406
+ // measured: the chip without the name, and the name, whichever place it took
407
+ const nameRef = useRef<HTMLSpanElement>(null);
408
+ const chipNameRef = useRef<HTMLSpanElement>(null);
409
+ const [cornerWidth, setCornerWidth] = useState(0);
410
+ const [nameWidth, setNameWidth] = useState(0);
411
+ useLayoutEffect(() => {
412
+ const chipName = chipNameRef.current?.getBoundingClientRect().width ?? 0;
413
+ const width = (cornerRef.current?.getBoundingClientRect().width ?? 0) - chipName;
414
+ const name = chipName || (nameRef.current?.getBoundingClientRect().width ?? 0);
415
+ setCornerWidth((prev) => (Math.abs(prev - width) < 1 ? prev : width));
416
+ setNameWidth((prev) => (Math.abs(prev - name) < 1 ? prev : name));
417
+ });
418
+ const nameInChip = Boolean(label && size) && nameWidth > 0 && cornerWidth + nameWidth + NAME_TAG_GAP > box.width - pastLeft - pastRight;
419
+ const chipWidth = cornerWidth + (nameInChip ? nameWidth : 0);
420
+ const cornerFromLeft = chipWidth > box.width && box.x + box.width - pastRight - chipWidth < 0;
421
+ const cornerStyle = cornerFromLeft ? { left: `${pastLeft}px`, right: 'auto' } : pastRight ? { right: `${pastRight}px` } : undefined;
422
+ return (
423
+ <div className={`canvas-view__outline canvas-view__outline--${kind}`}
424
+ data-test-id={`inspector-${kind === 'pick-covered' ? 'pick' : kind}-box`}
425
+ data-outline={kind}
426
+ data-outline-node={box.id}
427
+ style={{ transform: `translate(${box.x}px, ${box.y}px)`, width: `${box.width}px`, height: `${box.height}px` }}>
428
+ {label && !nameInChip ? <span ref={nameRef}
429
+ className="canvas-view__tag"
430
+ data-test-id="inspector-name-tip"
431
+ style={pastLeft ? { left: `${pastLeft - 2}px` } : undefined}>{label}</span> : null}
432
+ {/* The other corner answers the other question a selection raises: how big is this thing —
433
+ and carries the one action that acts on the whole block. The two are one unbroken chip:
434
+ a red button floating on its own would read as part of the page under the overlay. */}
435
+ {size ? (
436
+ <div ref={cornerRef}
437
+ className="canvas-view__corner"
438
+ data-outline-corner={box.id}
439
+ style={cornerStyle}>
440
+ {nameInChip ? <span ref={chipNameRef}
441
+ className="canvas-view__tag canvas-view__tag--name"
442
+ data-test-id="inspector-name-tip">{label}</span> : null}
443
+ {ghost?.place === 'top' ? ghost.node : null}
444
+ {/* the settings wait behind this: open, they would cover the very page one is looking at
445
+ to decide where the next block goes. Left of the size — the delete button stays at
446
+ the far end, alone, where a click meant for the settings cannot land on it */}
447
+ {onSettings ? (
448
+ <button type="button"
449
+ className={`canvas-view__tag-action canvas-view__tag-action--settings${settingsOpen ? ' canvas-view__tag-action--on' : ''}${settingsLoading ? ' canvas-view__tag-action--loading' : ''}`}
450
+ data-test-id="inspector-settings-button"
451
+ data-action="node-settings"
452
+ data-loading={settingsLoading || undefined}
453
+ data-tooltip={settingsLoading ? 'Loading the block’s settings…' : settingsOpen ? 'Hide the block’s settings' : 'The block’s settings'}
454
+ aria-label={settingsOpen ? 'Hide the block’s settings' : 'The block’s settings'}
455
+ aria-pressed={settingsOpen ?? false}
456
+ onClick={onSettings}>
457
+ <svg viewBox="0 0 16 16"
458
+ aria-hidden="true">
459
+ <path d="M2.5 4.5h6M11.5 4.5h2M2.5 11.5h2M7.5 11.5h6"
460
+ fill="none"
461
+ stroke="currentColor"
462
+ strokeWidth="1.4"
463
+ strokeLinecap="round" />
464
+ <circle cx="10"
465
+ cy="4.5"
466
+ r="1.6"
467
+ fill="none"
468
+ stroke="currentColor"
469
+ strokeWidth="1.4" />
470
+ <circle cx="6"
471
+ cy="11.5"
472
+ r="1.6"
473
+ fill="none"
474
+ stroke="currentColor"
475
+ strokeWidth="1.4" />
476
+ </svg>
477
+ </button>
478
+ ) : null}
479
+ <span className="canvas-view__tag canvas-view__tag--size"
480
+ data-test-id="inspector-size-tip"
481
+ data-outline-size>{Math.round(box.width)}×{Math.round(box.height)}</span>
482
+ {onRemove ? (
483
+ <button type="button"
484
+ className="canvas-view__tag-action"
485
+ data-test-id="inspector-remove-button"
486
+ data-action="remove-node"
487
+ data-tooltip="Delete this block"
488
+ aria-label="Delete this block"
489
+ onClick={onRemove}>
490
+ <svg viewBox="0 0 16 16"
491
+ aria-hidden="true">
492
+ <path d="M3.4 4.6h9.2M6.4 4.6V3.2h3.2v1.4M4.9 4.6l.6 8.2h5l.6-8.2"
493
+ fill="none"
494
+ stroke="currentColor"
495
+ strokeWidth="1.4" />
496
+ </svg>
497
+ </button>
498
+ ) : null}
499
+ </div>
500
+ ) : null}
501
+ {ghost?.place === 'bottom' ? (
502
+ <div className="canvas-view__corner canvas-view__corner--bottom"
503
+ style={cornerStyle}>{ghost.node}</div>
504
+ ) : null}
505
+ {ghost?.place === 'left' || ghost?.place === 'right' ? (
506
+ <div className={`canvas-view__edge canvas-view__edge--${ghost.place}`}>{ghost.node}</div>
507
+ ) : null}
508
+ </div>
509
+ );
510
+ }
511
+
512
+ /** a container with no width or height encloses nothing worth outlining — the "+" speaks for itself */
513
+ const drawableArea = (slot: ISlotBox) => (slot.area && slot.area.width > 0 && slot.area.height > 0 ? slot.area : null);
514
+
515
+ /**
516
+ * A place of the selected block at rest: every slot it opens, outlined quietly. This is the map of
517
+ * where things can go. It disappears the moment one place is hovered or picked — two greens on
518
+ * screen, one still and one travelling, collided both with each other and with the live frame.
519
+ */
520
+ function SlotArea({ slot, area }: { slot: ISlotBox; area: NonNullable<ISlotBox['area']> }) {
521
+ return (
522
+ <div className="canvas-view__slot-area canvas-view__slot-area--resting"
523
+ data-slot-area={`${slot.ownerId}:${slot.slot}`}
524
+ style={{
525
+ transform: `translate(${area.x}px, ${area.y}px)`,
526
+ width: `${area.width}px`,
527
+ height: `${area.height}px`,
528
+ }} />
529
+ );
530
+ }
531
+
532
+ interface IRect {
533
+ readonly x: number;
534
+ readonly y: number;
535
+ readonly width: number;
536
+ readonly height: number;
537
+ }
538
+
539
+ /**
540
+ * The place a block is about to land in, marked: a container, or a band at a block's edge. Named
541
+ * in both corners the way a selected block is — what it is on the left, how big it is on the
542
+ * right. The travelling dashes make it the one thing moving on the overlay.
543
+ */
544
+ function TargetFrame({ rect, label, area }: { rect: IRect | null; label: string; area: string }) {
545
+ const style = rect
546
+ ? { transform: `translate(${rect.x}px, ${rect.y}px)`, width: `${rect.width}px`, height: `${rect.height}px` }
547
+ : { inset: 0 }; // nothing measurable to point at — the frame is the answer
548
+ return (
549
+ <div className="canvas-view__slot-area canvas-view__slot-area--target"
550
+ data-test-id="inspector-child-box"
551
+ data-slot-area={area}
552
+ data-slot-target
553
+ style={style}>
554
+ <span className="canvas-view__tag canvas-view__tag--slot">{label}</span>
555
+ {rect ? (
556
+ <span className="canvas-view__tag canvas-view__tag--slot canvas-view__tag--size"
557
+ data-slot-size>{Math.round(rect.width)}×{Math.round(rect.height)}</span>
558
+ ) : null}
559
+ </div>
560
+ );
561
+ }
562
+
563
+ /**
564
+ * What a place lights up while it is pointed at: the CONTAINER the new block goes into — which
565
+ * block it joins is the one thing the ghost itself cannot show. The page root is no container at
566
+ * all (not a rendered slot): a place of the page itself lights nothing, its ghost is the answer.
567
+ */
568
+ type TAddTarget = { readonly of: 'container' } | { readonly of: 'page' };
569
+
570
+ /** one place a block may go: what it says, what it would do, and what it lights up */
571
+ interface IAddPoint {
572
+ readonly key: string;
573
+ readonly title: string;
574
+ /** the structural name (docs/code-conventions): add-child-after — into the block's own (empty)
575
+ * slot, or the page's first block; add-before/after — next to the block, among its siblings;
576
+ * add-child-before — the page's start */
577
+ readonly testId: 'inspector-add-child-before-button' | 'inspector-add-child-after-button'
578
+ | 'inspector-add-before-button' | 'inspector-add-after-button';
579
+ readonly at: IInsertionPoint;
580
+ readonly target: TAddTarget;
581
+ }
582
+
583
+
584
+ /**
585
+ * A place offered on screen as a GHOST: a green tab with the place's words. Next to the selected
586
+ * block it is GLUED to the block's frame — on top a segment of the corner strip beside the size,
587
+ * below a tab hanging from the bottom edge, left or right a tab on that edge (the neighbours stand
588
+ * side by side) — so frame, tags and ghost read as one piece. A "+" floating in a corner said
589
+ * nothing to authors; a tab on the edge it adds next to says where. Inside an empty slot of the
590
+ * block, or at the page's own ends, it is a tag of its own (`free`).
591
+ */
592
+ type TGhostPlace = 'top' | 'bottom' | 'left' | 'right' | 'free';
593
+
594
+ interface IGhost {
595
+ readonly point: IAddPoint;
596
+ readonly place: TGhostPlace;
597
+ /** where a free ghost stands (its top-left corner) — the glued ones go wherever their frame is */
598
+ readonly at: { readonly x: number; readonly y: number } | null;
599
+ /** what it says on the tab — short: the frame it is glued to names the block */
600
+ readonly words: string;
601
+ /**
602
+ * Next to something — `start` or `end` of it: only the side the pointer is on is shown, both at
603
+ * once crowd the block they are about. `inside` an empty slot: always shown.
604
+ */
605
+ readonly side: 'start' | 'end' | 'inside';
606
+ }
607
+
608
+ /** a side tab is at most this tall — a "+" on the edge, not a second frame along it */
609
+ const SIDE_TAB_MAX_HEIGHT = 48;
610
+ /** the insert popover's width — the search and a block's name with its provider */
611
+ const INSERT_POPOVER_WIDTH = 320;
612
+
613
+ /** a ghost's tab: a "+" and its words; on a side edge, too narrow for words — the tooltip has them */
614
+ function GhostTab({ ghost, height, onPick, onHover }: {
615
+ ghost: IGhost;
616
+ /** a side tab's height — the block's, up to SIDE_TAB_MAX_HEIGHT */
617
+ height?: number;
618
+ onPick: (at: IInsertionPoint) => void;
619
+ onHover: (point: IAddPoint | null) => void;
620
+ }) {
621
+ const { point, place, at, words } = ghost;
622
+ const narrow = place === 'left' || place === 'right';
623
+ const style = place === 'free' && at
624
+ ? { transform: `translate(${at.x}px, ${at.y}px)` }
625
+ : narrow && height !== undefined ? { height: `${height}px` } : undefined;
626
+ return (
627
+ <button type="button"
628
+ className={`canvas-view__ghost canvas-view__ghost--${place}`}
629
+ data-test-id={point.testId}
630
+ data-add-point={point.key}
631
+ data-add-side={ghost.side}
632
+ data-add-place={place}
633
+ data-tooltip={narrow ? words : undefined}
634
+ aria-label={point.title}
635
+ style={style}
636
+ onPointerEnter={() => onHover(point)}
637
+ onPointerLeave={() => onHover(null)}
638
+ onClick={() => onPick(point.at)}>
639
+ <span className="canvas-view__ghost-plus"
640
+ aria-hidden="true">+</span>
641
+ {narrow ? null : <span className="canvas-view__ghost-words">{words}</span>}
642
+ </button>
643
+ );
644
+ }
645
+
646
+ /**
647
+ * Where the picked block will LAND: the emptiness the agent made room with (LANDING_NODE_ID),
648
+ * framed in a colour of its own — green says which block it joins, violet says the very spot.
649
+ */
650
+ function LandingFrame({ box }: { box: INodeBox }) {
651
+ return (
652
+ <div className="canvas-view__landing"
653
+ data-test-id="inspector-landing-box"
654
+ style={{ transform: `translate(${box.x}px, ${box.y}px)`, width: `${box.width}px`, height: `${box.height}px` }}>
655
+ <span className="canvas-view__tag canvas-view__tag--landing">the new block lands here</span>
656
+ </div>
657
+ );
658
+ }
659
+
660
+ /**
661
+ * The bar above the frame. It earns its place twice: it holds the actions a preview needs, and its
662
+ * height is the room the topmost outline's label needs — hanging off the very top of the frame,
663
+ * that label used to be cut off by the panel's edge.
664
+ */
665
+ /** the tree column, drawn as a sidebar — the toggle the workbench's desk offers in this bar */
666
+ const TreeIcon = (
667
+ <svg viewBox="0 0 16 16"
668
+ aria-hidden="true">
669
+ <rect x="1.8"
670
+ y="2.8"
671
+ width="12.4"
672
+ height="10.4"
673
+ rx="1.6"
674
+ fill="none"
675
+ stroke="currentColor"
676
+ strokeWidth="1.5" />
677
+ <path d="M6 2.8v10.4"
678
+ fill="none"
679
+ stroke="currentColor"
680
+ strokeWidth="1.5" />
681
+ </svg>
682
+ );
683
+
684
+ /** the preview, opened on its own: an arrow leaving a box */
685
+ const OpenPreviewIcon = (
686
+ <svg viewBox="0 0 16 16"
687
+ aria-hidden="true">
688
+ <path d="M6.5 3.5H3.5v9h9V9.5M9 3.5h3.5V7M12.5 3.5L7.5 8.5"
689
+ fill="none"
690
+ stroke="currentColor"
691
+ strokeWidth="1.5"
692
+ strokeLinecap="round" />
693
+ </svg>
694
+ );
695
+
696
+ const LinkIcon = (
697
+ <svg viewBox="0 0 16 16"
698
+ aria-hidden="true">
699
+ <path d="M6.8 9.2a3 3 0 0 0 4.2 0l2-2a3 3 0 0 0-4.2-4.2l-1 1"
700
+ fill="none"
701
+ stroke="currentColor"
702
+ strokeWidth="1.5"
703
+ strokeLinecap="round" />
704
+ <path d="M9.2 6.8a3 3 0 0 0-4.2 0l-2 2a3 3 0 0 0 4.2 4.2l1-1"
705
+ fill="none"
706
+ stroke="currentColor"
707
+ strokeWidth="1.5"
708
+ strokeLinecap="round" />
709
+ </svg>
710
+ );
711
+
712
+ /** what the bar says about the version open: a draft (its name, whether its edits are on disk), or a published revision */
713
+ type TVersionBadge
714
+ = | { readonly kind: 'draft'; readonly name: string; readonly saving: boolean }
715
+ | { readonly kind: 'revision'; readonly rev: number };
716
+
717
+ /** a published revision's way to an edit: a fresh draft of it, opened beside it */
718
+ interface INewDraftControl {
719
+ readonly busy: boolean;
720
+ readonly onNewDraft: () => void;
721
+ }
722
+
723
+ /** the blocks of the page with newer versions — the chip that opens the updates window (ADR-0061) */
724
+ interface IUpdatesControl {
725
+ readonly count: number;
726
+ readonly onOpen: () => void;
727
+ }
728
+
729
+ const PublishIcon = (
730
+ <svg viewBox="0 0 16 16"
731
+ aria-hidden="true">
732
+ <path d="M8 10.5V2.8M4.8 5.8L8 2.6l3.2 3.2M3 9.5v2.7c0 .7.5 1.2 1.2 1.2h7.6c.7 0 1.2-.5 1.2-1.2V9.5"
733
+ fill="none"
734
+ stroke="currentColor"
735
+ strokeWidth="1.5"
736
+ strokeLinecap="round"
737
+ strokeLinejoin="round" />
738
+ </svg>
739
+ );
740
+
741
+ const NewDraftIcon = (
742
+ <svg viewBox="0 0 16 16"
743
+ aria-hidden="true">
744
+ <path d="M9.5 2.5H4.2c-.7 0-1.2.5-1.2 1.2v8.6c0 .7.5 1.2 1.2 1.2h7.6c.7 0 1.2-.5 1.2-1.2V6M8 6.5v5M5.5 9h5M12.8 1.6l1.6 1.6-3.9 3.9-2 .4.4-2z"
745
+ fill="none"
746
+ stroke="currentColor"
747
+ strokeWidth="1.3"
748
+ strokeLinecap="round"
749
+ strokeLinejoin="round" />
750
+ </svg>
751
+ );
752
+
753
+ /** the draft's way to the site: shown while a draft is open, quiet while it is being saved */
754
+ interface IPublishControl {
755
+ readonly busy: boolean;
756
+ /** unsaved edits are on their way to the store — publishing now would miss them */
757
+ readonly dirty: boolean;
758
+ readonly onPublish: () => void;
759
+ }
760
+
761
+ /** a tool of the workbench as the bar shows it */
762
+ interface IToolFace {
763
+ readonly id: string;
764
+ readonly label: string;
765
+ readonly iconUrl?: string;
766
+ }
767
+
768
+ const ToolIcon = (
769
+ <svg viewBox="0 0 16 16"
770
+ aria-hidden="true">
771
+ <path d="M3 4.5h10M3 8h10M3 11.5h6"
772
+ fill="none"
773
+ stroke="currentColor"
774
+ strokeWidth="1.4"
775
+ strokeLinecap="round" />
776
+ </svg>
777
+ );
778
+
779
+ function ActionBar({ inspecting, chromes, openChrome, onChrome, tools, openTool, onTool, pageSettingsOpen, onPageSettings, version, newDraft, updates, canCut, canCopy, canPaste, canUndo, canRedo, previewHref, onShare, publish, onRestart, onFullscreen, onInspecting, onCut, onCopy, onPaste, onUndo, onRedo }: {
780
+ inspecting: boolean;
781
+ /** the standing controls the page's blocks bring — a button each, the open one's card in the corner */
782
+ chromes: readonly IChromeNode[];
783
+ openChrome: string | null;
784
+ onChrome: (id: string) => void;
785
+ /** the workbench's tools (ADR-0062) — a button each, the open one over the stage */
786
+ tools: readonly IToolFace[];
787
+ openTool: string | null;
788
+ onTool: (id: string) => void;
789
+ pageSettingsOpen: boolean;
790
+ /** toggles the page's own settings; null where there are none (a prefab) */
791
+ onPageSettings: (() => void) | null;
792
+ /** the version open — a revision puts the bar in the read-only mood, the badge says WHY nothing can be edited */
793
+ version: TVersionBadge | null;
794
+ /** a revision's way to a draft of it; null where there is none (a draft open, a frozen page) */
795
+ newDraft: INewDraftControl | null;
796
+ /** the page's blocks with newer versions; null where there are none, or nothing can change */
797
+ updates: IUpdatesControl | null;
798
+ canCut: boolean;
799
+ canCopy: boolean;
800
+ canPaste: boolean;
801
+ canUndo: boolean;
802
+ canRedo: boolean;
803
+ /** the preview's own address — the same page without the editor, for a new tab and for sharing */
804
+ previewHref: string;
805
+ onShare: () => void;
806
+ /** null while a revision is inspected — nothing to publish */
807
+ publish: IPublishControl | null;
808
+ onRestart: () => void;
809
+ onFullscreen: () => void;
810
+ onInspecting: (on: boolean) => void;
811
+ onCut: () => void;
812
+ onCopy: () => void;
813
+ onPaste: () => void;
814
+ onUndo: () => void;
815
+ onRedo: () => void;
816
+ }) {
817
+ // hosted by the workbench's desk? its tree column is a click away from here — the bar is where
818
+ // the hands are. Optional, like every host cooperation
819
+ const workspace = useDiOptional(WORKSPACE_TOKEN);
820
+ const desk = useSyncExternalStore(workspace?.subscribe ?? noSubscription, workspace?.snapshot ?? noState, noState);
821
+ return (
822
+ <div className="canvas-view__bar">
823
+ {workspace && desk ? (
824
+ <>
825
+ <button type="button"
826
+ className={`canvas-view__action${desk.treeShown ? ' canvas-view__action--on' : ''}`}
827
+ data-test-id="canvas-tree-toggle"
828
+ data-action="tree"
829
+ data-tooltip={desk.treeShown ? 'Hide the tree' : 'Show the tree — the outline of the page beside the canvas'}
830
+ aria-label={desk.treeShown ? 'Hide the tree' : 'Show the tree'}
831
+ aria-pressed={desk.treeShown}
832
+ onClick={() => workspace.setTreeShown(!desk.treeShown)}>
833
+ {TreeIcon}
834
+ </button>
835
+ <span className="canvas-view__divider"
836
+ aria-hidden="true" />
837
+ </>
838
+ ) : null}
839
+ <button type="button"
840
+ className="canvas-view__action"
841
+ data-test-id="canvas-restart-button"
842
+ data-action="restart"
843
+ data-tooltip="Restart the preview — a fresh page, nothing remembered"
844
+ aria-label="Restart the preview"
845
+ onClick={onRestart}>
846
+ <svg viewBox="0 0 16 16"
847
+ aria-hidden="true">
848
+ <path d="M13.2 8a5.2 5.2 0 1 1-1.6-3.7"
849
+ fill="none"
850
+ stroke="currentColor"
851
+ strokeWidth="1.6" />
852
+ <path d="M12.4 1.6v3.4H9"
853
+ fill="none"
854
+ stroke="currentColor"
855
+ strokeWidth="1.6" />
856
+ </svg>
857
+ </button>
858
+ <button type="button"
859
+ className="canvas-view__action canvas-view__action--play"
860
+ data-test-id="canvas-fullscreen-button"
861
+ data-action="fullscreen"
862
+ data-tooltip="Full screen — the page over everything else"
863
+ aria-label="Full screen"
864
+ onClick={onFullscreen}>
865
+ <svg viewBox="0 0 16 16"
866
+ aria-hidden="true">
867
+ <path d="M4.5 3l8 5-8 5z"
868
+ fill="currentColor" />
869
+ </svg>
870
+ </button>
871
+ <button type="button"
872
+ className={`canvas-view__action canvas-view__action--picker${inspecting ? '' : ' canvas-view__action--off'}`}
873
+ data-test-id="canvas-inspect-toggle"
874
+ data-action="inspect"
875
+ data-inspecting={inspecting || undefined}
876
+ data-tooltip={inspecting
877
+ ? 'Inspector on — clicks select blocks. Turn it off to use the page itself'
878
+ : 'Inspector off — the page behaves as it will for a visitor'}
879
+ aria-label={inspecting ? 'Turn the inspector off' : 'Turn the inspector on'}
880
+ aria-pressed={inspecting}
881
+ onClick={() => onInspecting(!inspecting)}>
882
+ <svg viewBox="0 0 16 16"
883
+ aria-hidden="true">
884
+ <rect x="1.6"
885
+ y="1.6"
886
+ width="8.6"
887
+ height="8.6"
888
+ rx="1.2"
889
+ fill="none"
890
+ stroke="currentColor"
891
+ strokeWidth="1.5" />
892
+ <path d="M8.2 8.2l6.2 2.4-2.6.8-.8 2.6z"
893
+ fill="currentColor" />
894
+ </svg>
895
+ </button>
896
+ {onPageSettings || chromes.length || tools.length ? <span className="canvas-view__divider"
897
+ aria-hidden="true" /> : null}
898
+ {onPageSettings ? (
899
+ <button type="button"
900
+ className={`canvas-view__action${pageSettingsOpen ? ' canvas-view__action--on' : ''}`}
901
+ data-test-id="canvas-page-settings"
902
+ data-action="page-settings"
903
+ data-tooltip={pageSettingsOpen ? 'Close the page’s settings' : 'The page’s own settings: its title, description, cover, server rendering'}
904
+ aria-label="Page settings"
905
+ aria-expanded={pageSettingsOpen}
906
+ onClick={onPageSettings}>
907
+ {PageSettingsIcon}
908
+ </button>
909
+ ) : null}
910
+ {chromes.map((node) => {
911
+ const face = chromeFaceOf(node);
912
+ const open = openChrome === node.id;
913
+ return (
914
+ <button key={node.id}
915
+ type="button"
916
+ className={`canvas-view__action${open ? ' canvas-view__action--on' : ''}`}
917
+ data-test-id="canvas-chrome-toggle"
918
+ data-action="chrome"
919
+ data-chrome={node.id}
920
+ data-tooltip={open ? `Hide: ${face.label.toLowerCase()}` : face.label}
921
+ aria-label={face.label}
922
+ aria-expanded={open}
923
+ onClick={() => onChrome(node.id)}>
924
+ {face.icon}
925
+ </button>
926
+ );
927
+ })}
928
+ {tools.map((tool) => {
929
+ const open = openTool === tool.id;
930
+ return (
931
+ <button key={tool.id}
932
+ type="button"
933
+ className={`canvas-view__action${open ? ' canvas-view__action--on' : ''}`}
934
+ data-test-id="canvas-tool-toggle"
935
+ data-action="tool"
936
+ data-tool={tool.id}
937
+ data-tooltip={open ? `Hide: ${tool.label.toLowerCase()}` : tool.label}
938
+ aria-label={tool.label}
939
+ aria-expanded={open}
940
+ onClick={() => onTool(tool.id)}>
941
+ {tool.iconUrl ? <img className="canvas-view__tool-icon"
942
+ src={tool.iconUrl}
943
+ alt="" /> : ToolIcon}
944
+ </button>
945
+ );
946
+ })}
947
+ {version?.kind === 'revision' ? (
948
+ <span className="canvas-view__readonly"
949
+ data-test-id="canvas-readonly-badge"
950
+ data-tooltip="A published revision is immutable — make a draft of it to change it">
951
+ {`published r${version.rev} — read-only`}
952
+ </span>
953
+ ) : null}
954
+ {/* …and a draft says which one it is, and whether its edits are on disk yet */}
955
+ {version?.kind === 'draft' ? (
956
+ <span className={`canvas-view__readonly canvas-view__readonly--draft${version.saving ? ' canvas-view__readonly--saving' : ''}`}
957
+ data-test-id="canvas-draft-badge"
958
+ data-saving={version.saving || undefined}
959
+ data-tooltip={version.saving
960
+ ? 'Saving your edits — a draft saves by itself'
961
+ : 'A draft: your edits are saved by themselves; Publish makes it the page readers get'}>
962
+ <span className="canvas-view__save-dot"
963
+ aria-hidden="true" />
964
+ {`draft ${version.name} — ${version.saving ? 'saving…' : 'saved'}`}
965
+ </span>
966
+ ) : null}
967
+ {newDraft ? (
968
+ <button type="button"
969
+ className="canvas-view__new-draft"
970
+ data-test-id="canvas-new-draft-button"
971
+ data-action="new-draft"
972
+ disabled={newDraft.busy}
973
+ data-tooltip="Make a draft of this revision to change it — it opens beside this one"
974
+ onClick={newDraft.onNewDraft}>
975
+ {NewDraftIcon}
976
+ {newDraft.busy ? 'Making a draft…' : 'New draft'}
977
+ </button>
978
+ ) : null}
979
+ {updates ? (
980
+ <button type="button"
981
+ className="canvas-view__updates"
982
+ data-test-id="canvas-updates-button"
983
+ data-action="updates"
984
+ data-count={updates.count}
985
+ data-tooltip="Some blocks of this page have newer versions — see what changed and update them"
986
+ onClick={updates.onOpen}>
987
+ {`↑ Updates · ${updates.count}`}
988
+ </button>
989
+ ) : null}
990
+ {publish ? (
991
+ <button type="button"
992
+ className="canvas-view__publish"
993
+ data-test-id="canvas-publish-button"
994
+ data-action="publish"
995
+ disabled={publish.busy || publish.dirty}
996
+ data-tooltip={publish.dirty
997
+ ? 'Wait a moment — the draft is being saved'
998
+ : 'Publish this draft — it becomes the live revision readers get'}
999
+ onClick={publish.onPublish}>
1000
+ {PublishIcon}
1001
+ {publish.busy ? 'Publishing…' : 'Publish'}
1002
+ </button>
1003
+ ) : null}
1004
+ {/* the right group is about the DRAFT, not about the frame the left-side actions drive:
1005
+ cut/copy/paste of the selection, then undo/redo. Disabled IS the empty state: the
1006
+ colour (blue can, gray cannot) is the whole status UI */}
1007
+ <button type="button"
1008
+ className="canvas-view__action canvas-view__action--history canvas-view__action--right"
1009
+ data-test-id="canvas-cut-button"
1010
+ data-action="cut"
1011
+ data-tooltip="Cut the selection (Ctrl+X)"
1012
+ aria-label="Cut the selection"
1013
+ disabled={!canCut}
1014
+ onClick={onCut}>
1015
+ <svg viewBox="0 0 16 16"
1016
+ aria-hidden="true">
1017
+ <circle cx="4"
1018
+ cy="4.2"
1019
+ r="2"
1020
+ fill="none"
1021
+ stroke="currentColor"
1022
+ strokeWidth="1.4" />
1023
+ <circle cx="4"
1024
+ cy="11.8"
1025
+ r="2"
1026
+ fill="none"
1027
+ stroke="currentColor"
1028
+ strokeWidth="1.4" />
1029
+ <path d="M13.4 2.6L5.6 10.7M5.6 5.3l3.2 3.3M9.9 9.6l3.5 3.8"
1030
+ fill="none"
1031
+ stroke="currentColor"
1032
+ strokeWidth="1.4" />
1033
+ </svg>
1034
+ </button>
1035
+ <button type="button"
1036
+ className="canvas-view__action canvas-view__action--history"
1037
+ data-test-id="canvas-copy-button"
1038
+ data-action="copy"
1039
+ data-tooltip="Copy the selection (Ctrl+C)"
1040
+ aria-label="Copy the selection"
1041
+ disabled={!canCopy}
1042
+ onClick={onCopy}>
1043
+ <svg viewBox="0 0 16 16"
1044
+ aria-hidden="true">
1045
+ <rect x="5.6"
1046
+ y="5.6"
1047
+ width="7.6"
1048
+ height="7.6"
1049
+ rx="1.4"
1050
+ fill="none"
1051
+ stroke="currentColor"
1052
+ strokeWidth="1.6" />
1053
+ <path d="M3 10.4V4.4A1.6 1.6 0 0 1 4.6 2.8h6"
1054
+ fill="none"
1055
+ stroke="currentColor"
1056
+ strokeWidth="1.6" />
1057
+ </svg>
1058
+ </button>
1059
+ <button type="button"
1060
+ className="canvas-view__action canvas-view__action--history"
1061
+ data-test-id="canvas-paste-button"
1062
+ data-action="paste"
1063
+ data-tooltip="Paste blocks (Ctrl+V)"
1064
+ aria-label="Paste blocks"
1065
+ disabled={!canPaste}
1066
+ onClick={onPaste}>
1067
+ <svg viewBox="0 0 16 16"
1068
+ aria-hidden="true">
1069
+ <path d="M10.4 2.9h1.2A1.4 1.4 0 0 1 13 4.3v9A1.4 1.4 0 0 1 11.6 14.7H4.4A1.4 1.4 0 0 1 3 13.3v-9A1.4 1.4 0 0 1 4.4 2.9h1.2"
1070
+ fill="none"
1071
+ stroke="currentColor"
1072
+ strokeWidth="1.6" />
1073
+ <rect x="5.5"
1074
+ y="1.4"
1075
+ width="5"
1076
+ height="2.9"
1077
+ rx="0.7"
1078
+ fill="none"
1079
+ stroke="currentColor"
1080
+ strokeWidth="1.6" />
1081
+ </svg>
1082
+ </button>
1083
+ <button type="button"
1084
+ className="canvas-view__action canvas-view__action--history"
1085
+ data-test-id="canvas-undo-button"
1086
+ data-action="undo"
1087
+ data-tooltip="Undo (Ctrl+Z)"
1088
+ aria-label="Undo"
1089
+ disabled={!canUndo}
1090
+ onClick={onUndo}>
1091
+ <svg viewBox="0 0 16 16"
1092
+ aria-hidden="true">
1093
+ <path d="M2 4.7v4h4"
1094
+ fill="none"
1095
+ stroke="currentColor"
1096
+ strokeWidth="1.6" />
1097
+ <path d="M14 11.3a6 6 0 0 0-10-4.4L2 8.7"
1098
+ fill="none"
1099
+ stroke="currentColor"
1100
+ strokeWidth="1.6" />
1101
+ </svg>
1102
+ </button>
1103
+ <button type="button"
1104
+ className="canvas-view__action canvas-view__action--history"
1105
+ data-test-id="canvas-redo-button"
1106
+ data-action="redo"
1107
+ data-tooltip="Redo (Ctrl+Shift+Z)"
1108
+ aria-label="Redo"
1109
+ disabled={!canRedo}
1110
+ onClick={onRedo}>
1111
+ <svg viewBox="0 0 16 16"
1112
+ aria-hidden="true">
1113
+ <path d="M14 4.7v4h-4"
1114
+ fill="none"
1115
+ stroke="currentColor"
1116
+ strokeWidth="1.6" />
1117
+ <path d="M2 11.3a6 6 0 0 1 10-4.4L14 8.7"
1118
+ fill="none"
1119
+ stroke="currentColor"
1120
+ strokeWidth="1.6" />
1121
+ </svg>
1122
+ </button>
1123
+ {/* the preview on its own: in a new tab, or as a link to hand to someone who may edit */}
1124
+ <span className="canvas-view__divider"
1125
+ aria-hidden="true" />
1126
+ <a className="canvas-view__action canvas-view__action--history"
1127
+ data-test-id="canvas-open-preview"
1128
+ data-action="open-preview"
1129
+ data-tooltip="Open this preview in a new tab — the page as it is here, without the editor"
1130
+ aria-label="Open the preview in a new tab"
1131
+ href={previewHref}
1132
+ target="_blank"
1133
+ rel="noreferrer">
1134
+ {OpenPreviewIcon}
1135
+ </a>
1136
+ <button type="button"
1137
+ className="canvas-view__action canvas-view__action--history"
1138
+ data-test-id="canvas-share-preview"
1139
+ data-action="share-preview"
1140
+ data-tooltip="Copy a link to this preview — anyone who may edit this site can open it"
1141
+ aria-label="Copy a link to the preview"
1142
+ onClick={onShare}>
1143
+ {LinkIcon}
1144
+ </button>
1145
+ </div>
1146
+ );
1147
+ }
1148
+
1149
+ // a function DECLARATION, not an arrow in the module literal: the component carries its own name
1150
+ /**
1151
+ * The width of a settings card holding a wide field (a paragraph's text): the field gets at least
1152
+ * half the screen — in stage pixels (an emulated viewport scales the stage) — plus the card's own
1153
+ * padding and the reference gutter beside its fields; never wider than the stage.
1154
+ */
1155
+ function wideFieldWidth(stage: HTMLElement | null, stageWidth: number): number {
1156
+ const view = stage?.ownerDocument.defaultView;
1157
+ const shown = stage?.getBoundingClientRect().width ?? 0;
1158
+ const scale = shown > 0 && stageWidth > 0 ? shown / stageWidth : 1;
1159
+ const half = (view?.innerWidth ?? stageWidth * 2) / 2 / scale;
1160
+ return Math.min(stageWidth - 16, Math.round(half + 64));
1161
+ }
1162
+
1163
+ function Canvas() {
1164
+ // the canvas's OWN session, scoped to its dock tab — the rest of the workbench follows the
1165
+ // ACTIVE one through the hub's facade
1166
+ const { session, state, markInteractive, markHovered, markLeft } = useCanvasEditingSession();
1167
+ const workbenchDi = useDi(DI_CONTAINER_TOKEN);
1168
+ const notices = noticesOf(workbenchDi);
1169
+ // a provider's chrome in this canvas reads and sets the author's views, reads the project's
1170
+ // config and its own scope (what of it is untranslated) — all exposed by the workbench, fed here
1171
+ // too so the canvas does not need the panel or an opened settings popover
1172
+ ensureEditorViews(workbenchDi, session);
1173
+ ensureSettingsDraft(workbenchDi, session);
1174
+ useFeedProjectConfig();
1175
+ // a provider taking new blocks under its wing links what arrives in this canvas's document
1176
+ useEffect(() => watchArrivals(session, ensureRefsEditing(workbenchDi)), [session, workbenchDi]);
1177
+ const preview = useIsPreviewMode();
1178
+ // the project's prefabs: a prefab's own block, picked to be seen, is found in its live tree
1179
+ const project = useProjectPrefabs(!preview);
1180
+ useFeedPrefabTrees(workbenchDi, project);
1181
+ // hosted in a dock? name OUR tab after the open document and report the draft's identity, so
1182
+ // "edit this draft" can find an already-open tab. Entirely optional: no dock — nothing to name
1183
+ const dockTab = useDiOptional(DOCK_TAB_TOKEN);
1184
+ useEffect(() => {
1185
+ if (!dockTab) return;
1186
+ const revSuffix = state.revision != null ? ` · r${state.revision}` : '';
1187
+ const named = state.kind === 'prefabs' ? `prefab ${state.pageName}` : state.pageName;
1188
+ dockTab.rename(state.pageName ? `Canvas · ${named}${revSuffix}` : 'Canvas');
1189
+ dockTab.setMeta({ editingPage: state.pageName, editingDraftId: state.draftId, editingRevision: state.revision });
1190
+ }, [dockTab, state.kind, state.pageName, state.draftId, state.revision]);
1191
+ // a restart throws the frame away and builds a new one: the surest way to a preview that
1192
+ // remembers nothing of what was clicked in it
1193
+ const [restarts, setRestarts] = useState(0);
1194
+ // …and a rebuild does the same for the one reason the editor cannot talk its way out of: a
1195
+ // block whose module the running realm never loaded (see the effects below)
1196
+ const [rebuilds, setRebuilds] = useState(0);
1197
+ const [fullscreen, setFullscreen] = useState(false);
1198
+ // the place under the pointer, so its container (or the band at a block's edge) can light up
1199
+ // before it is picked — hovering a "+" should answer "where" before the click, not after
1200
+ const [hoveredAdd, setHoveredAdd] = useState<IAddPoint | null>(null);
1201
+ // a pick already IS the selection — revealing it would yank the page under the pointer
1202
+ const pickedRef = useRef<string | null>(null);
1203
+ const onPick = useCallback((nodeId: string | null, ctrl: boolean, part: string | null) => {
1204
+ // pointer events live INSIDE the iframe and never reach the wrapper's capture handler — the
1205
+ // agent's report is where this canvas learns it was touched, so activation happens here
1206
+ markInteractive();
1207
+ const doc = session.snapshot().doc;
1208
+ if (!doc) return;
1209
+ // a place is waiting for a block: whatever was clicked, the click means "never mind"
1210
+ if (session.snapshot().insertionPoint) {
1211
+ session.setInsertionPoint(null);
1212
+ return;
1213
+ }
1214
+ if (ctrl) {
1215
+ // the Ctrl mode: a click toggles the branch whole. On no block it toggles nothing — a
1216
+ // stray Ctrl-click must not throw away an accumulated selection
1217
+ if (nodeId && findNodeDoc(doc, nodeId)) session.toggleMultiSelect(nodeId);
1218
+ return;
1219
+ }
1220
+ // a click that landed on no block — or on the page itself, its root — means "never mind": the
1221
+ // page background is how one steps back out of a selection. Any block is picked at once, open
1222
+ // settings or not: they wait behind a button now and follow the selection (Canvas below), so
1223
+ // there is no card a click away would have to close first
1224
+ if (!nodeId || nodeId === doc.id) {
1225
+ pickedRef.current = null;
1226
+ session.selectNode(null);
1227
+ return;
1228
+ }
1229
+ // ids the draft no longer knows (an edit landed after the frame rendered) are dropped
1230
+ if (!findNodeDoc(doc, nodeId)) return;
1231
+ // a PREFAB's own block is picked as itself — to be seen, read-only (prefabParts.ts); its prefab
1232
+ // node is the Ctrl mode's unit, and a click on the tree's prefab row
1233
+ const picked = part ?? nodeId;
1234
+ pickedRef.current = picked;
1235
+ session.selectNode(picked);
1236
+ }, [session, markInteractive]);
1237
+
1238
+ // hovering BORROWS the focus: pointing into this canvas makes it the active one (the tree
1239
+ // follows) for as long as the pointer stays — leaving hands the focus back to the selection
1240
+ // anchor. The hovered node also travels into the session for the tree's pale highlight.
1241
+ const onHover = useCallback((nodeId: string | null, part: string | null) => {
1242
+ markHovered();
1243
+ session.hoverNode(part ?? nodeId);
1244
+ }, [session, markHovered]);
1245
+
1246
+ const pattern = (state.doc as { pattern?: string } | null)?.pattern ?? '/';
1247
+ // previewToken: the token carries the draft identity; the PATH stays the page's real
1248
+ // pattern so modules inside the preview see an honest location. editorBridge NAMES the agent
1249
+ // to attach — derived from this module's own reference, so the preview gets the agent of THIS
1250
+ // editor build. The URL shared with observers is the same address without that parameter.
1251
+ const bridgeId = previewBridgeIdOf(useComponent().reference);
1252
+ // a draft token for editing, an `r<rev>` token for the read-only inspection of a published
1253
+ // revision; a prefab's behind `prefab.` (it previews as a page whose root is its own)
1254
+ const tokenEntity = state.kind === 'prefabs' ? `prefab.${state.pageName}` : state.pageName;
1255
+ const previewToken = state.revision != null ? `${tokenEntity}.r${state.revision}` : `${tokenEntity}.${state.draftId}`;
1256
+ const src = `${pattern}?previewToken=${previewToken}&editorBridge=${bridgeId}`;
1257
+ const frameKey = `${src}#${rebuilds}#${restarts}`;
1258
+ // the address to hand around: the preview without the agent, absolute — a link is for elsewhere
1259
+ const previewHref = typeof window === 'undefined'
1260
+ ? `${pattern}?previewToken=${previewToken}`
1261
+ : new URL(`${pattern}?previewToken=${previewToken}`, window.location.origin).href;
1262
+ // Which side of the selection — or of the page, with nothing selected — the pointer is on: the
1263
+ // ghost offered is the one on that side. The pointer reports every frame; only a FLIP of the side
1264
+ // re-renders (setState with the same value is dropped), the geometry is read through a ref
1265
+ const [pointerAtEnd, setPointerAtEnd] = useState(true);
1266
+ const sideRef = useRef<{ rect: IRect | null; axis: TAxis }>({ rect: null, axis: 'column' });
1267
+ const onPointer = useCallback((x: number, y: number) => {
1268
+ const { rect, axis } = sideRef.current;
1269
+ if (!rect) return;
1270
+ setPointerAtEnd(axis === 'row' ? x >= rect.x + rect.width / 2 : y >= rect.y + rect.height / 2);
1271
+ }, []);
1272
+ const { scrolling, boxes, parts, slots, units, viewport, hoveredId, hoveredPart, hoveredCtrl, connected, command, stageRef, overlayRef, frameRef } = usePreviewLink(frameKey, onPick, onHover, onPointer);
1273
+ // the modifier is watched on BOTH sides of the boundary: the preview realm stamps it on its
1274
+ // events, and these listeners catch a press that never generates one (keyboard focus stays on
1275
+ // the editor side — the agent suppresses the mousedowns that would move it)
1276
+ const ctrlHeld = useCtrlHeld();
1277
+
1278
+ // What the LIVE realm can render: the modules it loaded when it booted. Kept as it was at boot
1279
+ // and not widened afterwards — a live update never adds a module, and a block whose type was
1280
+ // taken off the page leaves its module loaded, so putting one back is still free.
1281
+ const bootedRefs = useRef<Set<string>>(new Set());
1282
+ useEffect(() => {
1283
+ bootedRefs.current = new Set(referencesOf(session.snapshot().doc));
1284
+ }, [frameKey, session]);
1285
+
1286
+ // the set as an id, so the two effects below depend on WHAT it is rather than on the array's
1287
+ // identity. Whether it FITS the realm is asked inside them, never during a render: the answer
1288
+ // changes in an effect (a rebuild), and a render that read it would read it stale.
1289
+ const docRefs = useMemo(() => referencesOf(state.doc), [state.doc]);
1290
+ const refsKey = docRefs.join('|');
1291
+ // A PREFAB node is no module: the realm re-expands it from the blocks it already shows, and a
1292
+ // prefab node it shows nothing of (put back by redo after an undo took it out, pasted in) has
1293
+ // only the server's render to come from. So a prefab fits the realm while the agent lists it
1294
+ // among the expanded ones — the booted references stand in until an agent has said
1295
+ const docUnits = useMemo(() => (state.doc ? unitNodeIds(state.doc) : []), [state.doc]);
1296
+ const unitsKey = docUnits.join('|');
1297
+ const unitsRef = useRef(units);
1298
+ unitsRef.current = units;
1299
+ const fitsRealm = (): boolean => docRefs.every((reference) => bootedRefs.current.has(reference))
1300
+ && (unitsRef.current === null || docUnits.every((id) => unitsRef.current!.has(id)));
1301
+
1302
+ /**
1303
+ * The place a block is about to go gets ROOM in the preview — the agent splices a little emptiness
1304
+ * into its copy of the page, and the overlay frames it (LandingFrame). Declared before the live
1305
+ * composition below: when the block lands, the place is spent in the same update as the tree
1306
+ * changes, and the room goes away before the block arrives.
1307
+ */
1308
+ const landingPoint = state.insertionPoint;
1309
+ useEffect(() => {
1310
+ if (!connected) return;
1311
+ command({ t: 'landing', at: landingPoint ? { parentId: landingPoint.parentId, slot: landingPoint.slot, index: landingPoint.index } : null });
1312
+ }, [connected, landingPoint?.parentId, landingPoint?.slot, landingPoint?.index, command]);
1313
+
1314
+ // the catalog's versions: the page's blocks with newer ones (the Updates window, ADR-0061), and the
1315
+ // tree as the preview realm runs it — the editor's own blocks (the page root) at their latest, the
1316
+ // way the server rendered the page, not the version the document was made with. Until the catalog
1317
+ // is known nothing is pushed: the realm holds the server's render of the same document
1318
+ const upgrades = useUpgrades(state.doc, !preview);
1319
+ const catalogKnown = upgrades.components.length > 0;
1320
+ const realmDoc = useMemo(() => (state.doc && catalogKnown ? withLatestTools(state.doc, upgrades.versions) : null), [state.doc, catalogKnown, upgrades.versions]);
1321
+
1322
+ /**
1323
+ * LIVE COMPOSITION: the tree the editor holds goes straight into the preview's page manager and
1324
+ * the application re-renders around it. No reload, so nothing the realm built up is thrown
1325
+ * away — and no window in which the overlay draws geometry of a tree that has already changed.
1326
+ *
1327
+ * What can degrade: a block that appears this way has no server-rendered data waiting for it,
1328
+ * so its queries fetch from the client. That is a Loading flash where a reload would have shown
1329
+ * the server's answer immediately — and the price of never reloading at all. What goes is the
1330
+ * tree as the realm runs it (`realmDoc` above).
1331
+ */
1332
+ useEffect(() => {
1333
+ if (!connected || !realmDoc || !fitsRealm()) return;
1334
+ command({ t: 'page', page: realmDoc });
1335
+ }, [connected, realmDoc, refsKey, unitsKey, command]);
1336
+
1337
+ // the author's views of reference providers (i18n: the language being edited) reach the
1338
+ // preview's own store — references there resolve in them, without a reload
1339
+ useEffect(() => {
1340
+ if (connected) command({ t: 'views', views: state.views });
1341
+ }, [connected, state.views, command]);
1342
+
1343
+ /**
1344
+ * …and the one case that cannot be pushed: a block of a type this realm never loaded. The frame
1345
+ * is rebuilt for it — but only once the draft on disk carries the block, because the shell that
1346
+ * answers `?previewToken` renders the SAVED revision. Until then the preview is one block behind
1347
+ * and says nothing about it, which is the same second the autosave takes anyway.
1348
+ */
1349
+ useEffect(() => {
1350
+ if (state.doc && !state.dirty && !fitsRealm()) setRebuilds((count) => count + 1);
1351
+ }, [refsKey, unitsKey, state.dirty]);
1352
+
1353
+ /**
1354
+ * Whether the preview is being INSPECTED right now — the toggle, and full screen.
1355
+ *
1356
+ * Full screen is the page and nothing else: it exists to be used, not composed. So the overlay
1357
+ * draws nothing over it and the agent stops swallowing clicks for as long as it lasts — a
1358
+ * frame that covers the screen while links still refuse to open would be the worst of both.
1359
+ * The toggle's own state is untouched; leaving full screen puts the editor back as it was.
1360
+ */
1361
+ const inspecting = state.inspecting && !fullscreen;
1362
+ useEffect(() => {
1363
+ if (connected) command({ t: 'armed', armed: inspecting });
1364
+ }, [connected, inspecting, command]);
1365
+
1366
+ const selectedId = state.selectedNodeId;
1367
+ useEffect(() => {
1368
+ if (!connected || !selectedId) return;
1369
+ if (pickedRef.current === selectedId) return; // selected from inside — already in view
1370
+ command({ t: 'reveal', nodeId: selectedId });
1371
+ }, [connected, selectedId, command]);
1372
+
1373
+ // the tree cannot see EMPTY slots in the document — the preview's declarations go through the
1374
+ // session (which drops unchanged lists, so per-measure geometry does not wake subscribers)
1375
+ useEffect(() => {
1376
+ session.setDeclaredSlots(slots.map((slot) => ({ parentId: slot.ownerId, slot: slot.slot })));
1377
+ }, [slots, session]);
1378
+
1379
+ const byId = useMemo(() => new Map(boxes.map((box) => [box.id, box])), [boxes]);
1380
+ // a prefab's own block, selected or under the pointer, is framed by its own box
1381
+ const partById = useMemo(() => new Map(parts.map((box) => [box.id, box])), [parts]);
1382
+ const boxOf = (id: string): INodeBox | undefined => byId.get(id) ?? partById.get(id);
1383
+ const selectedBox = selectedId ? boxOf(selectedId) : undefined;
1384
+ // with Ctrl the prefab is the unit — the frame says what a Ctrl-click would take
1385
+ const hoveredShownId = hoveredCtrl || ctrlHeld ? hoveredId : hoveredPart ?? hoveredId;
1386
+ const hoveredBox = hoveredShownId && hoveredShownId !== selectedId ? boxOf(hoveredShownId) : undefined;
1387
+ // The yellow mode: every picked element wears a pick box — pickedness is per element (a flat
1388
+ // set in the session). The frame is THICK where a picked region BEGINS (the element's parent
1389
+ // is not picked) and thin inside it, so what was actually clicked still reads at a glance.
1390
+ const pickedBoxes = state.multiSelectedIds.map((id) => byId.get(id)).filter((box) => box !== undefined);
1391
+
1392
+ // the selected block's settings live beside it — unless the workbench still docks a settings
1393
+ // panel, which then keeps them. Hidden while the page is the visitor's (inspector off), in the
1394
+ // yellow mode and while moving: those modes are about the tree, not one block's settings.
1395
+ const settingsPanels = ensureSettingsPanels(workbenchDi);
1396
+ const panelCount = useSyncExternalStore(settingsPanels.subscribe, settingsPanels.snapshot, settingsPanels.snapshot);
1397
+ // the popover's size is the author's wish, remembered across popovers and reloads
1398
+ const [popoverSize, setPopoverSize] = useState<IPopoverSize>(() => readPopoverSize(localStorageOrNull()));
1399
+ const resizePopover = useCallback((size: IPopoverSize) => {
1400
+ setPopoverSize(size);
1401
+ writePopoverSize(localStorageOrNull(), size);
1402
+ }, []);
1403
+ // what the open form asks for — its height with room enough, a wide field in it
1404
+ const [popoverContent, setPopoverContent] = useState<IPopoverContent>({ height: 0, wide: false });
1405
+ const tellPopoverContent = useCallback((content: IPopoverContent) => setPopoverContent(content), []);
1406
+ // the open settings' form still on its way — the popover waits unseen, the button turns
1407
+ const [settingsLoading, setSettingsLoading] = useState(false);
1408
+ const tellSettingsLoading = useCallback((loading: boolean) => setSettingsLoading(loading), []);
1409
+ // the PAGE's settings wait behind a button among the canvas tools: opening a draft selects its
1410
+ // root, and a popover docked over the page would hide the very controls one opens a draft to
1411
+ // use. Open for ONE document while its root stays selected — picking anything else closes them
1412
+ const docKey = `${state.kind}:${state.pageName}:${state.draftId ?? `r${state.revision}`}`;
1413
+ // PUBLISH from the bar: the draft becomes the live revision, and every session holding it —
1414
+ // this canvas's first — reopens it as that revision, read-only (the pages list does the same
1415
+ // from its rows; the listing refetches itself, the action invalidates it)
1416
+ const hub = editingSessionHubOf(workbenchDi);
1417
+ const publish = useServerAction({ token: publishPageToken, start: EServerActionStart.START_MANUAL });
1418
+ const publishingRef = useRef<{ name: string; draftId: string; kind: ContentKind } | null>(null);
1419
+ // the dialog that names the publication; the listing (the pages views' cache entry) says which
1420
+ // revision this one replaces
1421
+ const [publishAsk, setPublishAsk] = useState(false);
1422
+ const [publishError, setPublishError] = useState<string | null>(null);
1423
+ // the page's blocks with newer versions, and the window that updates them in one step (ADR-0061)
1424
+ const holder = useDi(TILES_HOLDER_TOKEN);
1425
+ const upgradeGroupsOfPage = useMemo(() => upgradeGroups(upgrades.outdated, upgrades.versions), [upgrades.outdated, upgrades.versions]);
1426
+ const [updatesOpen, setUpdatesOpen] = useState(false);
1427
+ const [updating, setUpdating] = useState(false);
1428
+ const runUpdates = async (selected: ReadonlySet<string>) => {
1429
+ const nodes = upgrades.outdated.filter((node) => selected.has(upgradeKey(node.upgrade.namespace, node.upgrade.name)));
1430
+ setUpdating(true);
1431
+ try {
1432
+ session.upgradeNodes(await upgradeChanges(holder, nodes, upgrades.components));
1433
+ notices?.show('publish', blocksLabel('Updated', nodes.length));
1434
+ setUpdatesOpen(false);
1435
+ } catch {
1436
+ notices?.show('refuse', 'Could not update the blocks — nothing was changed');
1437
+ } finally {
1438
+ setUpdating(false);
1439
+ }
1440
+ };
1441
+ const listing = useServerAction({ token: listPagesToken, input: { kind: state.kind } });
1442
+ const listedPage = listing.isSuccess() && listing.serverData.ok
1443
+ ? listing.serverData.pages.find((page) => page.name === state.pageName) ?? null
1444
+ : null;
1445
+ const liveOfPage = listedPage?.meta.live ?? null;
1446
+ // a page the platform froze takes no drafts (its revision is a live reference, changed by deploys)
1447
+ const pageLocked = listedPage?.meta.locked === true;
1448
+ // A REVISION's way to an edit: a fresh draft of it, opened in a tab beside — like the pages list's
1449
+ // "New draft"; without the workbench's tabs, in this canvas
1450
+ const createDraft = useServerAction({ token: createPageDraftToken, start: EServerActionStart.START_MANUAL });
1451
+ const tabs = useDiOptional(WORKSPACE_TOKEN);
1452
+ useEffect(() => {
1453
+ if (!createDraft.isSuccess() || !createDraft.serverData.ok || state.pageName === null) return;
1454
+ const { draftId, page } = createDraft.serverData;
1455
+ if (tabs) tabs.open({ kind: state.kind, page: state.pageName, draftId }, page);
1456
+ else session.openPage(state.pageName, draftId, page, state.kind);
1457
+ }, [createDraft]);
1458
+ useEffect(() => {
1459
+ if (createDraft.isSuccess() && !createDraft.serverData.ok) notices?.show('refuse', 'Could not make a draft of this revision');
1460
+ }, [createDraft]);
1461
+ useEffect(() => {
1462
+ const asked = publishingRef.current;
1463
+ if (!asked || publish.isLoading()) return;
1464
+ publishingRef.current = null;
1465
+ if (publish.isSuccess() && publish.serverData.ok) {
1466
+ const { live } = publish.serverData;
1467
+ const held = hub ? hub.keys().map((key) => hub.peek(key)).filter((s): s is NonNullable<typeof s> => s !== null) : [session];
1468
+ for (const s of held) {
1469
+ const st = s.snapshot();
1470
+ if (st.kind === asked.kind && st.pageName === asked.name && st.draftId === asked.draftId && st.doc) s.openRevision(asked.name, live, st.doc, asked.kind);
1471
+ }
1472
+ notices?.show('publish', `Published as r${live}`);
1473
+ setPublishAsk(false);
1474
+ } else {
1475
+ const code = publish.isError() ? publish.serverError.code : publish.isSuccess() && !publish.serverData.ok ? publish.serverData.code : 'unknown';
1476
+ const issues = publish.isSuccess() && !publish.serverData.ok ? publish.serverData.issues ?? [] : [];
1477
+ setPublishError([code, ...issues].join('\n'));
1478
+ }
1479
+ }, [publish]);
1480
+ // the standing controls of the page's blocks, a button each in the bar; one card open at a time
1481
+ const { chromes, rootOf } = useChromeNodes(state.doc);
1482
+ const [openChrome, setOpenChrome] = useState<string | null>(null);
1483
+ const openChromeNode = chromes.find((node) => node.id === openChrome) ?? null;
1484
+ // the workbench's tools (ADR-0062): none inside a preview of the workbench itself
1485
+ const editorTools = useDiOptional(EDITOR_TOOLS_TOKEN);
1486
+ const tools = preview ? [] : editorTools?.list() ?? [];
1487
+ // the tool open over this document, and what of it is open, live in the workbench's address
1488
+ // (for the document in front): a reload, a shared link, a tab coming back open the same
1489
+ const toolDesk = useDiOptional(WORKSPACE_TOKEN);
1490
+ const toolTab = dockTab?.id ?? null;
1491
+ const remembered = toolDesk && toolTab ? toolDesk.toolOf(toolTab) : null;
1492
+ const [openTool, setOpenTool] = useState<string | null>(() => (preview ? null : remembered?.tool ?? null));
1493
+ const toolState = useRef<string | null>(remembered?.state ?? null);
1494
+ useEffect(() => {
1495
+ if (toolDesk && toolTab) toolDesk.setTool(toolTab, openTool ? { tool: openTool, state: toolState.current } : null);
1496
+ }, [toolDesk, toolTab, openTool]);
1497
+ const openToolNode = tools.find((node) => node.id === openTool) ?? null;
1498
+ const [pageSettingsFor, setPageSettingsFor] = useState<string | null>(null);
1499
+ const pageSettingsOpen = pageSettingsFor === docKey && selectedId === state.doc?.id;
1500
+ // A block's settings wait behind the button in its size chip: open beside the block they cover
1501
+ // the page one looks at to decide where the next block goes. Any change of the selection turns
1502
+ // them off — reset during the render that sees the change, so the next block never shows them for
1503
+ // a frame; closing them is not stepping out of the selection either
1504
+ const [settingsShown, setSettingsShown] = useState(false);
1505
+ const [settingsSelection, setSettingsSelection] = useState(selectedId);
1506
+ if (settingsSelection !== selectedId) {
1507
+ setSettingsSelection(selectedId);
1508
+ setSettingsShown(false);
1509
+ }
1510
+ useEffect(() => {
1511
+ if (selectedId !== state.doc?.id) setPageSettingsFor(null);
1512
+ }, [selectedId, state.doc?.id]);
1513
+ // a prefab has no route and no head of its own: its root's settings go nowhere. The button
1514
+ // toggles: open settings fold on a second press, and fold with any click away (the selection
1515
+ // leaves the root), like every card of the inspector
1516
+ const openPageSettings = state.doc && state.kind !== 'prefabs'
1517
+ ? () => {
1518
+ if (pageSettingsOpen) {
1519
+ setPageSettingsFor(null);
1520
+ return;
1521
+ }
1522
+ session.selectNode(state.doc!.id);
1523
+ setPageSettingsFor(docKey);
1524
+ }
1525
+ : null;
1526
+ // a prefab's own block selected: the block as its prefab holds it, under the id the page knows it by
1527
+ const selectedPart = state.doc && isPartId(selectedId) ? prefabPartOf(state.doc, selectedId, project.treeOf) : null;
1528
+ const selectedNode = state.doc && selectedId
1529
+ ? findNodeDoc(state.doc, selectedId) ?? (selectedPart ? { ...selectedPart.node, id: selectedId } : null)
1530
+ : null;
1531
+ // …and naming a place for a block is its own mode: the insert popover takes the floor. A block
1532
+ // the preview has not measured (hidden, not rendered) has no chip to open its settings from —
1533
+ // its settings are the one way to it, so they show
1534
+ const isRootSelected = selectedNode !== null && selectedNode.id === state.doc?.id;
1535
+ const settingsAsked = isRootSelected ? pageSettingsOpen : settingsShown || selectedBox === undefined;
1536
+ const showSettings = !preview && panelCount === 0 && selectedNode !== null && inspecting && !state.insertionPoint
1537
+ && state.multiSelectedIds.length === 0 && !state.movingNodeId && settingsAsked;
1538
+ const stageSize = viewport ?? { width: stageRef.current?.clientWidth ?? 800, height: stageRef.current?.clientHeight ?? 600 };
1539
+ // a block the preview has not measured (hidden, not yet rendered) still has settings: docked
1540
+ // the page's settings open in the stage's top-right corner; any other block's beside the block
1541
+ const settingsPlacement = pageSettingsOpen
1542
+ ? (() => {
1543
+ const width = Math.min(popoverSize.width, stageSize.width - 16);
1544
+ const y = 8;
1545
+ return { x: Math.max(8, stageSize.width - width - 8), y, width, maxHeight: Math.max(160, stageSize.height - y - 8), fromBottom: false, side: 'below' as const };
1546
+ })()
1547
+ : placePopover(selectedBox ?? null, stageSize, {
1548
+ top: 8,
1549
+ width: popoverContent.wide ? Math.max(popoverSize.width, wideFieldWidth(stageRef.current, stageSize.width)) : popoverSize.width,
1550
+ contentHeight: popoverContent.height,
1551
+ // laid AGAINST the block — over its frame's 2px, so the two join into one piece (seamOf) —
1552
+ // and above it clear of the corner tags, which stay where they are
1553
+ gap: -2,
1554
+ gapAbove: TAG_HEIGHT,
1555
+ });
1556
+ // where the popover joins the selection's frame: no border of its own there
1557
+ const settingsSeam = !pageSettingsOpen && selectedBox ? seamOf(settingsPlacement, selectedBox) : null;
1558
+ // the card steps out of the way while the page scrolls (it stands where the block no longer is)
1559
+ // and while its block is out of sight — it comes back where the block is once both are over.
1560
+ // Hidden, not closed: the form keeps its focus, its open picker, what is being typed
1561
+ const settingsAway = scrolling || (!pageSettingsOpen && selectedBox !== undefined && outOfSight(selectedBox, stageSize));
1562
+ const startsRegion = (id: string): boolean => {
1563
+ const parent = state.doc ? parentOfNode(state.doc, id) : null;
1564
+ return !parent || !state.multiSelectedIds.includes(parent.id);
1565
+ };
1566
+ const hoverCtrl = hoveredCtrl || ctrlHeld;
1567
+ const point = state.insertionPoint;
1568
+ // A picked place takes its ghost off the screen, and an unpicked one puts it back — neither
1569
+ // sends the pointer anywhere, so a hover from before would survive both and keep drawing a
1570
+ // frame nobody is pointing at. It looked exactly like a place that would not let go.
1571
+ useEffect(() => setHoveredAdd(null), [point]);
1572
+ const isChosen = (at: IInsertionPoint) => point?.parentId === at.parentId && point.slot === at.slot && point.index === at.index;
1573
+ // pressing a ghost NAMES the place, and the insert popover opens beside it with the blocks that
1574
+ // can go there. Pressing the chosen one again takes the choice back.
1575
+ const pickPlace = (at: IInsertionPoint) => {
1576
+ session.setInsertionPoint(isChosen(at) ? null : at);
1577
+ };
1578
+ // the room the agent made where the block will land (LANDING_NODE_ID) — a child the document
1579
+ // does not have, so the slot it stands in counts it off again
1580
+ const landingBox = point ? byId.get(LANDING_NODE_ID) : undefined;
1581
+ const liveSlots = useMemo(() => (point && landingBox
1582
+ ? slots.map((slot) => (slot.ownerId === point.parentId && slot.slot === point.slot ? { ...slot, filled: Math.max(0, slot.filled - 1) } : slot))
1583
+ : slots), [slots, point, landingBox]);
1584
+ // what a place lights up on screen: the container it joins — the page's own places light nothing
1585
+ const rectOf = (add: IAddPoint): IRect | null => {
1586
+ if (add.target.of !== 'container') return null;
1587
+ const slot = liveSlots.find((s) => s.ownerId === add.at.parentId && s.slot === add.at.slot);
1588
+ return slot ? drawableArea(slot) : null;
1589
+ };
1590
+ const nameOf = (nodeId: string) => {
1591
+ const box = byId.get(nodeId);
1592
+ return box ? shortName(box.reference) : nodeId;
1593
+ };
1594
+ /**
1595
+ * Where a block may go RIGHT NOW, as ghosts. They follow the selection instead of covering the
1596
+ * page:
1597
+ *
1598
+ * - a selected block offers the places next to it, in its parent's slot — before and after it,
1599
+ * along the way its neighbours run (axisAmong); the pointer's side decides which is shown;
1600
+ * - …and the inside of each EMPTY slot it opens. A slot that holds blocks already is reached
1601
+ * through them: select its first or last block and add next to it;
1602
+ * - with nothing selected — or the page root selected, which is the same thing — the page's own
1603
+ * ends: before the first block and after the last (the pointer's side again), or a single ghost
1604
+ * on an empty page.
1605
+ *
1606
+ * The page root is a special case throughout: its children are rendered by the root component
1607
+ * itself, not through `useChildren`, so no marker is ever planted for it and its geometry comes
1608
+ * from the children's own boxes.
1609
+ */
1610
+ const ghosts = useMemo((): IGhost[] => {
1611
+ const doc = state.doc;
1612
+ if (!doc) return [];
1613
+ if (state.revision != null) return []; // a revision takes no blocks — no places to offer
1614
+ const pointAt = (
1615
+ parentId: string,
1616
+ slotName: string,
1617
+ index: number,
1618
+ title: string,
1619
+ testId: IAddPoint['testId'],
1620
+ target: TAddTarget = { of: 'container' },
1621
+ ): IAddPoint => ({
1622
+ key: `${parentId}:${slotName}:${index}`,
1623
+ title,
1624
+ testId,
1625
+ // the title travels WITH the pick: whoever answers it (the palette) repeats the same
1626
+ // relative words the ghost spoke, instead of translating the place back into node ids
1627
+ at: { parentId, slot: slotName, index, label: title },
1628
+ target,
1629
+ });
1630
+ const roomIn = (parentId: string, slotName: string) => {
1631
+ const declared = liveSlots.find((s) => s.ownerId === parentId && s.slot === slotName);
1632
+ return !declared || hasRoom(declared); // the root's slot is never declared — assume room
1633
+ };
1634
+ const capitalized = (title: string) => `${title.charAt(0).toUpperCase()}${title.slice(1)}`;
1635
+
1636
+ if (!selectedId || selectedId === doc.id) {
1637
+ const children = doc.children ?? [];
1638
+ const drawn = children.map((child) => byId.get(child.id)).filter((box) => box !== undefined);
1639
+ if (!drawn.length) {
1640
+ // nothing on the page yet: one ghost at the top of it
1641
+ const first = pointAt(doc.id, DEFAULT_SLOT, 0, 'add the first block', 'inspector-add-child-after-button', { of: 'page' });
1642
+ return [{ point: first, place: 'free', at: { x: 8, y: 8 }, words: capitalized(first.title), side: 'inside' }];
1643
+ }
1644
+ // the page's own ends: a tag on the top edge of its first block, one hanging from its last
1645
+ const first = drawn[0];
1646
+ const last = drawn[drawn.length - 1];
1647
+ const start = pointAt(doc.id, DEFAULT_SLOT, 0, `add above ${nameOf(children[0].id)}`, 'inspector-add-child-before-button', { of: 'page' });
1648
+ const end = pointAt(doc.id, DEFAULT_SLOT, children.length, `add below ${nameOf(children[children.length - 1].id)}`, 'inspector-add-child-after-button', { of: 'page' });
1649
+ return [
1650
+ { point: start, place: 'free', at: { x: first.x, y: Math.max(0, first.y - TAG_HEIGHT) }, words: capitalized(start.title), side: 'start' },
1651
+ { point: end, place: 'free', at: { x: last.x, y: last.y + last.height }, words: capitalized(end.title), side: 'end' },
1652
+ ];
1653
+ }
1654
+
1655
+ const found: IGhost[] = [];
1656
+ // inside the selected block: a tag in each EMPTY slot, in the top-left corner of its container
1657
+ // (where its resting outline is) — or at its marker, the very point a block would land at
1658
+ for (const slot of liveSlots.filter((s) => s.ownerId === selectedId && hasRoom(s) && s.filled === 0)) {
1659
+ const area = drawableArea(slot);
1660
+ const inside = slotLabel(nameOf(slot.ownerId), slot.slot); // the same name the frame wears
1661
+ const into = pointAt(slot.ownerId, slot.slot, 0, `add into ${inside}`, 'inspector-add-child-after-button');
1662
+ found.push({ point: into, place: 'free', at: area ? { x: area.x, y: area.y } : { x: slot.x, y: slot.y }, words: capitalized(into.title), side: 'inside' });
1663
+ }
1664
+
1665
+ // …and next to it, glued to its frame: the parent's slot, at the block's own position and just
1666
+ // after it, on the edges its neighbours say (one under another — top and bottom; side by side —
1667
+ // left and right)
1668
+ const parent = parentOfNode(doc, selectedId);
1669
+ const box = byId.get(selectedId);
1670
+ if (parent && box) {
1671
+ const slotName = findNodeDoc(doc, selectedId)?.slot ?? DEFAULT_SLOT;
1672
+ const siblings = (parent.children ?? []).filter((child) => (child.slot ?? DEFAULT_SLOT) === slotName);
1673
+ const index = siblings.findIndex((child) => child.id === selectedId);
1674
+ if (index >= 0 && roomIn(parent.id, slotName)) {
1675
+ const row = axisAmong(siblings.map((child) => byId.get(child.id)), index) === 'row';
1676
+ const target: TAddTarget = parent.id === doc.id ? { of: 'page' } : { of: 'container' };
1677
+ const name = nameOf(selectedId);
1678
+ const before = pointAt(parent.id, slotName, index, `add before ${name}`, 'inspector-add-before-button', target);
1679
+ const after = pointAt(parent.id, slotName, index + 1, `add after ${name}`, 'inspector-add-after-button', target);
1680
+ found.push({ point: before, place: row ? 'left' : 'top', at: null, words: row ? capitalized(before.title) : 'Add above', side: 'start' });
1681
+ found.push({ point: after, place: row ? 'right' : 'bottom', at: null, words: row ? capitalized(after.title) : 'Add below', side: 'end' });
1682
+ }
1683
+ }
1684
+ return found;
1685
+ }, [state.doc, state.revision, selectedId, liveSlots, byId]);
1686
+
1687
+ // what the pointer's side is read against: the selected block — or the page, its blocks' span
1688
+ const sideGhost = ghosts.find((ghost) => ghost.side !== 'inside');
1689
+ const pageBlocks = !selectedId || selectedId === state.doc?.id
1690
+ ? (state.doc?.children ?? []).map((child) => byId.get(child.id)).filter((box) => box !== undefined)
1691
+ : [];
1692
+ sideRef.current = {
1693
+ rect: selectedId && selectedId !== state.doc?.id
1694
+ ? (selectedBox ?? null)
1695
+ : pageBlocks.length
1696
+ ? { x: 0, y: pageBlocks[0].y, width: viewport?.width ?? 0, height: pageBlocks[pageBlocks.length - 1].y + pageBlocks[pageBlocks.length - 1].height - pageBlocks[0].y }
1697
+ : null,
1698
+ axis: sideGhost?.place === 'left' || sideGhost?.place === 'right' ? 'row' : 'column',
1699
+ };
1700
+ // on screen: what is inside always, of the two sides only the pointer's — the glued one goes
1701
+ // onto the selection's frame, the free ones stand on their own
1702
+ const shownGhosts = ghosts.filter((ghost) => ghost.side === 'inside' || (ghost.side === 'end') === pointerAtEnd);
1703
+ const gluedGhost = shownGhosts.find((ghost) => ghost.place !== 'free');
1704
+ // the popover lying under the block takes the bottom edge — the ghost below rides on the seam,
1705
+ // in the popover's head, still right under the block
1706
+ const ghostOnSeam = showSettings && !settingsLoading && settingsSeam?.edge === 'top' && gluedGhost?.place === 'bottom' && pickedBoxes.length === 0 ? gluedGhost : undefined;
1707
+ const freeGhosts = shownGhosts.filter((ghost) => ghost.place === 'free');
1708
+
1709
+ // the picked place is found among ALL the ghosts, both sides — the pointer wandering to the other
1710
+ // half while the picker is open must not lose the place it named
1711
+ const pickedGhost = point ? ghosts.find((ghost) => isChosen(ghost.point.at)) : undefined;
1712
+ const pickedAdd = pickedGhost?.point;
1713
+ // a hover only counts while its ghost is still on screen: pressing one takes it away without a
1714
+ // pointer-leave, and a hover left behind would freeze a frame on the overlay
1715
+ const hovered = hoveredAdd && shownGhosts.some((ghost) => ghost.point.key === hoveredAdd.key) ? hoveredAdd : null;
1716
+ const marked = pickedAdd ?? (point ? null : hovered);
1717
+ const markedRect = marked ? rectOf(marked) : null;
1718
+ // the insert popover stands beside the place: the room made for the block once the preview has
1719
+ // made it — until then the block whose ghost was pressed, or the free tag itself
1720
+ const insertPlacement = (() => {
1721
+ if (!point) return null;
1722
+ const anchor = landingBox
1723
+ ?? (pickedGhost?.place !== 'free' ? selectedBox : undefined)
1724
+ ?? (pickedGhost?.at ? { ...pickedGhost.at, width: INSERT_POPOVER_WIDTH / 2, height: TAG_HEIGHT } : null);
1725
+ return placePopover(anchor, stageSize, { top: 44, width: INSERT_POPOVER_WIDTH, minHeight: 260 });
1726
+ })();
1727
+
1728
+ // every place the SELECTED block offers, filled ones included: "what can go inside this block"
1729
+ // is the question a selection asks, and a slot with children is still a place
1730
+ const restingSlots = marked ? [] : liveSlots
1731
+ .filter((slot) => slot.ownerId === selectedId)
1732
+ .map((slot) => ({ slot, area: drawableArea(slot) }))
1733
+ .filter((entry): entry is { slot: ISlotBox; area: NonNullable<ISlotBox['area']> } => entry.area !== null);
1734
+
1735
+ // inside a preview realm the canvas must NOT spawn another preview (iframe-in-iframe forever
1736
+ // when the workbench page previews itself)
1737
+ if (preview) {
1738
+ return <div className="canvas-view canvas-view__empty"
1739
+ data-canvas-preview-stub>A preview inside a preview does not expand</div>;
1740
+ }
1741
+ if (!state.doc) {
1742
+ return <div className="canvas-view canvas-view__empty"
1743
+ data-canvas-empty>The preview of the opened page will appear here</div>;
1744
+ }
1745
+
1746
+ return (
1747
+ <TooltipHost>
1748
+ {/* dock-panel-flush: opt-in to the dock's full-bleed panel contract (no padding around the iframe) */}
1749
+ <div className={`canvas-view dock-panel-flush${fullscreen ? ' canvas-view--fullscreen' : ''}`}
1750
+ data-canvas={state.pageName ?? undefined}
1751
+ data-fullscreen={fullscreen || undefined}
1752
+ /* a pointerDOWN inside this canvas is deliberate — the focus stays; a mere hover only
1753
+ BORROWS it (markHovered), and the leave returns a still-borrowed focus to the
1754
+ selection anchor (markLeft), taking the pale tree highlight along */
1755
+ onPointerDownCapture={markInteractive}
1756
+ onPointerEnter={markHovered}
1757
+ onPointerLeave={() => {
1758
+ session.hoverNode(null);
1759
+ markLeft();
1760
+ }}>
1761
+ {/* the BUTTON shows the toggle's own state, not the full-screen override — leaving full
1762
+ screen puts the editor back exactly as it was found */}
1763
+ <ActionBar inspecting={state.inspecting}
1764
+ chromes={chromes}
1765
+ openChrome={openChrome}
1766
+ onChrome={(id) => setOpenChrome((open) => (open === id ? null : id))}
1767
+ tools={tools.map((node) => ({
1768
+ id: node.id,
1769
+ label: toolLabelOf(node),
1770
+ iconUrl: upgrades.components.find((c) => c.id === node.reference)?.iconUrl,
1771
+ }))}
1772
+ openTool={openToolNode ? openToolNode.id : null}
1773
+ onTool={(id) => {
1774
+ // another tool, or none: what was open of the last one is not its
1775
+ toolState.current = null;
1776
+ setOpenTool((open) => (open === id ? null : id));
1777
+ }}
1778
+ pageSettingsOpen={pageSettingsOpen}
1779
+ onPageSettings={openPageSettings}
1780
+ version={state.revision != null
1781
+ ? { kind: 'revision', rev: state.revision }
1782
+ : state.draftId !== null
1783
+ ? { kind: 'draft', name: (state.doc as { title?: string } | null)?.title ?? state.draftId.slice(0, 8), saving: state.dirty }
1784
+ : null}
1785
+ updates={state.revision == null && state.doc !== null && upgrades.outdated.length ? {
1786
+ count: upgrades.outdated.length,
1787
+ onOpen: () => setUpdatesOpen(true),
1788
+ } : null}
1789
+ newDraft={state.revision != null && state.pageName !== null && !pageLocked ? {
1790
+ busy: createDraft.isLoading(),
1791
+ onNewDraft: () => createDraft.start!({ name: state.pageName!, fromRev: state.revision!, kind: state.kind }),
1792
+ } : null}
1793
+ canCut={state.revision == null && session.copySelection().length > 0}
1794
+ canCopy={session.copySelection().length > 0}
1795
+ canPaste={state.revision == null && state.doc !== null
1796
+ && typeof navigator !== 'undefined' && Boolean(navigator.clipboard?.readText)}
1797
+ canUndo={state.revision == null && (state.doc?.history?.length ?? 0) > 0}
1798
+ canRedo={state.revision == null && (state.doc?.redo?.length ?? 0) > 0}
1799
+ previewHref={previewHref}
1800
+ publish={state.draftId !== null && state.pageName !== null ? {
1801
+ busy: publishingRef.current !== null && publish.isLoading(),
1802
+ dirty: state.dirty,
1803
+ onPublish: () => {
1804
+ setPublishError(null);
1805
+ setPublishAsk(true);
1806
+ },
1807
+ } : null}
1808
+ onShare={() => {
1809
+ // the WINDOW's clipboard: this is browser code, and a test realm's bare
1810
+ // `navigator` is the host's, not the document's
1811
+ const clipboard = typeof window === 'undefined' ? undefined : window.navigator.clipboard;
1812
+ if (!clipboard?.writeText) {
1813
+ notices?.show('link', 'Copying is not available here');
1814
+ return;
1815
+ }
1816
+ clipboard.writeText(previewHref)
1817
+ .then(() => notices?.show('link', 'Preview link copied'))
1818
+ .catch(() => notices?.show('link', 'Could not copy the link'));
1819
+ }}
1820
+ onRestart={() => setRestarts((count) => count + 1)}
1821
+ onFullscreen={() => setFullscreen(true)}
1822
+ onInspecting={(on) => session.setInspecting(on)}
1823
+ onCut={() => {
1824
+ const blocks = session.copySelection();
1825
+ if (!blocks.length) return;
1826
+ void navigator.clipboard?.writeText(serializeBlocks(blocks));
1827
+ session.removeSelection();
1828
+ notices?.show('cut', blocksLabel('Cut', blocks.length));
1829
+ }}
1830
+ onCopy={() => {
1831
+ const blocks = session.copySelection();
1832
+ if (!blocks.length) return;
1833
+ void navigator.clipboard?.writeText(serializeBlocks(blocks));
1834
+ notices?.show('copy', blocksLabel('Copied', blocks.length));
1835
+ }}
1836
+ onPaste={() => {
1837
+ void navigator.clipboard?.readText().then((text) => {
1838
+ const blocks = parseBlocks(text);
1839
+ if (!blocks) return;
1840
+ session.pasteNodes(blocks);
1841
+ notices?.show('paste', blocksLabel('Pasted', blocks.length));
1842
+ }).catch(() => undefined); // permission refused → nothing to paste
1843
+ }}
1844
+ onUndo={() => {
1845
+ session.undo(); // the button is disabled on an empty stack — the step happened
1846
+ notices?.show('undo', 'Undone');
1847
+ }}
1848
+ onRedo={() => {
1849
+ session.redo();
1850
+ notices?.show('redo', 'Redone');
1851
+ }} />
1852
+ <div className="canvas-view__stage"
1853
+ ref={stageRef}>
1854
+ {fullscreen ? (
1855
+ <button type="button"
1856
+ className="canvas-view__close"
1857
+ data-test-id="canvas-exit-fullscreen-button"
1858
+ data-action="exit-fullscreen"
1859
+ data-tooltip="Leave full screen"
1860
+ aria-label="Leave full screen"
1861
+ onClick={() => setFullscreen(false)}>
1862
+ <svg viewBox="0 0 16 16"
1863
+ aria-hidden="true">
1864
+ <path d="M4.5 4.5l7 7M11.5 4.5l-7 7"
1865
+ stroke="currentColor"
1866
+ strokeWidth="1.8" />
1867
+ </svg>
1868
+ </button>
1869
+ ) : null}
1870
+ <iframe key={frameKey}
1871
+ ref={frameRef}
1872
+ className="canvas-view__frame"
1873
+ title={state.revision != null
1874
+ ? `preview: ${tokenEntity} r${state.revision}`
1875
+ : `preview: ${tokenEntity} draft ${state.draftId}`}
1876
+ data-preview-src={src}
1877
+ /* which build of the frame this is: bumped only when the preview cannot be
1878
+ updated in place, so a save that changes nothing structural leaves it alone */
1879
+ data-frame-build={rebuilds}
1880
+ src={src} />
1881
+ <div className="canvas-view__overlay"
1882
+ data-test-id="inspector"
1883
+ data-overlay-connected={connected || undefined}
1884
+ data-overlay-boxes={boxes.length}
1885
+ data-overlay-slots={slots.length}>
1886
+ <div className="canvas-view__layer"
1887
+ ref={overlayRef}>
1888
+ {/* inspector off: the page is the visitor's for a moment, and nothing is drawn on it */}
1889
+ {inspecting && marked && marked.target.of === 'container' ? (
1890
+ /* picked: everything else steps back, so the green frame on screen answers "which
1891
+ block does it join" — and the palette wears the same frame. Hovered: the same
1892
+ frame, shown before the click rather than after it. A place of the page itself
1893
+ lights nothing: the page is no container, its ghost is the answer */
1894
+ <TargetFrame rect={markedRect}
1895
+ area={`${marked.at.parentId}:${marked.at.slot}`}
1896
+ label={slotLabel(nameOf(marked.at.parentId), marked.at.slot)} />
1897
+ ) : null}
1898
+ {/* …and the very spot, once the preview has made room for it */}
1899
+ {inspecting && point && landingBox ? <LandingFrame box={landingBox} /> : null}
1900
+ {inspecting && !point ? (
1901
+ <>
1902
+ {restingSlots.map(({ slot, area }) => (
1903
+ <SlotArea key={`area:${slot.ownerId}:${slot.slot}`}
1904
+ slot={slot}
1905
+ area={area} />
1906
+ ))}
1907
+ {/* the slot AS the selection (picked in the tree): its container wears the green
1908
+ child box — the same frame an insert lights, because that is what it means */}
1909
+ {(() => {
1910
+ const sel = state.selectedSlot;
1911
+ const declared = sel
1912
+ ? slots.find((s) => s.ownerId === sel.parentId && s.slot === sel.slot)
1913
+ : undefined;
1914
+ if (!sel || !declared) return null;
1915
+ return <TargetFrame rect={drawableArea(declared)}
1916
+ area={`${sel.parentId}:${sel.slot}`}
1917
+ label={slotLabel(nameOf(sel.parentId), sel.slot)} />;
1918
+ })()}
1919
+ {hoveredBox ? <Outline box={hoveredBox}
1920
+ kind={hoverCtrl ? 'hover-pick' : 'hover'} /> : null}
1921
+ {/* every picked element is shown; the region's first element wears the thick frame */}
1922
+ {pickedBoxes.map((box) => (
1923
+ <Outline key={`pick:${box.id}`}
1924
+ box={box}
1925
+ kind={startsRegion(box.id) ? 'pick' : 'pick-covered'} />
1926
+ ))}
1927
+ {selectedBox ? (
1928
+ <Outline box={selectedBox}
1929
+ kind="select"
1930
+ size
1931
+ within={stageSize}
1932
+ label={shortName(selectedBox.reference)}
1933
+ onSettings={selectedBox.id === state.doc.id || selectedNode === null
1934
+ ? undefined // the page's settings live behind the bar's button; a library prefab's block has none to show
1935
+ : () => setSettingsShown((shown) => !shown)}
1936
+ settingsOpen={settingsShown}
1937
+ settingsLoading={showSettings && settingsLoading}
1938
+ // the ghosts belong to the insert flow — the yellow mode holds the floor
1939
+ ghost={gluedGhost && gluedGhost.place !== 'free' && !ghostOnSeam && pickedBoxes.length === 0 ? {
1940
+ place: gluedGhost.place,
1941
+ node: <GhostTab ghost={gluedGhost}
1942
+ height={Math.min(selectedBox.height, SIDE_TAB_MAX_HEIGHT)}
1943
+ onPick={pickPlace}
1944
+ onHover={setHoveredAdd} />,
1945
+ } : undefined}
1946
+ onRemove={selectedBox.id === state.doc.id || state.revision != null || isPartId(selectedBox.id)
1947
+ ? undefined // no page without its root; no removals in a read-only view; a prefab's block is the prefab's
1948
+ : () => session.removeNode(selectedBox.id)} />
1949
+ ) : null}
1950
+ {pickedBoxes.length === 0 && freeGhosts.map((ghost) => (
1951
+ <GhostTab key={ghost.point.key}
1952
+ ghost={ghost}
1953
+ onPick={pickPlace}
1954
+ onHover={setHoveredAdd} />
1955
+ ))}
1956
+ </>
1957
+ ) : null}
1958
+ </div>
1959
+ </div>
1960
+ {!preview && !fullscreen && openChromeNode ? <ChromeCard node={openChromeNode}
1961
+ rootReference={rootOf(openChromeNode)} /> : null}
1962
+ {!fullscreen && openToolNode ? <ToolPanel key={openToolNode.id}
1963
+ node={openToolNode}
1964
+ instance={bridgeId}
1965
+ session={session}
1966
+ previewHref={previewHref}
1967
+ frame={frameRef}
1968
+ state={{
1969
+ read: () => toolState.current,
1970
+ write: (next) => {
1971
+ toolState.current = next;
1972
+ if (toolDesk && toolTab) toolDesk.setTool(toolTab, { tool: openToolNode.id, state: next });
1973
+ },
1974
+ }}
1975
+ onClose={() => {
1976
+ toolState.current = null;
1977
+ setOpenTool(null);
1978
+ }} /> : null}
1979
+ {point && insertPlacement && inspecting && !fullscreen ? (
1980
+ <InsertPopover session={session}
1981
+ point={point}
1982
+ placement={insertPlacement} />
1983
+ ) : null}
1984
+ {updatesOpen && upgradeGroupsOfPage.length && typeof document !== 'undefined' ? (
1985
+ <UpdatesModal host={document}
1986
+ groups={upgradeGroupsOfPage}
1987
+ busy={updating}
1988
+ onCancel={() => setUpdatesOpen(false)}
1989
+ onConfirm={(selected) => void runUpdates(selected)} />
1990
+ ) : null}
1991
+ {publishAsk && state.draftId !== null && state.pageName !== null && typeof document !== 'undefined' ? (
1992
+ <PublishModal host={document}
1993
+ noun={state.kind === 'prefabs' ? 'prefab' : 'page'}
1994
+ route={state.kind === 'prefabs' ? state.pageName : pattern}
1995
+ suggested={(state.doc as { title?: string } | null)?.title ?? state.draftId.slice(0, 8)}
1996
+ live={liveOfPage}
1997
+ publishing={publishingRef.current !== null && publish.isLoading()}
1998
+ error={publishError}
1999
+ onCancel={() => setPublishAsk(false)}
2000
+ onConfirm={(title) => {
2001
+ if (state.draftId === null || state.pageName === null) return;
2002
+ setPublishError(null);
2003
+ publishingRef.current = { name: state.pageName, draftId: state.draftId, kind: state.kind };
2004
+ publish.start!({ name: state.pageName, draftId: state.draftId, kind: state.kind, title });
2005
+ }} />
2006
+ ) : null}
2007
+ {showSettings && selectedNode ? (
2008
+ <SettingsPopover session={session}
2009
+ state={state}
2010
+ hidden={settingsAway}
2011
+ placement={settingsPlacement}
2012
+ size={popoverSize}
2013
+ onResize={resizePopover}
2014
+ onContent={tellPopoverContent}
2015
+ onLoading={tellSettingsLoading}
2016
+ // putting the settings away keeps the selection: they were in the
2017
+ // way, the block was not
2018
+ onClose={isRootSelected ? () => setPageSettingsFor(null) : () => setSettingsShown(false)}
2019
+ seam={settingsSeam}
2020
+ seamGhost={ghostOnSeam ? <GhostTab ghost={ghostOnSeam}
2021
+ onPick={pickPlace}
2022
+ onHover={setHoveredAdd} /> : null}
2023
+ stage={stageSize}
2024
+ reference={selectedNode.reference}
2025
+ placed={upgrades.versions.placedAt(selectedNode.reference)} />
2026
+ ) : null}
2027
+ </div>
2028
+ </div>
2029
+ </TooltipHost>
2030
+ );
2031
+ }
2032
+
2033
+ export default markInternalTile(defineComponentSide<ICanvasSettings>({
2034
+ Component: Canvas,
2035
+ defaultSettings() {
2036
+ return {};
2037
+ },
2038
+ }));