@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,257 @@
1
+ // The selected block's settings, over the canvas: beside the block where there is room
2
+ // (popoverPlacement.ts). A grip in the popover's free corner resizes it; the size the author
3
+ // settles on is their wish for every popover after (popoverSize.ts), fitted to the room each
4
+ // one gets. The same NodeSettings a settings panel would show — the canvas only decides where
5
+ // it goes and how big it may be.
6
+ //
7
+ // The wish is a LEAST size: a form with more in it grows taller, up to the room there is, and a
8
+ // form with a wide field (a paragraph's text, `data-wide-field`) widens the card for it — the
9
+ // popover tells the canvas what its content asks for (`onContent`), the canvas places it. The
10
+ // rest of the form keeps the wished width (canvas.css), so a wide text does not stretch a select.
11
+ //
12
+ // ONE PIECE WITH THE SELECTION: laid against the block (popoverPlacement.ts), the popover wears the
13
+ // selection's square blue frame and laps over the block's own by its 2px — where the two meet (the
14
+ // seam) they share one line, the way a tab and its panel do.
15
+ //
16
+ // LOADING: the block's form is its module's, loaded when the settings open. Until it has arrived
17
+ // (the host marks it `data-form-loading`) the popover is laid out and measured but unseen, and the
18
+ // canvas turns the settings button meanwhile (`onLoading`): the card appears once, whole, where it
19
+ // stays — not a header in one place first and the full form in another a moment later.
20
+ //
21
+ // TABS: the settings come in sections that name their tab (settingsTabs.ts) — Main, Styles, Other.
22
+ // The popover shows one tab at a time, and the tab bar only when there is more than one; the
23
+ // author's last pick is the tab every popover after opens on, where the block has it.
24
+ //
25
+ // VERSION: the head names where the block comes from — its distribution and the version the page
26
+ // names — whatever tab is shown; a newer one deployed is marked beside it (the Main tab says how to
27
+ // move to it). A prefab is live and a tool's block runs at its latest: neither shows one.
28
+ import { useLayoutEffect, useRef, useState, type CSSProperties, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react';
29
+ import type { EditingSession, IEditingState } from '@mosetta/content';
30
+ import type { IPlacedVersion } from '../content/catalogVersions';
31
+ import { NodeSettings } from '../content/nodeSettings';
32
+ import type { IPopoverPlacement, ISeam } from './popoverPlacement';
33
+ import { DEFAULT_POPOVER_SIZE, gripCornerOf, resizeBox, type IPopoverSize, type IResizedBox } from './popoverSize';
34
+ import { readSettingsTab, SETTINGS_TABS, shownTab, tabOf, tabsIn, writeSettingsTab, type TSettingsTab } from './settingsTabs';
35
+
36
+ /**
37
+ * The localStorage of the WINDOW this renders in — not the bare global, which in a host running the
38
+ * tree in another realm (a test's jsdom window) is someone else's — or null where reading it throws
39
+ * (blocked site data, sandboxing).
40
+ */
41
+ function storageOrNull(): Storage | null {
42
+ try {
43
+ return typeof window === 'undefined' ? null : window.localStorage;
44
+ } catch {
45
+ return null;
46
+ }
47
+ }
48
+
49
+ const nameOf = (reference: string) => /_n_(.+?)_v_/.exec(reference)?.[1] ?? reference;
50
+
51
+ interface IDrag {
52
+ /** the placement at the grab: the edges opposite the grip stay where they were */
53
+ readonly placement: IPopoverPlacement;
54
+ readonly start: { readonly width: number; readonly height: number };
55
+ readonly from: { readonly x: number; readonly y: number };
56
+ /** client pixels per stage pixel (an emulated viewport scales the stage) */
57
+ readonly scale: number;
58
+ readonly box: IResizedBox;
59
+ }
60
+
61
+ /** what the form inside asks for: its height with room enough, and whether it holds a wide field */
62
+ export interface IPopoverContent {
63
+ readonly height: number;
64
+ readonly wide: boolean;
65
+ }
66
+
67
+ export function SettingsPopover({ session, state, hidden, placement, size, onResize, onContent, onLoading, onClose, seam, seamGhost, stage, reference, placed }: {
68
+ session: EditingSession;
69
+ state: IEditingState;
70
+ /** out of the way for a moment (the page scrolls, the block is out of sight) — kept, not closed */
71
+ hidden: boolean;
72
+ placement: IPopoverPlacement;
73
+ /** the author's wished size; the placement already fitted its width */
74
+ size: IPopoverSize;
75
+ onResize(size: IPopoverSize): void;
76
+ onContent(content: IPopoverContent): void;
77
+ /** whether the block's form is still on its way — the popover is unseen meanwhile */
78
+ onLoading?(loading: boolean): void;
79
+ /** the settings put away — the block stays selected */
80
+ onClose(): void;
81
+ /** where the popover joins the block's frame — one line shared with it there */
82
+ seam?: ISeam | null;
83
+ /** the place under the block, when the popover lies there: it rides on the seam, in the head */
84
+ seamGhost?: ReactNode;
85
+ stage: { width: number; height: number };
86
+ reference: string;
87
+ /** the distribution and version the block comes from, when it names one */
88
+ placed?: IPlacedVersion | null;
89
+ }) {
90
+ const rootRef = useRef<HTMLDivElement>(null);
91
+ const bodyRef = useRef<HTMLDivElement>(null);
92
+ const [drag, setDrag] = useState<IDrag | null>(null);
93
+ // the form on its way: unseen until it arrives (a block without a form arrives at once — the
94
+ // first layout effect says so before anything is painted)
95
+ const [loading, setLoading] = useState(true);
96
+ // told in the same commit — the button stops turning as the card appears, not a tick later
97
+ useLayoutEffect(() => onLoading?.(loading), [loading, onLoading]);
98
+ useLayoutEffect(() => () => onLoading?.(false), [onLoading]);
99
+ // the tabs the sections fill, and the one the author picked last
100
+ const [tabs, setTabs] = useState<readonly TSettingsTab[]>([]);
101
+ const [wishedTab, setWishedTab] = useState<TSettingsTab>(() => readSettingsTab(storageOrNull()));
102
+ const tab = shownTab(tabs, wishedTab);
103
+ const pickTab = (next: TSettingsTab) => {
104
+ setWishedTab(next);
105
+ writeSettingsTab(storageOrNull(), next);
106
+ };
107
+ const place = drag?.placement ?? placement;
108
+ const corner = gripCornerOf(place);
109
+ // the wished height is the least one; more content grows the card, up to the room there is
110
+ const least = size.height !== null ? Math.min(size.height, placement.maxHeight) : undefined;
111
+ const style: CSSProperties & Record<'--settings-wish-width', string> = {
112
+ left: `${drag ? drag.box.x : placement.x}px`,
113
+ width: `${drag ? drag.box.width : placement.width}px`,
114
+ maxHeight: `${place.maxHeight}px`,
115
+ ...(drag ? { height: `${drag.box.height}px` } : least !== undefined ? { minHeight: `${least}px` } : {}),
116
+ top: `${place.y}px`,
117
+ ...(place.fromBottom ? { transform: 'translateY(-100%)' } : {}),
118
+ // the width the author wished for: what the form's ordinary controls keep (canvas.css)
119
+ '--settings-wish-width': `${size.width}px`,
120
+ };
121
+
122
+ // what the content asks for, told whenever it changes: the height it would take with room (the
123
+ // card's own chrome plus the whole scrolled body) and whether a wide field is on the tab shown —
124
+ // and which tabs its sections fill, as the mounted form comes and changes
125
+ useLayoutEffect(() => {
126
+ const root = rootRef.current;
127
+ const body = bodyRef.current;
128
+ if (!root || !body) return undefined;
129
+ let told: IPopoverContent | null = null;
130
+ const tell = () => {
131
+ setLoading(body.querySelector('[data-form-loading]') !== null);
132
+ const found = tabsIn(body);
133
+ setTabs((prev) => (prev.join('|') === found.join('|') ? prev : found));
134
+ const showing = body.getAttribute('data-settings-tab');
135
+ const wide = [...body.querySelectorAll('[data-wide-field]')].some((field) => !showing || tabOf(field) === showing);
136
+ const next = { height: root.offsetHeight - body.clientHeight + body.scrollHeight, wide };
137
+ if (told && told.wide === next.wide && Math.abs(told.height - next.height) < 2) return;
138
+ told = next;
139
+ onContent(next);
140
+ };
141
+ tell();
142
+ const view = root.ownerDocument.defaultView;
143
+ const resize = view?.ResizeObserver ? new view.ResizeObserver(tell) : null;
144
+ if (body.firstElementChild) resize?.observe(body.firstElementChild);
145
+ const mutations = view?.MutationObserver ? new view.MutationObserver(tell) : null;
146
+ // the tab shown is an attribute of the body: switching it is a change of content too
147
+ // …and so is the form's arrival
148
+ mutations?.observe(body, { subtree: true, childList: true, attributes: true, attributeFilter: ['data-settings-tab', 'data-form-loading'] });
149
+ return () => {
150
+ resize?.disconnect();
151
+ mutations?.disconnect();
152
+ };
153
+ }, [onContent]);
154
+
155
+ const grab = (e: ReactPointerEvent<HTMLDivElement>) => {
156
+ const root = rootRef.current;
157
+ if (!root || e.button !== 0) return;
158
+ e.preventDefault();
159
+ e.stopPropagation();
160
+ e.currentTarget.setPointerCapture?.(e.pointerId);
161
+ const rect = root.getBoundingClientRect();
162
+ const scale = placement.width > 0 && rect.width > 0 ? rect.width / placement.width : 1;
163
+ const start = { width: placement.width, height: rect.height / scale };
164
+ setDrag({ placement, start, from: { x: e.clientX, y: e.clientY }, scale, box: { x: placement.x, ...start } });
165
+ };
166
+ const follow = (e: ReactPointerEvent<HTMLDivElement>) => {
167
+ if (!drag) return;
168
+ const delta = { dx: (e.clientX - drag.from.x) / drag.scale, dy: (e.clientY - drag.from.y) / drag.scale };
169
+ setDrag({ ...drag, box: resizeBox(drag.placement, drag.start, delta, stage) });
170
+ };
171
+ const release = () => {
172
+ if (!drag) return;
173
+ onResize({ width: Math.round(drag.box.width), height: Math.round(drag.box.height) });
174
+ setDrag(null);
175
+ };
176
+
177
+ return (
178
+ <div ref={rootRef}
179
+ className={`canvas-view__settings${drag ? ' canvas-view__settings--resizing' : ''}${corner.vertical === 'top' ? ' canvas-view__settings--grip-top' : ''}${hidden || loading ? ' canvas-view__settings--hidden' : ''}${seam ? ' canvas-view__settings--joined' : ''}`}
180
+ style={style}
181
+ data-test-id="canvas-settings"
182
+ data-settings-hidden={hidden || undefined}
183
+ data-settings-loading={loading || undefined}
184
+ aria-busy={loading || undefined}
185
+ data-settings-side={placement.side}
186
+ data-settings-seam={seam?.edge}
187
+ // the canvas's own pointer and key handling is for the page, not for the form
188
+ onMouseDown={(e) => e.stopPropagation()}>
189
+ <div className="canvas-view__settings-head">
190
+ <span className="canvas-view__settings-name">{nameOf(reference)}</span>
191
+ <span className="canvas-view__settings-id">{state.selectedNodeId}</span>
192
+ {placed ? (
193
+ <span className="canvas-view__settings-version"
194
+ data-test-id="canvas-settings-version"
195
+ data-newer={placed.newer ?? undefined}
196
+ data-tooltip={placed.newer
197
+ ? `This page uses ${placed.namespace} ${placed.version}; ${placed.newer} is out — the Main tab updates the block`
198
+ : 'The tile palette and version this block comes from'}>
199
+ {`${placed.namespace}@${placed.version}`}
200
+ {placed.newer ? <span className="canvas-view__settings-newer">{`↑ ${placed.newer}`}</span> : null}
201
+ </span>
202
+ ) : null}
203
+ {seamGhost ? <span className="canvas-view__settings-seam-ghost">{seamGhost}</span> : null}
204
+ <button type="button"
205
+ className="canvas-view__settings-action"
206
+ data-test-id="canvas-settings-close"
207
+ data-tooltip="Put the settings away — the block stays selected"
208
+ aria-label="Close settings"
209
+ onClick={onClose}>
210
+ ×
211
+ </button>
212
+ </div>
213
+ {tabs.length > 1 ? (
214
+ <div className="canvas-view__settings-tabs"
215
+ role="tablist"
216
+ aria-label="Settings">
217
+ {SETTINGS_TABS.filter((entry) => tabs.includes(entry.id)).map((entry) => (
218
+ <button key={entry.id}
219
+ type="button"
220
+ role="tab"
221
+ className={`canvas-view__settings-tab${entry.id === tab ? ' canvas-view__settings-tab--on' : ''}`}
222
+ data-test-id="canvas-settings-tab"
223
+ data-tab={entry.id}
224
+ data-tooltip={entry.hint}
225
+ aria-selected={entry.id === tab}
226
+ onClick={() => pickTab(entry.id)}>
227
+ {entry.label}
228
+ </button>
229
+ ))}
230
+ </div>
231
+ ) : null}
232
+ <div ref={bodyRef}
233
+ className="canvas-view__settings-body"
234
+ data-settings-tab={tab}>
235
+ <NodeSettings session={session}
236
+ state={state} />
237
+ </div>
238
+ <div className={`canvas-view__settings-grip canvas-view__settings-grip--${corner.vertical}-${corner.horizontal}`}
239
+ data-test-id="canvas-settings-grip"
240
+ data-tooltip="Drag to resize the settings — the size is remembered · double-click for the default"
241
+ aria-hidden="true"
242
+ onPointerDown={grab}
243
+ onPointerMove={follow}
244
+ onPointerUp={release}
245
+ onPointerCancel={release}
246
+ onDoubleClick={() => onResize(DEFAULT_POPOVER_SIZE)}>
247
+ <svg viewBox="0 0 10 10">
248
+ <path d="M9 3L3 9M9 6.5L6.5 9"
249
+ fill="none"
250
+ stroke="currentColor"
251
+ strokeWidth="1.2"
252
+ strokeLinecap="round" />
253
+ </svg>
254
+ </div>
255
+ </div>
256
+ );
257
+ }
@@ -0,0 +1,78 @@
1
+ // The settings popover's TABS. The catalog is the editor's own — what the tabs are, in what order,
2
+ // what they are called; a section of the settings only says which tab it belongs to. A block's
3
+ // form says it by wrapping a section (`<BlockInspector tab="styles">` writes the attribute below,
4
+ // the kit's styler wears it), the editor's own sections say it the same way. Anything a form leaves
5
+ // unnamed is the block's main settings; a name the catalog does not know goes under Other.
6
+ //
7
+ // Read off the DOM, like the reference circles' `data-field`: the form is a mounted application of
8
+ // its own, and the attribute is the whole contract between it and the popover around it.
9
+
10
+ export const INSPECTOR_TAB_ATTRIBUTE = 'data-inspector-tab';
11
+
12
+ /** the class of the element a block's form is mounted into — where unnamed content means "main" */
13
+ export const FORM_HOST_CLASS = 'settings__form-host';
14
+
15
+ export const SETTINGS_TABS = [
16
+ { id: 'main', label: 'Main', hint: 'What the block is: its content and its options' },
17
+ { id: 'styles', label: 'Styles', hint: 'How the block looks: the styles of its parts' },
18
+ { id: 'other', label: 'Other', hint: 'Everything else: server rendering, the fields a prefab opens…' },
19
+ ] as const;
20
+
21
+ export type TSettingsTab = (typeof SETTINGS_TABS)[number]['id'];
22
+
23
+ /** a section's own name, as the catalog knows it */
24
+ export const catalogTabOf = (name: string | null): TSettingsTab => (name === 'main' || name === 'styles' ? name : name ? 'other' : 'main');
25
+
26
+ /** the tab an element of the settings shows under: its nearest named section's, or main */
27
+ export function tabOf(element: Element): TSettingsTab {
28
+ return catalogTabOf(element.closest(`[${INSPECTOR_TAB_ATTRIBUTE}]`)?.getAttribute(INSPECTOR_TAB_ATTRIBUTE) ?? null);
29
+ }
30
+
31
+ /** elements that carry no content of their own */
32
+ const SILENT = new Set(['STYLE', 'LINK', 'SCRIPT', 'TEMPLATE']);
33
+
34
+ /** whether a form host holds anything outside every named section — its main settings */
35
+ function hasUnnamed(element: Element): boolean {
36
+ for (const child of element.children) {
37
+ if (SILENT.has(child.tagName) || child.hasAttribute(INSPECTOR_TAB_ATTRIBUTE)) continue;
38
+ if (!child.querySelector(`[${INSPECTOR_TAB_ATTRIBUTE}]`)) return true;
39
+ if (hasUnnamed(child)) return true;
40
+ }
41
+ return false;
42
+ }
43
+
44
+ /** the tabs the sections of a settings body fill, in the catalog's order */
45
+ export function tabsIn(body: Element): TSettingsTab[] {
46
+ const found = new Set<TSettingsTab>();
47
+ for (const section of body.querySelectorAll(`[${INSPECTOR_TAB_ATTRIBUTE}]`)) {
48
+ found.add(catalogTabOf(section.getAttribute(INSPECTOR_TAB_ATTRIBUTE)));
49
+ }
50
+ for (const host of body.querySelectorAll(`.${FORM_HOST_CLASS}`)) {
51
+ if (hasUnnamed(host)) found.add('main');
52
+ }
53
+ return SETTINGS_TABS.map((tab) => tab.id).filter((id) => found.has(id));
54
+ }
55
+
56
+ /** the tab a body shows when the author's last pick is not among its tabs: the first one */
57
+ export function shownTab(tabs: readonly TSettingsTab[], wished: TSettingsTab): TSettingsTab {
58
+ return tabs.includes(wished) ? wished : tabs[0] ?? 'main';
59
+ }
60
+
61
+ const TAB_STORAGE_KEY = 'mosetta:settings-tab';
62
+
63
+ /** the author's last pick, remembered across popovers and reloads (any storage failure: main) */
64
+ export function readSettingsTab(storage: Storage | null): TSettingsTab {
65
+ try {
66
+ return catalogTabOf(storage?.getItem(TAB_STORAGE_KEY) ?? null);
67
+ } catch {
68
+ return 'main';
69
+ }
70
+ }
71
+
72
+ export function writeSettingsTab(storage: Storage | null, tab: TSettingsTab): void {
73
+ try {
74
+ storage?.setItem(TAB_STORAGE_KEY, tab);
75
+ } catch {
76
+ // a blocked storage forgets the pick — the tab still switches
77
+ }
78
+ }
@@ -0,0 +1,86 @@
1
+ // An EDITOR TOOL over the canvas (ADR-0062, canvasTools.ts): the tool's node mounted over the stage
2
+ // — the tree beside the canvas stays in sight — in a DI scope of its own that holds what the tool
3
+ // works on. Each canvas mounts its own copy under an id of its own: two tabs with the tool open
4
+ // are two tools, not one node twice.
5
+ import { BlueprintNodeRenderer, ProvideDiScope, useDi, useDiScope } from '@mosetta/react';
6
+ import { BlockScope, DI_CONTAINER_TOKEN } from '@mosetta/di';
7
+ import type { EditingSession } from '@mosetta/content';
8
+ import { useEffect, useMemo, useRef, useState } from 'react';
9
+ import type { RefObject } from 'react';
10
+ import { CANVAS_TOOL_TOKEN } from './canvasTools';
11
+ import type { IToolNode } from './canvasTools';
12
+
13
+ /** how long a tool waits for the preview's fonts before it reads none — a cold workbench brings its preview in seconds */
14
+ const FONTS_PATIENCE_MS = 30_000;
15
+
16
+ export function ToolPanel({ node, instance, session, previewHref, frame, state, onClose }: {
17
+ node: IToolNode;
18
+ /** the canvas's own name — the mounted node's id is the tool's and this */
19
+ instance: string;
20
+ session: EditingSession;
21
+ previewHref: string;
22
+ /** the canvas's preview frame */
23
+ frame: RefObject<HTMLIFrameElement | null>;
24
+ /** where the tool's own state is kept — the canvas hands it to the workbench's address */
25
+ state: { read(): string | null; write(state: string | null): void };
26
+ onClose: () => void;
27
+ }) {
28
+ const di = useDi(DI_CONTAINER_TOKEN);
29
+ const parentScope = useDiScope();
30
+ // read through, so the scope made once keeps answering the current ones
31
+ const href = useRef(previewHref);
32
+ href.current = previewHref;
33
+ const close = useRef(onClose);
34
+ close.current = onClose;
35
+ const kept = useRef(state);
36
+ kept.current = state;
37
+ const [scope] = useState(() => {
38
+ const toolScope = new BlockScope(parentScope);
39
+ di.registerScope(toolScope);
40
+ di.registerValueOfToken(CANVAS_TOOL_TOKEN, {
41
+ session,
42
+ previewHref: () => href.current,
43
+ loadedFonts: async () => {
44
+ const deadline = Date.now() + FONTS_PATIENCE_MS;
45
+ for (;;) {
46
+ let document: Document | null | undefined;
47
+ try {
48
+ document = frame.current?.contentWindow?.document;
49
+ } catch {
50
+ return []; // a frame of another origin keeps its document to itself
51
+ }
52
+ // the preview's own document, not the blank one a frame starts with
53
+ if (document && document.readyState === 'complete' && document.location.href !== 'about:blank') {
54
+ const fonts = (document as Document & { fonts?: FontFaceSet }).fonts;
55
+ if (!fonts) return [];
56
+ await fonts.ready;
57
+ return [...fonts].filter((face) => face.status === 'loaded').map(({ family, weight, style, unicodeRange }) => ({ family, weight, style, unicodeRange }));
58
+ }
59
+ if (Date.now() > deadline) return [];
60
+ await new Promise((resolve) => setTimeout(resolve, 200));
61
+ }
62
+ },
63
+ readState: () => kept.current.read(),
64
+ writeState: (next: string | null) => kept.current.write(next),
65
+ close: () => close.current(),
66
+ }, toolScope);
67
+ return toolScope;
68
+ });
69
+ useEffect(() => () => di.disposeScope(scope), [di, scope]);
70
+ const mounted = useMemo(() => ({
71
+ id: `${node.id}-${instance}`,
72
+ reference: node.reference,
73
+ children: [],
74
+ ...(node.settings ? { settings: node.settings } : {}),
75
+ }), [node.id, node.reference, node.settings, instance]);
76
+ return (
77
+ <div className="canvas-view__tool"
78
+ data-test-id="canvas-tool"
79
+ data-tool={node.id}>
80
+ <ProvideDiScope scope={scope}>
81
+ <BlueprintNodeRenderer moduleTreeNode={mounted}
82
+ inheritedSsr={false} />
83
+ </ProvideDiScope>
84
+ </div>
85
+ );
86
+ }
@@ -0,0 +1,61 @@
1
+ // AUTO-BINDING: a provider that takes new blocks under its wing (a node's `autoBind`, switched
2
+ // in its settings) links the bindable fields of ITS TYPE of every block that arrives in its reach
3
+ // — inserted, pasted — in the very step of the arrival: EditingSession.amendStep folds the links
4
+ // into it, and one Ctrl+Z takes the block and its links back together. A text placed on a page
5
+ // with i18n set so is in i18n from its first moment, counted among the untranslated at once.
6
+ //
7
+ // Watched from the session, not wired into each way a block can arrive: whatever step put new
8
+ // nodes on the page is the step that gets their links. Each step is looked at once — an undo
9
+ // that brings an older insert back to the top of the history is no arrival.
10
+ import type { EditingSession, IBlueprintNodeDoc, IPageAction, IPageDoc } from '@mosetta/content';
11
+ import type { RefsEditing } from './refsEditing';
12
+
13
+ const ARRIVALS = new Set<IPageAction['type']>(['insert', 'paste']);
14
+
15
+ function idsOf(node: IBlueprintNodeDoc | IPageDoc, into: Set<string> = new Set()): Set<string> {
16
+ into.add(node.id);
17
+ for (const child of node.children ?? []) idsOf(child, into);
18
+ return into;
19
+ }
20
+
21
+ /** whether some node of the page takes new blocks under its wing — nothing to do on most pages */
22
+ function anyKeeper(node: IBlueprintNodeDoc | IPageDoc): boolean {
23
+ if ((node as { autoBind?: boolean }).autoBind) return true;
24
+ return (node.children ?? []).some(anyKeeper);
25
+ }
26
+
27
+ /** the nodes the last step put on the page: in the page now, not in the page it was before */
28
+ function arrivedIn(doc: IPageDoc): string[] {
29
+ const before = doc.history?.[doc.history.length - 1]?.before;
30
+ if (!before) return [];
31
+ const had = idsOf(before);
32
+ return [...idsOf(doc)].filter((id) => !had.has(id));
33
+ }
34
+
35
+ /** starts watching `session`; the returned function stops */
36
+ export function watchArrivals(session: EditingSession, refs: RefsEditing): () => void {
37
+ const seen = new WeakSet<IPageAction>();
38
+ const look = (): void => {
39
+ const { doc } = session.snapshot();
40
+ const step = doc?.history?.[doc.history.length - 1]?.action;
41
+ if (!doc || !step || seen.has(step)) return;
42
+ seen.add(step);
43
+ if (!ARRIVALS.has(step.type) || !anyKeeper(doc)) return;
44
+ const arrived = arrivedIn(doc);
45
+ if (!arrived.length) return;
46
+ void (async () => {
47
+ // the providers' adapters and the blocks' declarations are module code: load what the page names
48
+ await refs.loadAll(doc);
49
+ const now = session.snapshot();
50
+ if (!now.doc) return;
51
+ // computed on the page as it is NOW — the author may have typed meanwhile
52
+ const patches = refs.autoBindPatches(now.doc, arrived, now.views);
53
+ session.amendStep(step, patches);
54
+ })();
55
+ };
56
+ // whatever the page already holds is no arrival
57
+ const start = session.snapshot().doc?.history;
58
+ const last = start?.[start.length - 1]?.action;
59
+ if (last) seen.add(last);
60
+ return session.subscribe(look);
61
+ }