@mosetta/editor 0.0.0-stage → 0.0.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/CHANGELOG.md +185 -0
  2. package/LICENSE +21 -0
  3. package/README.md +10 -2
  4. package/mosetta.json +6 -0
  5. package/package.json +35 -3
  6. package/prefabs/workbench/prefab.json +11 -0
  7. package/prefabs/workbench/versions/1.json +120 -0
  8. package/prefabs/workbench/versions/2.json +13 -0
  9. package/prefabs/workbench/versions/3.json +19 -0
  10. package/prefabs/workbench/versions/4.json +29 -0
  11. package/prefabs/workbench/versions/5.json +29 -0
  12. package/tiles/canvas/canvas.component.tsx +2038 -0
  13. package/tiles/canvas/canvas.css +973 -0
  14. package/tiles/canvas/canvasTools.ts +66 -0
  15. package/tiles/canvas/chromeNodes.ts +35 -0
  16. package/tiles/canvas/ghosts.ts +33 -0
  17. package/tiles/canvas/insertPopover.tsx +56 -0
  18. package/tiles/canvas/popoverPlacement.ts +104 -0
  19. package/tiles/canvas/popoverSize.ts +87 -0
  20. package/tiles/canvas/providerChrome.tsx +191 -0
  21. package/tiles/canvas/scrollWatch.ts +43 -0
  22. package/tiles/canvas/settingsPopover.tsx +257 -0
  23. package/tiles/canvas/settingsTabs.ts +78 -0
  24. package/tiles/canvas/toolPanel.tsx +86 -0
  25. package/tiles/content/autoBind.ts +61 -0
  26. package/tiles/content/blockPage.css +390 -0
  27. package/tiles/content/blockPage.tsx +379 -0
  28. package/tiles/content/blockPicker.css +215 -0
  29. package/tiles/content/blockPicker.tsx +501 -0
  30. package/tiles/content/blockUpgrades.ts +145 -0
  31. package/tiles/content/blockVariants.ts +185 -0
  32. package/tiles/content/blockVocabulary.ts +77 -0
  33. package/tiles/content/catalogVersions.ts +106 -0
  34. package/tiles/content/componentPreview.css +82 -0
  35. package/tiles/content/componentPreview.tsx +249 -0
  36. package/tiles/content/content.server.ts +593 -0
  37. package/tiles/content/contentCommon.ts +133 -0
  38. package/tiles/content/disconnected.ts +56 -0
  39. package/tiles/content/distributions.css +256 -0
  40. package/tiles/content/distributions.tsx +271 -0
  41. package/tiles/content/editingAutosave.ts +125 -0
  42. package/tiles/content/editingClipboard.ts +185 -0
  43. package/tiles/content/editingConflicts.ts +223 -0
  44. package/tiles/content/editingHotkeys.ts +59 -0
  45. package/tiles/content/editingNotices.ts +105 -0
  46. package/tiles/content/formRoot.ts +18 -0
  47. package/tiles/content/fuzzySearch.ts +121 -0
  48. package/tiles/content/mediaUpload.ts +55 -0
  49. package/tiles/content/moduleSiblings.ts +9 -0
  50. package/tiles/content/mountedTree.ts +136 -0
  51. package/tiles/content/nodeSettings.css +483 -0
  52. package/tiles/content/nodeSettings.tsx +963 -0
  53. package/tiles/content/pasteText.ts +131 -0
  54. package/tiles/content/prefabFields.tsx +158 -0
  55. package/tiles/content/prefabParts.ts +105 -0
  56. package/tiles/content/previewEnvironment.ts +46 -0
  57. package/tiles/content/projectConfig.ts +46 -0
  58. package/tiles/content/projectPrefabs.ts +57 -0
  59. package/tiles/content/publishModal.css +109 -0
  60. package/tiles/content/publishModal.tsx +92 -0
  61. package/tiles/content/refsEditing.ts +412 -0
  62. package/tiles/content/scaledStage.ts +63 -0
  63. package/tiles/content/searchField.css +32 -0
  64. package/tiles/content/searchField.tsx +32 -0
  65. package/tiles/content/settingsDraft.ts +178 -0
  66. package/tiles/content/settingsIssues.tsx +124 -0
  67. package/tiles/content/themeScope.ts +72 -0
  68. package/tiles/content/themeVariables.ts +47 -0
  69. package/tiles/content/tooltipPlacement.ts +17 -0
  70. package/tiles/content/tooltips.tsx +131 -0
  71. package/tiles/content/treeMounter.ts +33 -0
  72. package/tiles/content/updatesModal.css +124 -0
  73. package/tiles/content/updatesModal.tsx +110 -0
  74. package/tiles/content/useCtrlHeld.ts +24 -0
  75. package/tiles/content/useEditingSession.ts +115 -0
  76. package/tiles/content/useMountedTree.ts +47 -0
  77. package/tiles/content/workbenchScale.css +27 -0
  78. package/tiles/hostFallback/hostFallback.component.tsx +32 -0
  79. package/tiles/hostFallback/hostFallback.css +12 -0
  80. package/tiles/hostFallback/hostFallback.editor.tsx +10 -0
  81. package/tiles/page/page.component.tsx +7 -0
  82. package/tiles/page/page.script.tsx +5 -0
  83. package/tiles/pageModules/pageModules.component.tsx +740 -0
  84. package/tiles/pageModules/pageModules.css +383 -0
  85. package/tiles/pageModules/pageModules.icon.svg +1 -0
  86. package/tiles/pageModules/servedPage.ts +465 -0
  87. package/tiles/pagesPanel/pagesPanel.component.tsx +1645 -0
  88. package/tiles/pagesPanel/pagesPanel.css +713 -0
  89. package/tiles/pagesPanel/pagesPanel.editor.tsx +23 -0
  90. package/tiles/pagesPanel/pagesSearch.ts +194 -0
  91. package/tiles/pagesPanel/routeTree.ts +98 -0
  92. package/tiles/previewBridge/landing.ts +66 -0
  93. package/tiles/previewBridge/previewAgent.ts +457 -0
  94. package/tiles/previewBridge/previewBridge.component.tsx +137 -0
  95. package/tiles/previewBridge/previewBridge.editor.tsx +10 -0
  96. package/tiles/previewBridge/previewBridgeCommon.ts +172 -0
  97. package/tiles/previewBridge/standIn.ts +22 -0
  98. package/tiles/treeView/treeView.component.tsx +514 -0
  99. package/tiles/treeView/treeView.css +303 -0
  100. package/tiles/treeView/treeView.editor.tsx +12 -0
  101. package/tiles/workbench/assistant.css +401 -0
  102. package/tiles/workbench/assistant.tsx +516 -0
  103. package/tiles/workbench/assistantChat.ts +165 -0
  104. package/tiles/workbench/dictation.ts +354 -0
  105. package/tiles/workbench/projectInfo.ts +72 -0
  106. package/tiles/workbench/recent.ts +67 -0
  107. package/tiles/workbench/workbench.component.tsx +476 -0
  108. package/tiles/workbench/workbench.css +376 -0
  109. package/tiles/workbench/workbenchAddress.ts +202 -0
  110. package/tiles/workbench/workspace.ts +775 -0
  111. package/tiles/workbenchEditor/workbenchEditor.component.tsx +461 -0
  112. package/tiles/workbenchEditor/workbenchEditor.css +370 -0
  113. package/tiles/workbenchHome/workbenchHome.component.tsx +196 -0
  114. package/tiles/workbenchHome/workbenchHome.css +271 -0
  115. package/tsconfig.json +3 -0
