@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,963 @@
1
+ // THE SELECTED NODE'S SETTINGS, as any host shows them — the canvas popover beside the block, or a
2
+ // settings panel a workbench still docks. What the workbench owns around the block's own form:
3
+ // the ssr flag, the page's meta on the root, and the REFERENCE PICKERS.
4
+ //
5
+ // HOSTS, not renders (ADR-0034): the block's form is a MOUNTED application — the block's own
6
+ // library's root mounts it when it ships one, the editor's otherwise. The draft it edits lives in
7
+ // the workbench's container (SETTINGS_DRAFT_TOKEN, read through the parent door).
8
+ //
9
+ // The pickers are drawn BY THE EDITOR, next to the fields the form marks with `data-field`, for
10
+ // the settings the block declares `bindable` — the Unity object-picker circle: grey for a value
11
+ // written here, blue for a value taken from a provider; a click lists what the reachable providers
12
+ // hand out. The form knows nothing of them.
13
+ import { useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
14
+ import type { ReactNode, RefObject } from 'react';
15
+ import { DI_CONTAINER_TOKEN, SingleDiToken } from '@mosetta/di';
16
+ import type { DiContainer } from '@mosetta/di';
17
+ import { useComponent, useDi, useDiOptional } from '@mosetta/react';
18
+ import { useServerAction } from '@mosetta/react/server-action';
19
+ import { EditingSession, editingSessionHubOf, findNodeDoc, unitOf } from '@mosetta/content';
20
+ import type { ICatalogEntry, IEditingState, IPageMeta } from '@mosetta/content';
21
+ import { parseLanguages } from '@mosetta/content/i18n';
22
+ import { isRefValue, TILES_HOLDER_TOKEN, parseRef } from '@mosetta/runtime/client';
23
+ import type { IRefTreeNode } from '@mosetta/runtime/client';
24
+ import './workbenchScale.css';
25
+ import './nodeSettings.css';
26
+ import { SearchField } from './searchField';
27
+ import { listComponentsToken, projectConfigToken } from './contentCommon';
28
+ import { ensureEditorViews, ensureSettingsDraft } from './settingsDraft';
29
+ import { useFeedProjectConfig } from './projectConfig';
30
+ import { formRootOf } from './formRoot';
31
+ import { ensureMediaUpload } from './mediaUpload';
32
+ import { hostedPage, hostFallbackOf } from './mountedTree';
33
+ import { useMountedTree } from './useMountedTree';
34
+ import { ensureRefsEditing } from './refsEditing';
35
+ import type { IRefOption } from './refsEditing';
36
+ import { OpenFieldsForm, PrefabInstanceForm } from './prefabFields';
37
+ import { useFeedPrefabTrees, useProjectPrefabs } from './projectPrefabs';
38
+ import { isPartId, openInPrefab, prefabPartOf } from './prefabParts';
39
+ import { disconnectedOf, useDisconnection } from './disconnected';
40
+ import { upgradeChanges, useUpgrades } from './blockUpgrades';
41
+ import type { IOutdatedNode } from './blockUpgrades';
42
+ import { noticesOf } from './editingNotices';
43
+ import type { IPrefabPart } from './prefabParts';
44
+ import { WORKSPACE_TOKEN } from '../workbench/workspace';
45
+ import { useFeedThemeVariables } from './themeVariables';
46
+ import { FORM_HOST_CLASS, INSPECTOR_TAB_ATTRIBUTE, tabOf } from '../canvas/settingsTabs';
47
+ import { blockSchemas, nodeSettingsIssues } from '@mosetta/settings/check';
48
+ import type { INodeSettingsIssue } from '@mosetta/settings/check';
49
+ import { removalPatch, SettingsIssues } from './settingsIssues';
50
+ import type { TSettingsTab } from '../canvas/settingsTabs';
51
+
52
+ /** a section of the editor's own settings, under the tab of the popover it belongs to */
53
+ function Section({ tab, children }: { tab: TSettingsTab; children: ReactNode }) {
54
+ return <div {...{ [INSPECTOR_TAB_ATTRIBUTE]: tab }}
55
+ className="settings__section">{children}</div>;
56
+ }
57
+
58
+ /**
59
+ * The page's own words: title, description, cover and date. They ride in the document (versioned
60
+ * with it), feed the head, the article lists and the share card. A field emptied leaves the doc.
61
+ * A project of several languages gets a language picker: in another language only the WORDS are
62
+ * edited (the title and description a reader of that language gets — the original shows through
63
+ * where one is empty); the cover, the date and the rest are the page's in every language.
64
+ */
65
+ function PageMetaForm({ session, meta }: { session: EditingSession; meta: IPageMeta }) {
66
+ const config = useServerAction({ token: projectConfigToken, input: {} });
67
+ const { languages, defaultLanguage } = parseLanguages(config.isSuccess() && config.serverData.ok ? config.serverData.config : null);
68
+ const [picked, setPicked] = useState<string | null>(null);
69
+ const lang = picked && picked !== defaultLanguage && languages.includes(picked) ? picked : null;
70
+ const words = lang ? meta.i18n?.[lang] ?? {} : null;
71
+ const field = (key: 'title' | 'description' | 'image' | 'date' | 'category', label: string, placeholder: string, type = 'text') => {
72
+ const own = meta[key];
73
+ const value = words ? words[key as 'title' | 'description'] : own;
74
+ return (
75
+ <label className="settings__meta-field">
76
+ <span className="settings__meta-label">{label}</span>
77
+ <input className="settings__meta-input"
78
+ type={type}
79
+ data-test-id={`settings-meta-${key}`}
80
+ data-field={key}
81
+ value={typeof value === 'string' ? value : ''}
82
+ // in a translation the original shows through — what the reader gets while it is empty
83
+ placeholder={words && typeof own === 'string' && own ? own : placeholder}
84
+ onChange={(e) => (lang
85
+ ? session.setPageMetaWords({ [lang]: { [key]: e.target.value } })
86
+ : session.setPageMeta({ [key]: e.target.value }))} />
87
+ </label>
88
+ );
89
+ };
90
+ return (
91
+ <div className="settings__meta"
92
+ data-test-id="settings-meta"
93
+ data-meta-lang={lang ?? defaultLanguage}>
94
+ <div className="settings__target">Page</div>
95
+ {languages.length > 1 ? (
96
+ <div className="settings__lang">
97
+ <select className="settings__lang-select"
98
+ aria-label="Language of the page's words"
99
+ data-test-id="settings-meta-lang"
100
+ value={lang ?? ''}
101
+ onChange={(e) => setPicked(e.target.value || null)}>
102
+ <option value="">{`${defaultLanguage} · original`}</option>
103
+ {languages.filter((l) => l !== defaultLanguage).map((l) => (
104
+ <option key={l}
105
+ value={l}>{`${l}${meta.i18n?.[l] ? '' : ' · not translated'}`}</option>
106
+ ))}
107
+ </select>
108
+ </div>
109
+ ) : null}
110
+ {field('title', 'Title', 'what the tab and the list show')}
111
+ {field('description', 'Description', 'one or two sentences')}
112
+ {lang ? (
113
+ <span className="settings__meta-label">The cover, the date and the category are the same in every language</span>
114
+ ) : (
115
+ <>
116
+ {field('image', 'Cover image URL', 'https://…')}
117
+ {field('date', 'Date', '', 'date')}
118
+ {field('category', 'Category', 'a key: english_grammar')}
119
+ <label className="settings__meta-flag"
120
+ data-tooltip="Search engines will not show this page: it gets robots noindex and stays out of the sitemap">
121
+ <input type="checkbox"
122
+ data-test-id="settings-meta-noindex"
123
+ checked={meta.noindex === true}
124
+ onChange={(e) => session.setPageMeta({ noindex: e.target.checked })} />
125
+ <span className="settings__meta-label">Hide from search engines</span>
126
+ </label>
127
+ </>
128
+ )}
129
+ </div>
130
+ );
131
+ }
132
+
133
+ interface IFieldBox {
134
+ readonly field: string;
135
+ readonly top: number;
136
+ readonly left: number;
137
+ readonly width: number;
138
+ readonly height: number;
139
+ }
140
+
141
+ /** the picker's greatest height (nodeSettings.css: .settings__picker max-height) plus its shadow */
142
+ const PICKER_HEIGHT = 430;
143
+
144
+ /** where the marked fields of a mounted form sit, relative to `container` — re-measured as the form changes */
145
+ function useFieldBoxes(container: RefObject<HTMLElement | null>, fields: readonly string[]): IFieldBox[] {
146
+ const [boxes, setBoxes] = useState<IFieldBox[]>([]);
147
+ const key = fields.join('|');
148
+ useEffect(() => {
149
+ const root = container.current;
150
+ if (!root || !fields.length) {
151
+ setBoxes([]);
152
+ return undefined;
153
+ }
154
+ // the popover's tab shown (settingsTabs.ts): a field on another tab is out of sight, and so is its circle
155
+ const tabbed = root.closest('[data-settings-tab]');
156
+ const measure = () => {
157
+ const origin = root.getBoundingClientRect();
158
+ const showing = tabbed?.getAttribute('data-settings-tab') ?? null;
159
+ const next: IFieldBox[] = [];
160
+ for (const field of fields) {
161
+ // a selector would have to quote the field's name; scanning is simpler and cannot misfire
162
+ const element = [...root.querySelectorAll('[data-field]')].find((el) => el.getAttribute('data-field') === field);
163
+ if (!element || (showing && tabOf(element) !== showing)) continue;
164
+ const rect = element.getBoundingClientRect();
165
+ next.push({ field, top: rect.top - origin.top, left: rect.left - origin.left, width: rect.width, height: rect.height });
166
+ }
167
+ // the mounted form's own height rides along — a picker taller than the rest of the form
168
+ // reserves the room it needs (see FieldRefs)
169
+ const form = root.firstElementChild;
170
+ next.push({ field: '', top: 0, left: 0, width: 0, height: form ? form.getBoundingClientRect().height : 0 });
171
+ setBoxes((prev) => (JSON.stringify(prev) === JSON.stringify(next) ? prev : next));
172
+ };
173
+ measure();
174
+ // the observers of the element's OWN window: a host realm may not expose them as globals
175
+ const view = root.ownerDocument.defaultView;
176
+ const mutations = view?.MutationObserver ? new view.MutationObserver(measure) : null;
177
+ mutations?.observe(root, { subtree: true, childList: true, attributes: true, characterData: true });
178
+ const resize = view?.ResizeObserver ? new view.ResizeObserver(measure) : null;
179
+ resize?.observe(root);
180
+ const switches = tabbed && view?.MutationObserver ? new view.MutationObserver(measure) : null;
181
+ if (tabbed) switches?.observe(tabbed, { attributes: true, attributeFilter: ['data-settings-tab'] });
182
+ return () => {
183
+ mutations?.disconnect();
184
+ resize?.disconnect();
185
+ switches?.disconnect();
186
+ };
187
+ }, [container, key]);
188
+ return boxes;
189
+ }
190
+
191
+ const text = (value: unknown): string => (typeof value === 'string' ? value : value == null ? '' : JSON.stringify(value));
192
+
193
+ /** the list a picker circle opens: every reachable provider, what it hands out, and the way back */
194
+ function RefPicker({ top, field, current, options, shown, onPick, onCreate, onUnbind, onShow, onClose }: {
195
+ top: number;
196
+ field: string;
197
+ current: { name: string; key: string } | null;
198
+ options: readonly IRefOption[];
199
+ shown: unknown;
200
+ onPick(name: string, key: string): void;
201
+ onCreate(name: string): void;
202
+ onUnbind(): void;
203
+ onShow(): void;
204
+ onClose(): void;
205
+ }) {
206
+ const [query, setQuery] = useState('');
207
+ const ref = useRef<HTMLDivElement>(null);
208
+ useEffect(() => {
209
+ const away = (event: MouseEvent) => {
210
+ if (!ref.current?.contains(event.target as Node)) onClose();
211
+ };
212
+ const escape = (event: KeyboardEvent) => {
213
+ if (event.key === 'Escape') onClose();
214
+ };
215
+ document.addEventListener('mousedown', away, true);
216
+ document.addEventListener('keydown', escape, true);
217
+ return () => {
218
+ document.removeEventListener('mousedown', away, true);
219
+ document.removeEventListener('keydown', escape, true);
220
+ };
221
+ }, [onClose]);
222
+ // the value in use is where the list opens — not somewhere the author has to scroll to
223
+ useLayoutEffect(() => {
224
+ const picker = ref.current;
225
+ const row = picker?.querySelector<HTMLElement>('.settings__picker-row--current');
226
+ if (picker && row) picker.scrollTop = Math.max(0, row.offsetTop - (picker.clientHeight - row.offsetHeight) / 2);
227
+ }, []);
228
+ const needle = query.trim().toLowerCase();
229
+ const matches = (label: string, value: unknown, where: readonly string[]) => !needle
230
+ || [label, text(value), ...where].some((part) => part.toLowerCase().includes(needle));
231
+ return (
232
+ <div ref={ref}
233
+ className="settings__picker"
234
+ style={{ top }}
235
+ data-test-id="settings-ref-picker"
236
+ data-ref-field={field}>
237
+ <SearchField className="settings__picker-search-field">
238
+ <input className="settings__picker-search"
239
+ autoFocus
240
+ placeholder="Search values"
241
+ value={query}
242
+ onChange={(e) => setQuery(e.target.value)} />
243
+ </SearchField>
244
+ {options.length === 0 ? <div className="settings__picker-empty">No block around hands out values</div> : null}
245
+ {options.map((option) => (
246
+ <div key={option.name}
247
+ data-ref-provider={option.name}>
248
+ <div className="settings__picker-group">
249
+ <span>{option.name}</span>
250
+ <span className="settings__picker-where">{option.where}</span>
251
+ </div>
252
+ {option.entries.filter((entry) => matches(entry.label, entry.value, option.usedBy[entry.key] ?? [])).map((entry) => {
253
+ const isCurrent = current?.name === option.name && current.key === entry.key;
254
+ // who reads it, unless the name already says so (a key made after its node)
255
+ const readers = (option.usedBy[entry.key] ?? []).filter((where) => !entry.label.startsWith(where));
256
+ return (
257
+ <button key={entry.key}
258
+ type="button"
259
+ className={`settings__picker-row${isCurrent ? ' settings__picker-row--current' : ''}`}
260
+ data-ref-entry={entry.key}
261
+ onClick={() => onPick(option.name, entry.key)}>
262
+ <span className="settings__picker-key">
263
+ {entry.label}
264
+ {readers.length ? <span className="settings__picker-where">{` · ${readers.join(', ')}`}</span> : null}
265
+ </span>
266
+ <span className="settings__picker-value">{text(entry.value)}</span>
267
+ </button>
268
+ );
269
+ })}
270
+ {option.canCreate ? (
271
+ <button type="button"
272
+ className="settings__picker-row"
273
+ data-ref-create={option.name}
274
+ onClick={() => onCreate(option.name)}>
275
+ <span>+ New value in {option.name}</span>
276
+ </button>
277
+ ) : null}
278
+ </div>
279
+ ))}
280
+ {current ? (
281
+ <>
282
+ <button type="button"
283
+ className="settings__picker-action"
284
+ data-ref-show
285
+ onClick={onShow}>
286
+ Show {current.name}
287
+ </button>
288
+ <button type="button"
289
+ className="settings__picker-action"
290
+ data-ref-unbind
291
+ onClick={onUnbind}>
292
+ None · keep “{text(shown)}” as text
293
+ </button>
294
+ </>
295
+ ) : null}
296
+ </div>
297
+ );
298
+ }
299
+
300
+ /**
301
+ * The circles beside the form's bindable fields, and the picker one of them opens. Reads and
302
+ * writes through the workbench's reference editor, as one undoable step per choice.
303
+ */
304
+ function FieldRefs({ container, session, state, node }: {
305
+ container: RefObject<HTMLElement | null>;
306
+ session: EditingSession;
307
+ state: IEditingState;
308
+ node: IRefTreeNode;
309
+ }) {
310
+ const di = useDi(DI_CONTAINER_TOKEN);
311
+ const refs = ensureRefsEditing(di);
312
+ const [, setLoads] = useState(0);
313
+ useEffect(() => refs.subscribe(() => setLoads((n) => n + 1)), [refs]);
314
+ const [open, setOpen] = useState<string | null>(null);
315
+ useEffect(() => setOpen(null), [node.id]);
316
+ const fields = refs.bindable(node.reference);
317
+ const measured = useFieldBoxes(container, fields);
318
+ const formHeight = measured.find((box) => box.field === '')?.height ?? 0;
319
+ const boxes = measured.filter((box) => box.field !== '');
320
+ const opened = boxes.find((box) => box.field === open);
321
+ // an open picker hangs below its field; the area grows to hold it (the popover scrolls, never clips)
322
+ const reserve = opened ? Math.max(0, opened.top + opened.height + 4 + PICKER_HEIGHT - formHeight) : 0;
323
+ const doc = state.doc as unknown as IRefTreeNode | null;
324
+ const settings = node.settings ?? {};
325
+ // a bound field wears the editor's colour on its OWN border (nodeSettings.css) — its own shape,
326
+ // whatever the form drew; kept on the field when the form renders it anew
327
+ const boundFields = fields.filter((field) => isRefValue(settings[field])).join('|');
328
+ useLayoutEffect(() => {
329
+ const root = container.current;
330
+ if (!root) return undefined;
331
+ const bound = new Set(boundFields ? boundFields.split('|') : []);
332
+ const mark = () => {
333
+ for (const el of root.querySelectorAll('[data-field]')) {
334
+ const on = bound.has(el.getAttribute('data-field') ?? '');
335
+ if (on !== el.hasAttribute('data-mosetta-bound')) {
336
+ if (on) el.setAttribute('data-mosetta-bound', '');
337
+ else el.removeAttribute('data-mosetta-bound');
338
+ }
339
+ }
340
+ };
341
+ mark();
342
+ const view = root.ownerDocument.defaultView;
343
+ const watch = view?.MutationObserver ? new view.MutationObserver(mark) : null;
344
+ watch?.observe(root, { subtree: true, childList: true });
345
+ return () => {
346
+ watch?.disconnect();
347
+ for (const el of root.querySelectorAll('[data-mosetta-bound]')) el.removeAttribute('data-mosetta-bound');
348
+ };
349
+ }, [container, boundFields]);
350
+ if (!doc || !boxes.length) return null;
351
+ const views = state.views;
352
+ const apply = (patches: ReturnType<typeof refs.bindPatches>) => {
353
+ session.updateNodesSettings(patches);
354
+ setOpen(null);
355
+ };
356
+ return (
357
+ <>
358
+ {reserve ? <div style={{ height: reserve }} /> : null}
359
+ {boxes.map((box) => {
360
+ const value = settings[box.field];
361
+ const bound = isRefValue(value) ? parseRef(value.$ref) : null;
362
+ return (
363
+ <div key={box.field}>
364
+ <button type="button"
365
+ className={`settings__ref${bound ? ' settings__ref--bound' : ''}`}
366
+ // beside its field, centred on it by its own css — a narrow field keeps it close
367
+ style={{ top: box.top + box.height / 2, left: box.left + box.width + 6 }}
368
+ data-tooltip={bound
369
+ ? `The value comes from ${bound.name} · ${bound.key} — click to pick another or unlink it`
370
+ : 'Take the value from another block — click to pick one'}
371
+ aria-label={bound ? `${box.field}: from ${bound.name}` : `${box.field}: pick a value`}
372
+ data-test-id="settings-ref"
373
+ data-ref-field={box.field}
374
+ data-ref-bound={bound ? bound.name : undefined}
375
+ onClick={() => setOpen(open === box.field ? null : box.field)} />
376
+ {open === box.field ? (
377
+ <RefPicker top={box.top + box.height + 4}
378
+ field={box.field}
379
+ current={bound}
380
+ options={refs.options(doc, node.id, views, box.field)}
381
+ shown={bound ? refs.read(doc, node.id, value as { $ref: string }, {}) : value}
382
+ onPick={(name, key) => apply(refs.bindPatches(doc, node.id, settings, box.field, name, views, key))}
383
+ onCreate={(name) => apply(refs.bindPatches(doc, node.id, settings, box.field, name, views))}
384
+ onUnbind={() => apply(refs.unbindPatches(doc, node.id, settings, box.field))}
385
+ onShow={() => {
386
+ // the Unity "ping": the provider lights up in the tree and the canvas
387
+ const providerId = refs.providerOf(doc, node.id, value);
388
+ if (!providerId) return;
389
+ session.hoverNode(providerId);
390
+ setTimeout(() => session.hoverNode(null), 1500);
391
+ }}
392
+ onClose={() => setOpen(null)} />
393
+ ) : null}
394
+ </div>
395
+ );
396
+ })}
397
+ </>
398
+ );
399
+ }
400
+
401
+ /**
402
+ * For a block that hands out values it can keep new ones of (a provider with `create` — i18n):
403
+ * whether it takes NEW blocks under its wing — every block placed in its reach from now on gets
404
+ * its fields of the provider's type linked to it at once (autoBind.ts), nothing to press.
405
+ */
406
+ function AutoBindToggle({ session, node }: { session: EditingSession; node: { id: string; reference: string; autoBind?: boolean } }) {
407
+ const di = useDi(DI_CONTAINER_TOKEN);
408
+ const refs = ensureRefsEditing(di);
409
+ // the provider's declaration is module code: known once its module is in (the refs say when)
410
+ const keeps = useSyncExternalStore(refs.subscribe, () => refs.canKeep(node.reference)?.type ?? (refs.canKeep(node.reference) ? '' : null));
411
+ if (keeps === null) return null;
412
+ const what = keeps ? `${keeps} fields` : 'fields';
413
+ return (
414
+ <Section tab="main">
415
+ <label className="settings__ssr"
416
+ title={`Blocks placed in this block's reach get their ${what} linked to it at once — a text is counted, and translated, from its first moment`}>
417
+ <input type="checkbox"
418
+ data-test-id="settings-auto-bind"
419
+ data-settings-auto-bind={node.id}
420
+ checked={node.autoBind === true}
421
+ onChange={(e) => session.setNodeAutoBind(node.id, e.target.checked)} />
422
+ {`Link new blocks' ${what} to this block`}
423
+ </label>
424
+ </Section>
425
+ );
426
+ }
427
+
428
+ /**
429
+ * A PREFAB's own block, selected on a page: what it is, why it cannot be changed here, and the
430
+ * ways on — to the prefab, where it is changed, and to the prefab node, the page's own block.
431
+ */
432
+ function PrefabPartNotice({ session, part }: { session: EditingSession; part: IPrefabPart }) {
433
+ const di = useDi(DI_CONTAINER_TOKEN);
434
+ const workspace = useDiOptional(WORKSPACE_TOKEN);
435
+ const working = editingSessionHubOf(di)?.active ?? session;
436
+ return (
437
+ <div className="settings__part"
438
+ data-test-id="settings-prefab-part"
439
+ data-prefab={part.prefab.name}>
440
+ <p className="settings__part-text">
441
+ A block of the prefab <b>{part.prefab.name}</b> — seen here, changed in the prefab: a change
442
+ there shows on every page that places it.
443
+ </p>
444
+ <div className="settings__part-actions">
445
+ {workspace ? (
446
+ <button type="button"
447
+ className="settings__part-action settings__part-action--main"
448
+ data-test-id="settings-part-open"
449
+ onClick={() => openInPrefab(workspace, working, part)}>
450
+ Open in prefab
451
+ </button>
452
+ ) : null}
453
+ <button type="button"
454
+ className="settings__part-action"
455
+ data-test-id="settings-part-owner"
456
+ onClick={() => session.selectNode(part.owner.id)}>
457
+ Select the prefab
458
+ </button>
459
+ </div>
460
+ </div>
461
+ );
462
+ }
463
+
464
+ /**
465
+ * A block with a NEWER VERSION (ADR-0061): the page keeps the version it was placed with until the
466
+ * author says otherwise — here, for this block (the toolbar's Updates does a page at once). Its
467
+ * settings move through the new version's own `upgrade`, under its new defaults; one undoable step.
468
+ */
469
+ function UpgradeLine({ session, node, readOnly }: { session: EditingSession; node: IOutdatedNode; readOnly: boolean }) {
470
+ const holder = useDi(TILES_HOLDER_TOKEN);
471
+ const notices = noticesOf(useDi(DI_CONTAINER_TOKEN));
472
+ const { components } = useUpgrades(null);
473
+ const [busy, setBusy] = useState(false);
474
+ const { name, fromVersion, toVersion } = node.upgrade;
475
+ const update = async () => {
476
+ setBusy(true);
477
+ try {
478
+ session.upgradeNodes(await upgradeChanges(holder, [node], components));
479
+ notices?.show('publish', `Updated ${name} to ${toVersion}`);
480
+ } catch {
481
+ notices?.show('refuse', `Could not update ${name} to ${toVersion}`);
482
+ } finally {
483
+ setBusy(false);
484
+ }
485
+ };
486
+ return (
487
+ <div className="settings__part settings__part--up"
488
+ data-test-id="settings-upgrade"
489
+ data-from={fromVersion}
490
+ data-to={toVersion}>
491
+ <p className="settings__part-text">
492
+ {`${name} ${fromVersion} → ${toVersion}`}
493
+ </p>
494
+ {readOnly ? null : (
495
+ <div className="settings__part-actions">
496
+ <button type="button"
497
+ className="settings__part-action settings__part-action--main"
498
+ data-test-id="settings-upgrade-button"
499
+ disabled={busy}
500
+ onClick={() => void update()}>
501
+ {busy ? 'Updating…' : `Update to ${toVersion}`}
502
+ </button>
503
+ </div>
504
+ )}
505
+ </div>
506
+ );
507
+ }
508
+
509
+ /**
510
+ * A block of a distribution the project DISCONNECTED (ADR-0059): the site leaves it out, so its
511
+ * form would set what nobody sees. What is left to do with it is said instead — take it out, or
512
+ * connect the distribution again where the project's settings are.
513
+ */
514
+ function DisconnectedNotice({ session, nodeId, title, readOnly, platformOrigin }: {
515
+ session: EditingSession; nodeId: string; title: string; readOnly: boolean; platformOrigin: string | null;
516
+ }) {
517
+ return (
518
+ <div className="settings__part settings__part--off"
519
+ data-test-id="settings-disconnected"
520
+ data-disconnected={title}>
521
+ <p className="settings__part-text">
522
+ <b>{title}</b> is disconnected from this project, so the site does not show this block — not
523
+ on the page, not in its previews. Delete it, or connect {title} again in the project’s settings.
524
+ </p>
525
+ <div className="settings__part-actions">
526
+ {readOnly ? null : (
527
+ <button type="button"
528
+ className="settings__part-action settings__part-action--main"
529
+ data-test-id="settings-disconnected-delete"
530
+ onClick={() => session.removeNode(nodeId)}>
531
+ Delete the block
532
+ </button>
533
+ )}
534
+ {platformOrigin ? (
535
+ <a className="settings__part-action"
536
+ data-test-id="settings-disconnected-settings"
537
+ href={`${platformOrigin}/`}
538
+ target="_blank"
539
+ rel="noreferrer">
540
+ Project settings ↗
541
+ </a>
542
+ ) : null}
543
+ </div>
544
+ </div>
545
+ );
546
+ }
547
+
548
+ /** how long the form's markup must hold still to count as arrived — a part of it loading on its own would change it again */
549
+ const FORM_SETTLE_MS = 150;
550
+ /** a form that draws nothing — or very late — is shown as it is after this */
551
+ const FORM_PATIENCE_MS = 4000;
552
+
553
+ /**
554
+ * Whether the block's own form has ARRIVED in its element. Its module loads after the settings
555
+ * open, and until then the element is empty (the hosted root renders nothing around the tree):
556
+ * arrived is markup in it that held still a moment — or patience run out. The host marks the
557
+ * form `data-form-loading` until then; the canvas popover stays unseen meanwhile, so it does not
558
+ * show a half of itself in one place and the whole in another.
559
+ */
560
+ function useFormArrived(host: RefObject<HTMLElement | null>): boolean {
561
+ const [arrived, setArrived] = useState(false);
562
+ useEffect(() => {
563
+ const element = host.current;
564
+ if (!element || arrived) return undefined;
565
+ let settle: ReturnType<typeof setTimeout> | undefined;
566
+ const done = () => setArrived(true);
567
+ const look = () => {
568
+ if (settle !== undefined) clearTimeout(settle);
569
+ settle = element.childElementCount > 0 ? setTimeout(done, FORM_SETTLE_MS) : undefined;
570
+ };
571
+ const patience = setTimeout(done, FORM_PATIENCE_MS);
572
+ const view = element.ownerDocument.defaultView;
573
+ const watch = view?.MutationObserver ? new view.MutationObserver(look) : null;
574
+ watch?.observe(element, { subtree: true, childList: true, characterData: true });
575
+ look();
576
+ return () => {
577
+ clearTimeout(patience);
578
+ if (settle !== undefined) clearTimeout(settle);
579
+ watch?.disconnect();
580
+ };
581
+ }, [host, arrived]);
582
+ return arrived;
583
+ }
584
+
585
+ /** the block's own form, mounted, with the editor's reference pickers beside its fields */
586
+ function HostedForm({ session, state, node, spec, header, readOnly = false, issues = [] }: {
587
+ session: EditingSession;
588
+ state: IEditingState;
589
+ node: IRefTreeNode;
590
+ spec: Parameters<typeof useMountedTree>[0];
591
+ header: ReactNode;
592
+ /** shown, not changed (a prefab's block): no reference pickers beside the fields */
593
+ readOnly?: boolean;
594
+ /** what the node's settings hold that its block does not take */
595
+ issues?: readonly INodeSettingsIssue[];
596
+ }) {
597
+ const { ref, failed } = useMountedTree(spec);
598
+ const arrived = useFormArrived(ref);
599
+ const area = useRef<HTMLDivElement>(null);
600
+ // the gutter for the reference circles is kept only where a field can have one — known from the
601
+ // block's declaration, before the circles are measured in (no shift when they appear)
602
+ const refs = ensureRefsEditing(useDi(DI_CONTAINER_TOKEN));
603
+ const bindable = useSyncExternalStore(refs.subscribe, () => !readOnly && refs.bindable(node.reference).length > 0);
604
+ return (
605
+ <>
606
+ {header}
607
+ {/* on every tab: an issue of the styles is as much in the way as one of the words */}
608
+ <SettingsIssues issues={issues}
609
+ container={area}
610
+ onRemove={readOnly ? undefined : (path) => session.updateNodesSettings([{ nodeId: node.id, patch: removalPatch(node.settings ?? {}, path) }])} />
611
+ {failed && (
612
+ <Section tab="main">
613
+ <div className="settings__hint"
614
+ data-host-failed>Could not render this form</div>
615
+ </Section>
616
+ )}
617
+ <div className="settings__form"
618
+ ref={area}
619
+ data-bindable={bindable || undefined}
620
+ data-form-loading={arrived || failed ? undefined : ''}>
621
+ {/* the element the form's own application lives in — nothing of ours goes inside it; the
622
+ pickers are its siblings, in the gutter to the right. What the form leaves unnamed is
623
+ its main settings (settingsTabs.ts) */}
624
+ <div ref={ref}
625
+ className={FORM_HOST_CLASS} />
626
+ {readOnly ? null : (
627
+ <FieldRefs container={area}
628
+ session={session}
629
+ state={state}
630
+ node={node} />
631
+ )}
632
+ </div>
633
+ </>
634
+ );
635
+ }
636
+
637
+ function NodeSettingsBody({ session, state }: { session: EditingSession; state: IEditingState }) {
638
+ const di = useDi(DI_CONTAINER_TOKEN);
639
+ // what the workbench EXPOSES to hosted forms, in its own container — the form's side of the
640
+ // door is useSettingsDraft (PARENT_DI_TOKEN → SETTINGS_DRAFT_TOKEN)
641
+ ensureSettingsDraft(di, session);
642
+ // what a provider's form reads beside its draft: the author's views, the project's config
643
+ ensureEditorViews(di, session);
644
+ useFeedProjectConfig();
645
+ // the styler's theme variables: the themes around the node the form is about
646
+ useFeedThemeVariables(state);
647
+ const self = useComponent().reference;
648
+ // the media uploader the hosted forms find through the parent chain
649
+ ensureMediaUpload(di);
650
+ // shares the palette's cache entry (same token+input) — no extra request
651
+ const catalog = useServerAction({ token: listComponentsToken, input: {} });
652
+ const project = useProjectPrefabs();
653
+ // the settings draft reads a prefab's blocks through these trees
654
+ useFeedPrefabTrees(di, project);
655
+ const node = state.doc && state.selectedNodeId ? findNodeDoc(state.doc, state.selectedNodeId) : null;
656
+ // a PREFAB's own block selected on the page (prefabParts.ts): shown as its prefab holds it
657
+ const part = !node && state.doc && isPartId(state.selectedNodeId) ? prefabPartOf(state.doc, state.selectedNodeId, project.treeOf) : null;
658
+ const shown = node ?? part?.node ?? null;
659
+ const components: readonly ICatalogEntry[] = catalog.isSuccess() && catalog.serverData.ok
660
+ ? catalog.serverData.components
661
+ : [];
662
+ const entry = shown ? components.find((c) => c.id === shown.reference) : undefined;
663
+ // what the selected block holds that it does not take — by its catalog entry's schema, its raw
664
+ // settings (a reference stands where the block lets one stand)
665
+ const schemaOf = useMemo(() => blockSchemas(components), [components]);
666
+ const issues = useMemo(() => (node ? nodeSettingsIssues(node, schemaOf) : []), [node, schemaOf]);
667
+ // a block without a form of its own has no fields to mark — the plaque alone speaks
668
+ const noForm = useRef<HTMLDivElement>(null);
669
+ // a block with a newer version says so above its form (ADR-0061)
670
+ const { outdated } = useUpgrades(state.doc);
671
+ const outdatedNode = node ? outdated.find((o) => o.nodeId === node.id) : undefined;
672
+ const upgradeLine = outdatedNode ? (
673
+ <Section tab="main">
674
+ <UpgradeLine session={session}
675
+ node={outdatedNode}
676
+ readOnly={state.revision !== null} />
677
+ </Section>
678
+ ) : null;
679
+ // a block of a disconnected distribution has no form to show: the site leaves it out
680
+ const disconnection = useDisconnection();
681
+ const offNamespace = node && state.doc && node.id !== state.doc.id ? disconnectedOf(node.reference, disconnection.disabled) : null;
682
+ const hosted = Boolean(shown && state.doc && (part || shown.id !== state.doc.id) && entry?.hasEditor && !offNamespace);
683
+
684
+ // the root that will mount the form: the block's own library ships one → it renders its own
685
+ // blocks and their forms; otherwise ours
686
+ const rootReference = useMemo(() => (shown ? formRootOf(components, shown.reference, self) : null), [components, shown?.reference, self.namespace, self.version]);
687
+
688
+ const spec = useMemo(() => (hosted && shown && rootReference && state.selectedNodeId
689
+ ? {
690
+ rootReference,
691
+ tree: hostedPage(rootReference, [{
692
+ id: `settings-form-${state.selectedNodeId}`,
693
+ reference: shown.reference.replace('_t_component_', '_t_editor_'),
694
+ settings: { placement: 'settings' },
695
+ children: [],
696
+ }], hostFallbackOf(self)),
697
+ }
698
+ : null), [hosted, state.selectedNodeId, shown?.reference, rootReference, self.namespace, self.version]);
699
+
700
+ // which draft the shown selection belongs to — the anchor's identity, visible to tests
701
+ const anchorDraft = state.draftId ?? undefined;
702
+ if (state.doc && part) {
703
+ return (
704
+ <div className="settings"
705
+ data-test-id="settings"
706
+ data-settings-part={state.selectedNodeId}
707
+ data-settings-draft={anchorDraft}>
708
+ <PrefabPartNotice session={session}
709
+ part={part} />
710
+ {/* what this page sets of the block — the fields the prefab opens (the prefab node's own
711
+ settings); a published revision sets nothing */}
712
+ <fieldset className="settings__fieldset"
713
+ disabled={state.revision !== null}>
714
+ <Section tab="main">
715
+ <PrefabInstanceForm session={session}
716
+ node={part.owner}
717
+ prefab={part.prefab}
718
+ only={part.node.id} />
719
+ </Section>
720
+ </fieldset>
721
+ {/* the block's own settings, as the prefab has them: shown, every control disabled */}
722
+ <fieldset className="settings__fieldset"
723
+ disabled
724
+ data-read-only>
725
+ {!entry ? (
726
+ <div className="settings__hint"
727
+ data-form-loading="">Loading the blocks…</div>
728
+ ) : entry.hasEditor ? (
729
+ <HostedForm session={session}
730
+ state={state}
731
+ node={part.node}
732
+ spec={spec}
733
+ header={null}
734
+ readOnly />
735
+ ) : (
736
+ <Section tab="main">
737
+ <div className="settings__hint">The block has no settings form</div>
738
+ </Section>
739
+ )}
740
+ </fieldset>
741
+ </div>
742
+ );
743
+ }
744
+ if (!state.doc || !node) {
745
+ return <div className="settings settings__hint"
746
+ data-test-id="settings"
747
+ data-settings-empty>Select a node in the Tree</div>;
748
+ }
749
+ // node-level ssr flag (SSR epic stage 4): the ROOT checkbox enables server rendering of the
750
+ // whole page; on any other node the checkbox opts its BRANCH out (ssr:false — cannot be
751
+ // re-enabled deeper, no islands). Unchecked → the field is removed from the doc.
752
+ const flags = node as { ssr?: boolean };
753
+ const isRoot = node.id === state.doc.id;
754
+ const ssrToggle = (
755
+ <Section tab="other">
756
+ <label className="settings__ssr">
757
+ <input type="checkbox"
758
+ data-test-id="settings-ssr-toggle"
759
+ data-settings-ssr={node.id}
760
+ checked={isRoot ? flags.ssr === true : flags.ssr === false}
761
+ onChange={(e) => session.setNodeSsr(
762
+ node.id,
763
+ e.target.checked ? (isRoot ? true : false) : undefined,
764
+ )} />
765
+ {isRoot ? 'Render the page on the server (SSR)' : 'Disable server rendering for this branch'}
766
+ </label>
767
+ </Section>
768
+ );
769
+ if (isRoot) {
770
+ // the root's block: the catalog's page roots (stamped isRoot by the build) and the editor's
771
+ // own generic root, which every workbench ships — the current one is listed even if the
772
+ // catalog does not know it (a library that left), so the select never lies
773
+ const fallback = `_ns_${self.namespace}_t_component_n_page_v_${self.version}`;
774
+ // a root at several deployed versions is told apart by its version
775
+ const several = (c: ICatalogEntry) => components.some((o) => o.isRoot && o.reference.name === c.reference.name && o.reference.namespace === c.reference.namespace && o.id !== c.id);
776
+ const roots = components.filter((c) => c.isRoot).map((c) => ({ id: c.id, label: several(c) ? `${c.reference.name} ${c.reference.version}` : c.reference.name }));
777
+ if (!roots.some((root) => root.id === fallback)) roots.push({ id: fallback, label: 'page (editor default)' });
778
+ if (!roots.some((root) => root.id === node.reference)) roots.push({ id: node.reference, label: node.reference });
779
+ return (
780
+ <div className="settings"
781
+ data-test-id="settings"
782
+ data-settings-root
783
+ data-settings-draft={anchorDraft}>
784
+ {upgradeLine}
785
+ {/* a page's root is a technicality next to its words (Other); a prefab's frame is what
786
+ the prefab is shown in, its first question (Main) */}
787
+ <Section tab={state.kind === 'prefabs' ? 'main' : 'other'}>
788
+ <label className="settings__root">
789
+ <span className="settings__root-label">{state.kind === 'prefabs' ? 'Frame' : 'Root component'}</span>
790
+ <select className="settings__root-select"
791
+ data-test-id="settings-root-select"
792
+ value={node.reference}
793
+ onChange={(e) => session.setRootReference(e.target.value)}>
794
+ {roots.map((root) => (
795
+ <option key={root.id}
796
+ value={root.id}>
797
+ {root.label}
798
+ </option>
799
+ ))}
800
+ </select>
801
+ <span className="settings__root-hint">
802
+ {state.kind === 'prefabs'
803
+ ? 'What the prefab previews inside here; on a page, only its blocks are placed'
804
+ : 'The block the whole page renders inside — its shell, styles and scripts'}
805
+ </span>
806
+ </label>
807
+ </Section>
808
+ {ssrToggle}
809
+ <Section tab="main">
810
+ <PageMetaForm session={session}
811
+ meta={state.doc.meta ?? {}} />
812
+ </Section>
813
+ </div>
814
+ );
815
+ }
816
+ if (offNamespace) {
817
+ const distributions = catalog.isSuccess() && catalog.serverData.ok ? catalog.serverData.distributions ?? [] : [];
818
+ return (
819
+ <div className="settings"
820
+ data-test-id="settings"
821
+ data-settings-disconnected={node.id}
822
+ data-settings-draft={anchorDraft}>
823
+ <Section tab="main">
824
+ <DisconnectedNotice session={session}
825
+ nodeId={node.id}
826
+ title={distributions.find((d) => d.namespace === offNamespace)?.title ?? offNamespace}
827
+ readOnly={state.revision !== null}
828
+ platformOrigin={disconnection.platformOrigin} />
829
+ </Section>
830
+ </div>
831
+ );
832
+ }
833
+ // a PREFAB placed on the page: its blocks are the prefab's — only the fields it opens are set here
834
+ if (unitOf(node.reference)) {
835
+ return (
836
+ <div className="settings"
837
+ data-test-id="settings"
838
+ data-settings-prefab={node.id}
839
+ data-settings-draft={anchorDraft}>
840
+ <Section tab="main">
841
+ <PrefabInstanceForm session={session}
842
+ node={node}
843
+ prefab={project.treeOf(node.reference)} />
844
+ </Section>
845
+ </div>
846
+ );
847
+ }
848
+ // in a prefab, a block's settings can be opened to the pages that place it
849
+ const openFields = state.kind === 'prefabs'
850
+ ? (
851
+ <Section tab="other">
852
+ <OpenFieldsForm session={session}
853
+ doc={state.doc}
854
+ node={node} />
855
+ </Section>
856
+ )
857
+ : null;
858
+ if (!entry) {
859
+ return <div className="settings settings__hint"
860
+ data-form-loading="">Loading the blocks…</div>;
861
+ }
862
+ if (!entry.hasEditor) {
863
+ return (
864
+ <div className="settings"
865
+ data-no-editor={node.reference}
866
+ data-settings-draft={anchorDraft}>
867
+ {upgradeLine}
868
+ {ssrToggle}
869
+ <SettingsIssues issues={issues}
870
+ container={noForm}
871
+ onRemove={state.revision !== null ? undefined : (path) => session.updateNodesSettings([{ nodeId: node.id, patch: removalPatch(node.settings ?? {}, path) }])} />
872
+ <Section tab="main">
873
+ <div className="settings__hint">The block has no settings form</div>
874
+ </Section>
875
+ {openFields}
876
+ </div>
877
+ );
878
+ }
879
+
880
+ return (
881
+ <div className="settings"
882
+ data-test-id="settings"
883
+ data-settings-form={node.id}
884
+ data-settings-draft={anchorDraft}>
885
+ <HostedForm session={session}
886
+ state={state}
887
+ node={node}
888
+ spec={spec}
889
+ issues={issues}
890
+ header={(
891
+ <>
892
+ {upgradeLine}
893
+ {ssrToggle}
894
+ <AutoBindToggle session={session}
895
+ node={node} />
896
+ </>
897
+ )} />
898
+ {openFields}
899
+ </div>
900
+ );
901
+ }
902
+
903
+ /**
904
+ * The selected node's settings. A published revision is read-only (versions are immutable — the
905
+ * session refuses every edit), and the settings look it rather than look editable (the canvas
906
+ * toolbar already says why): everything sits in a DISABLED FIELDSET, and the browser disables every control inside it — the ssr flag, the
907
+ * meta, the reference pickers and the block's own mounted form alike, whatever binding drew it.
908
+ */
909
+ export function NodeSettings({ session, state }: { session: EditingSession; state: IEditingState }) {
910
+ const readOnly = state.revision !== null;
911
+ return (
912
+ // a prefab's block is shown read-only anyway, and its ways on (to the prefab, to its node)
913
+ // lead out of the revision — they stay pressable (its body disables the rest itself)
914
+ <fieldset className="settings__fieldset"
915
+ disabled={readOnly && !isPartId(state.selectedNodeId)}
916
+ data-read-only={readOnly || undefined}>
917
+ <NodeSettingsBody session={session}
918
+ state={state} />
919
+ </fieldset>
920
+ );
921
+ }
922
+
923
+ // ---- which host shows the settings --------------------------------------------------------
924
+
925
+ /**
926
+ * How many settings PANELS the workbench has mounted. A workbench that still docks one keeps its
927
+ * forms there; otherwise the canvas shows them in a popover beside the selected block.
928
+ */
929
+ export class SettingsPanels {
930
+ private count = 0;
931
+
932
+ private readonly listeners = new Set<() => void>();
933
+
934
+ readonly snapshot = (): number => this.count;
935
+
936
+ readonly subscribe = (listener: () => void): (() => void) => {
937
+ this.listeners.add(listener);
938
+ return () => {
939
+ this.listeners.delete(listener);
940
+ };
941
+ };
942
+
943
+ /** a panel came up; the returned function is its going away */
944
+ mount(): () => void {
945
+ this.count += 1;
946
+ this.notify();
947
+ return () => {
948
+ this.count -= 1;
949
+ this.notify();
950
+ };
951
+ }
952
+
953
+ private notify(): void {
954
+ for (const listener of [...this.listeners]) listener();
955
+ }
956
+ }
957
+
958
+ const SETTINGS_PANELS_TOKEN = new SingleDiToken<SettingsPanels>('EDITOR_SETTINGS_PANELS_TOKEN');
959
+
960
+ export function ensureSettingsPanels(di: DiContainer): SettingsPanels {
961
+ if (!di.hasByToken(SETTINGS_PANELS_TOKEN)) di.registerValueOfToken(SETTINGS_PANELS_TOKEN, new SettingsPanels());
962
+ return di.getByToken(SETTINGS_PANELS_TOKEN);
963
+ }