@@ -0,0 +1,461 @@
1
+ // The EDITOR page of the workbench (ADR-0051 §4): a row of tabs in a browser's manner over the
2
+ // tree and the canvas — one page under `/workbench/{kind}/{name}`, whose address names the
3
+ // document in front and every open tab. A document coming to the front is a shallow move of the
4
+ // client router; leaving for a list is a move to another page, and the canvases go with it —
5
+ // they come back cold (the owner's call), the tabs do not: this browser tab remembers them.
6
+ import { defineComponentSide, markInternalTile } from '@mosetta/react';
7
+ import { BlueprintNodeRenderer, ProvideDiScope, useComponent, useDi, useDiOptional, useDiScope, useIsPreviewMode, useRoute } from '@mosetta/react';
8
+ import { BlockScope, DI_CONTAINER_TOKEN } from '@mosetta/di';
9
+ import { DockTab, DOCK_TAB_TOKEN } from '@mosetta/ui';
10
+ import { ROUTER_TOKEN } from '@mosetta/runtime/client';
11
+ import { ContentKind, editingSessionHubOf } from '@mosetta/content';
12
+ import type { ITileReference } from '@mosetta/core';
13
+ import { useEffect, useRef, useState, useSyncExternalStore } from 'react';
14
+ import { siblingReference } from '../content/moduleSiblings';
15
+ import { useEditingSession } from '../content/useEditingSession';
16
+ import { TooltipHost } from '../content/tooltips';
17
+ import '../content/workbenchScale.css';
18
+ import './workbenchEditor.css';
19
+ import { AddressWorkspace, IWorkspaceState, IWorkspaceTab, TREE_WIDTH_MAX, TREE_WIDTH_MIN, Workspace, WORKSPACE_TOKEN } from '../workbench/workspace';
20
+ import { basePathOf, parseEditorAddress } from '../workbench/workbenchAddress';
21
+
22
+ const PagesIcon = (
23
+ <svg viewBox="0 0 16 16"
24
+ aria-hidden="true">
25
+ <path d="M4 2.5h5.5L12.5 5.5V13.5H4z"
26
+ fill="none"
27
+ stroke="currentColor"
28
+ strokeWidth="1.4"
29
+ strokeLinejoin="round" />
30
+ <path d="M9.5 2.5v3h3M6 8.5h4M6 10.8h4"
31
+ fill="none"
32
+ stroke="currentColor"
33
+ strokeWidth="1.3" />
34
+ </svg>
35
+ );
36
+
37
+ const PrefabsIcon = (
38
+ <svg viewBox="0 0 16 16"
39
+ aria-hidden="true">
40
+ <path d="M8 2.2l5.2 3v5.6L8 13.8l-5.2-3V5.2z"
41
+ fill="none"
42
+ stroke="currentColor"
43
+ strokeWidth="1.4"
44
+ strokeLinejoin="round" />
45
+ <path d="M8 8l5.2-2.8M8 8L2.8 5.2M8 8v5.8"
46
+ fill="none"
47
+ stroke="currentColor"
48
+ strokeWidth="1.2" />
49
+ </svg>
50
+ );
51
+
52
+ const CloseIcon = (
53
+ <svg viewBox="0 0 16 16"
54
+ aria-hidden="true">
55
+ <path d="M4.5 4.5l7 7M11.5 4.5l-7 7"
56
+ stroke="currentColor"
57
+ strokeWidth="1.6"
58
+ strokeLinecap="round" />
59
+ </svg>
60
+ );
61
+
62
+ const PlusIcon = (
63
+ <svg viewBox="0 0 16 16"
64
+ aria-hidden="true">
65
+ <path d="M8 3.2v9.6M3.2 8h9.6"
66
+ fill="none"
67
+ stroke="currentColor"
68
+ strokeWidth="1.6"
69
+ strokeLinecap="round" />
70
+ </svg>
71
+ );
72
+
73
+ // ---- the tab handle --------------------------------------------------------------------------
74
+
75
+ /**
76
+ * The CURRENT TAB as the canvas sees it — the same cooperation contract a dock hands its panels,
77
+ * so the canvas keys its session by the tab and wakes when the tab comes to the front. The title
78
+ * is not the canvas's to set here: the workspace names a tab after the document its session
79
+ * holds, which says the same thing without a word from the canvas.
80
+ */
81
+ class WorkspaceTabHandle extends DockTab {
82
+ constructor(
83
+ private readonly workspace: Workspace,
84
+ readonly id: string,
85
+ ) {
86
+ super();
87
+ }
88
+
89
+ rename(): void {
90
+ // the tab's words come from its session (see workspace.tabLabelOf)
91
+ }
92
+
93
+ setMeta(): void {
94
+ // the document a tab holds is read from its session, never from a report
95
+ }
96
+
97
+ onDidActivate(listener: () => void): () => void {
98
+ return this.workspace.onDidActivate(this.id, listener);
99
+ }
100
+
101
+ front(): boolean {
102
+ return this.workspace.snapshot().front === this.id;
103
+ }
104
+ }
105
+
106
+ // ---- pieces ------------------------------------------------------------------------------------
107
+
108
+ /** a sibling module of the editor, opened by reference — the way a dock opens a component tab */
109
+ function Opened({ own, name, id, settings }: { own: ITileReference; name: string; id: string; settings?: Record<string, unknown> }) {
110
+ return (
111
+ <BlueprintNodeRenderer moduleTreeNode={{
112
+ id,
113
+ reference: siblingReference(own, name),
114
+ children: [],
115
+ ...(settings ? { settings } : {}),
116
+ }}
117
+ inheritedSsr={false} />
118
+ );
119
+ }
120
+
121
+ /**
122
+ * One canvas, mounted for as long as its tab is open, shown while the tab is in front. Its own
123
+ * DI scope carries the tab handle; the canvas resolves it up the scope chain.
124
+ */
125
+ /**
126
+ * One canvas, mounted for as long as its tab is open, shown while the tab is in front. Its own
127
+ * DI scope carries the tab handle; the canvas resolves it up the scope chain.
128
+ */
129
+ function CanvasHost({ workspace, tab, own, front }: { workspace: Workspace; tab: IWorkspaceTab; own: ITileReference; front: boolean }) {
130
+ const di = useDi(DI_CONTAINER_TOKEN);
131
+ const parentScope = useDiScope();
132
+ const [scope] = useState(() => {
133
+ const tabScope = new BlockScope(parentScope);
134
+ di.registerScope(tabScope);
135
+ di.registerValueOfToken(DOCK_TAB_TOKEN, new WorkspaceTabHandle(workspace, tab.id), tabScope);
136
+ return tabScope;
137
+ });
138
+ useEffect(() => () => di.disposeScope(scope), [di, scope]);
139
+ return (
140
+ <ProvideDiScope scope={scope}>
141
+ <div className={`workbench__canvas${front ? '' : ' workbench__pane--hidden'}`}
142
+ data-test-id="workbench-canvas"
143
+ data-tab={tab.id}
144
+ data-front={front || undefined}>
145
+ <Opened own={own}
146
+ name="canvas"
147
+ id={`wb-canvas-${tab.id}`} />
148
+ </div>
149
+ </ProvideDiScope>
150
+ );
151
+ }
152
+
153
+ /** the open documents as tabs — a browser's tab row, the one in front standing out */
154
+ /** the open documents as tabs — a browser's tab row, the one in front standing out */
155
+ function TabStrip({ workspace, state }: { workspace: Workspace; state: IWorkspaceState }) {
156
+ return (
157
+ <div className="workbench__tabs"
158
+ role="tablist"
159
+ data-test-id="workbench-tabs">
160
+ {state.tabs.map((tab) => {
161
+ const front = tab.id === state.front;
162
+ return (
163
+ <div key={tab.id}
164
+ className={`workbench__tab${front ? ' workbench__tab--front' : ''}`}
165
+ role="tab"
166
+ tabIndex={0}
167
+ aria-selected={front}
168
+ data-test-id="workbench-tab"
169
+ data-tab={tab.id}
170
+ data-tab-kind={tab.doc.kind}
171
+ data-tab-page={tab.doc.page}
172
+ data-tooltip={tab.doc.kind === 'prefabs' ? `The prefab ${tab.label}` : tab.label}
173
+ onClick={() => workspace.activate(tab.id)}
174
+ onAuxClick={(e) => {
175
+ if (e.button === 1) workspace.close(tab.id); // the middle button closes, as in a browser
176
+ }}
177
+ onKeyDown={(e) => {
178
+ if (e.target === e.currentTarget && (e.key === 'Enter' || e.key === ' ')) {
179
+ e.preventDefault();
180
+ workspace.activate(tab.id);
181
+ }
182
+ }}>
183
+ <span className="workbench__tab-kind">{tab.doc.kind === 'prefabs' ? PrefabsIcon : PagesIcon}</span>
184
+ <span className="workbench__tab-label">{tab.label}</span>
185
+ {tab.loading ? <span className="workbench__tab-loading"
186
+ aria-label="Loading" /> : null}
187
+ <button type="button"
188
+ className="workbench__tab-close"
189
+ data-test-id="workbench-tab-close"
190
+ aria-label={`Close ${tab.label}`}
191
+ data-tooltip="Close this tab"
192
+ onClick={(e) => {
193
+ e.stopPropagation();
194
+ workspace.close(tab.id);
195
+ }}>
196
+ {CloseIcon}
197
+ </button>
198
+ </div>
199
+ );
200
+ })}
201
+ <button type="button"
202
+ className={`workbench__tab-new${state.picker ? ' workbench__tab-new--on' : ''}`}
203
+ data-test-id="workbench-tab-new"
204
+ aria-label="Open a page or a prefab"
205
+ aria-expanded={state.picker !== null}
206
+ data-tooltip="Open another page or prefab — the pages, right here"
207
+ onClick={() => workspace.togglePicker(state.picker ?? 'pages')}>
208
+ {PlusIcon}
209
+ </button>
210
+ </div>
211
+ );
212
+ }
213
+
214
+ /**
215
+ * The QUICK PICKER: the compact pages (or prefabs) list in a popover under the tabs — another
216
+ * page is a click away without leaving the editor for the Pages view. Escape and a click
217
+ * outside close it; so does opening a document (the workspace closes it).
218
+ */
219
+ /**
220
+ * The QUICK PICKER: the compact pages (or prefabs) list in a popover under the tabs — another
221
+ * page is a click away without leaving the editor for the Pages view. Escape and a click
222
+ * outside close it; so does opening a document (the workspace closes it).
223
+ */
224
+ function Picker({ workspace, kind, own }: { workspace: Workspace; kind: ContentKind; own: ITileReference }) {
225
+ const [el, setEl] = useState<HTMLDivElement | null>(null);
226
+ useEffect(() => {
227
+ if (!el) return undefined;
228
+ const doc = el.ownerDocument;
229
+ const onPointerDown = (e: PointerEvent): void => {
230
+ const target = e.target as Element | null;
231
+ // the "+" that toggles the picker handles itself; a click anywhere else outside closes
232
+ if (target && (el.contains(target) || target.closest('[data-test-id="workbench-tab-new"]'))) return;
233
+ workspace.closePicker();
234
+ };
235
+ const onKeyDown = (e: KeyboardEvent): void => {
236
+ if (e.key === 'Escape') workspace.closePicker();
237
+ };
238
+ doc.addEventListener('pointerdown', onPointerDown, true);
239
+ doc.addEventListener('keydown', onKeyDown);
240
+ return () => {
241
+ doc.removeEventListener('pointerdown', onPointerDown, true);
242
+ doc.removeEventListener('keydown', onKeyDown);
243
+ };
244
+ }, [el, workspace]);
245
+ return (
246
+ <>
247
+ {/* the backdrop: the desk under the picker is mostly an iframe, where a click never reaches
248
+ this document — so a layer of our own takes it and closes the picker */}
249
+ <div className="workbench__picker-backdrop"
250
+ data-test-id="workbench-picker-backdrop"
251
+ onPointerDown={() => workspace.closePicker()} />
252
+ <div ref={setEl}
253
+ className="workbench__picker"
254
+ role="dialog"
255
+ aria-label={kind === 'prefabs' ? 'Open a prefab' : 'Open a page'}
256
+ data-test-id="workbench-picker"
257
+ data-kind={kind}>
258
+ <div className="workbench__picker-switch"
259
+ role="tablist">
260
+ {(['pages', 'prefabs'] as const).map((choice) => (
261
+ <button key={choice}
262
+ type="button"
263
+ role="tab"
264
+ className={`workbench__picker-choice${kind === choice ? ' workbench__picker-choice--on' : ''}`}
265
+ aria-selected={kind === choice}
266
+ data-test-id={`workbench-picker-${choice}`}
267
+ onClick={() => {
268
+ if (kind !== choice) workspace.togglePicker(choice);
269
+ }}>
270
+ {choice === 'pages' ? 'Pages' : 'Prefabs'}
271
+ </button>
272
+ ))}
273
+ </div>
274
+ <div className="workbench__picker-list">
275
+ <Opened own={own}
276
+ name="pagesPanel"
277
+ id={`wb-picker-${kind}`}
278
+ settings={{ kind, compact: true }} />
279
+ </div>
280
+ </div>
281
+ </>
282
+ );
283
+ }
284
+
285
+ /**
286
+ * The handle between the tree and the canvas: dragging it sets the tree's width for the document
287
+ * in front. The width is held here while the drag lasts (every move would otherwise go through
288
+ * the workspace and the address) and committed on release. Pointer capture keeps the drag on the
289
+ * handle; the canvases stop taking pointer events meanwhile — an iframe would swallow the drag.
290
+ */
291
+ /**
292
+ * The handle between the tree and the canvas: dragging it sets the tree's width for the document
293
+ * in front. The width is held here while the drag lasts (every move would otherwise go through
294
+ * the workspace and the address) and committed on release. Pointer capture keeps the drag on the
295
+ * handle; the canvases stop taking pointer events meanwhile — an iframe would swallow the drag.
296
+ */
297
+ function Splitter({ width, onPreview, onCommit }: { width: number; onPreview: (width: number | null) => void; onCommit: (width: number) => void }) {
298
+ const drag = useRef<{ startX: number; startWidth: number; width: number } | null>(null);
299
+ const fit = (value: number): number => Math.min(TREE_WIDTH_MAX, Math.max(TREE_WIDTH_MIN, Math.round(value)));
300
+ return (
301
+ <div className="workbench__splitter"
302
+ role="separator"
303
+ aria-orientation="vertical"
304
+ aria-label="Resize the tree"
305
+ aria-valuenow={width}
306
+ data-test-id="workbench-splitter"
307
+ data-tooltip="Drag to make the tree wider or narrower — remembered for this page"
308
+ onPointerDown={(e) => {
309
+ if (e.button !== 0) return;
310
+ e.preventDefault();
311
+ drag.current = { startX: e.clientX, startWidth: width, width };
312
+ e.currentTarget.setPointerCapture?.(e.pointerId);
313
+ onPreview(width);
314
+ }}
315
+ onPointerMove={(e) => {
316
+ if (!drag.current) return;
317
+ drag.current.width = fit(drag.current.startWidth + (e.clientX - drag.current.startX));
318
+ onPreview(drag.current.width);
319
+ }}
320
+ onPointerUp={(e) => {
321
+ if (!drag.current) return;
322
+ const committed = drag.current.width;
323
+ drag.current = null;
324
+ e.currentTarget.releasePointerCapture?.(e.pointerId);
325
+ onPreview(null);
326
+ onCommit(committed);
327
+ }}
328
+ onPointerCancel={() => {
329
+ drag.current = null;
330
+ onPreview(null);
331
+ }} />
332
+ );
333
+ }
334
+
335
+ /** the editor: the tabs over the desk — the tree column beside the canvas in front */
336
+ /** the editor: the tabs over the desk — the tree column beside the canvas in front */
337
+ function EditorArea({ workspace, state, own }: { workspace: Workspace; state: IWorkspaceState; own: ITileReference }) {
338
+ // the width while a drag lasts; null between drags — the workspace's word then
339
+ const [dragWidth, setDragWidth] = useState<number | null>(null);
340
+ const treeWidth = dragWidth ?? state.treeWidth;
341
+ // a canvas is mounted on the tab's FIRST visit and kept from then on: after a reload only the
342
+ // tab in front loads its preview — every restored tab loading at once would keep the one the
343
+ // author actually looks at waiting behind the others
344
+ const [visited, setVisited] = useState<ReadonlySet<string>>(() => new Set(state.front ? [state.front] : []));
345
+ useEffect(() => {
346
+ if (state.front && !visited.has(state.front)) setVisited((prev) => new Set(prev).add(state.front!));
347
+ }, [state.front]);
348
+ return (
349
+ <div className="workbench__editor"
350
+ data-test-id="workbench-editor"
351
+ data-tree-shown={state.treeShown || undefined}>
352
+ <TabStrip workspace={workspace}
353
+ state={state} />
354
+ <div className={`workbench__desk${dragWidth !== null ? ' workbench__desk--resizing' : ''}`}>
355
+ {state.picker ? <Picker workspace={workspace}
356
+ kind={state.picker}
357
+ own={own} /> : null}
358
+ <aside className={`workbench__tree${state.treeShown ? '' : ' workbench__pane--hidden'}`}
359
+ data-test-id="workbench-tree"
360
+ data-tree-width={treeWidth}
361
+ style={{ width: `${treeWidth}px` }}>
362
+ <Opened own={own}
363
+ name="treeView"
364
+ id="wb-tree" />
365
+ </aside>
366
+ {state.treeShown ? (
367
+ <Splitter width={treeWidth}
368
+ onPreview={setDragWidth}
369
+ onCommit={(width) => workspace.setTreeWidth(width)} />
370
+ ) : null}
371
+ <div className="workbench__canvases">
372
+ {state.tabs.length === 0 ? (
373
+ <div className="workbench__blank"
374
+ data-test-id="workbench-blank">
375
+ <p>Nothing is open. Pick a page or a prefab to edit.</p>
376
+ <button type="button"
377
+ className="workbench__button"
378
+ onClick={() => workspace.go('pages')}>
379
+ The pages
380
+ </button>
381
+ </div>
382
+ ) : null}
383
+ {state.tabs.filter((tab) => tab.id === state.front || visited.has(tab.id)).map((tab) => (
384
+ <CanvasHost key={tab.id}
385
+ workspace={workspace}
386
+ tab={tab}
387
+ own={own}
388
+ front={tab.id === state.front} />
389
+ ))}
390
+ </div>
391
+ </div>
392
+ </div>
393
+ );
394
+ }
395
+
396
+ // ---- the block ---------------------------------------------------------------------------------
397
+
398
+ const noSubscription = (): (() => void) => () => undefined;
399
+ const noState = (): IWorkspaceState | null => null;
400
+
401
+ function Editor() {
402
+ // touching the session arms the workbench's services (autosave, conflicts, hotkeys, clipboard)
403
+ useEditingSession();
404
+ const di = useDi(DI_CONTAINER_TOKEN);
405
+ const own = useComponent().reference;
406
+ const route = useRoute();
407
+ const router = useDiOptional(ROUTER_TOKEN) ?? null;
408
+ // the workspace is born with the editor — BEFORE any canvas renders, so every restored tab's
409
+ // session is keyed by the time its canvas asks for it; one per container, kept across the
410
+ // editor's own remounts (a visit to a list and back finds the tabs where they were)
411
+ const [workspace] = useState<AddressWorkspace | null>(() => {
412
+ const hub = editingSessionHubOf(di);
413
+ if (!hub) return null;
414
+ if (di.hasByToken(WORKSPACE_TOKEN)) return di.getByToken(WORKSPACE_TOKEN) as AddressWorkspace;
415
+ const base = basePathOf(route?.pattern ?? null, window.location.pathname);
416
+ const created = new AddressWorkspace(di, hub, own, window, base, router);
417
+ di.registerValueOfToken(WORKSPACE_TOKEN, created);
418
+ return created;
419
+ });
420
+ useEffect(() => workspace?.attach(), [workspace]);
421
+ // the address moved under the editor (Back, a link into it): the tabs follow — the router
422
+ // re-reads the route, the editor reads the route
423
+ const located = route ? `${route.path ?? ''}?${new URLSearchParams(route.search ?? {}).toString()}` : null;
424
+ useEffect(() => {
425
+ if (!workspace || !route) return;
426
+ const address = parseEditorAddress(route.path ?? window.location.pathname, workspace.base, new URLSearchParams(route.search ?? {}));
427
+ if (address) workspace.locate(address);
428
+ }, [workspace, located]);
429
+ const state = useSyncExternalStore(workspace?.subscribe ?? noSubscription, workspace?.snapshot ?? noState, noState);
430
+ // every canvas claims the focus as it mounts — the one in front takes it back after them
431
+ useEffect(() => workspace?.focusFront(), [workspace, state?.front, state?.tabs.length]);
432
+ if (!workspace || !state) {
433
+ return <div className="workbench-editor workbench-editor--bare"
434
+ data-test-id="workbench-bare">The editor needs its editing session to run.</div>;
435
+ }
436
+ return (
437
+ <TooltipHost>
438
+ <EditorArea workspace={workspace}
439
+ state={state}
440
+ own={own} />
441
+ </TooltipHost>
442
+ );
443
+ }
444
+
445
+ function EditorBlock() {
446
+ // an editor inside a preview must not open canvases of its own (an iframe in an iframe,
447
+ // forever, when the workbench page previews itself)
448
+ if (useIsPreviewMode()) {
449
+ return <div className="workbench-editor workbench-editor--bare"
450
+ data-workbench-preview-stub>An editor inside a preview does not expand</div>;
451
+ }
452
+ if (typeof window === 'undefined') return null; // nothing to show a server: the editor is the browser's
453
+ return <Editor />;
454
+ }
455
+
456
+ export default markInternalTile(defineComponentSide({
457
+ Component: EditorBlock,
458
+ defaultSettings() {
459
+ return {};
460
+ },
461
+ }));