@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,178 @@
1
+ // THE DRAFT the workbench exposes to hosted settings forms — registered in the WORKBENCH's own
2
+ // container under a named token; the form reads it through the parent-container door
3
+ // (PARENT_DI_TOKEN → SETTINGS_DRAFT_TOKEN). One store for the workbench's lifetime, reading
4
+ // whatever is selected when asked: selecting another node hands the same store a new subject.
5
+ //
6
+ // REFERENCES: the form sees VALUES. A setting bound to a provider (`{ "$ref": "i18n/k3" }`) is
7
+ // shown as the provider hands it out in the author's current view, and an edit of it goes to the
8
+ // provider's settings (refsEditing.ts) — one undoable step either way. A snapshot keeps its
9
+ // identity while nothing it shows changed, as the store contract requires.
10
+ import { DiContainer } from '@mosetta/di';
11
+ import { editingSessionHubOf, EditingSession, findNodeDoc } from '@mosetta/content';
12
+ import type { IPageMetaWords } from '@mosetta/content';
13
+ import { isRefValue } from '@mosetta/runtime/client';
14
+ import type { IRefTreeNode } from '@mosetta/runtime/client';
15
+ import { EDITOR_VIEWS_TOKEN, SETTINGS_DRAFT_TOKEN } from '@mosetta/react';
16
+ import type { IFieldBinding } from '@mosetta/react';
17
+ import { ensureRefsEditing } from './refsEditing';
18
+ import { ensurePrefabTrees, isPartId, prefabPartOf } from './prefabParts';
19
+
20
+ /** the settings of a node that has none — one object, so a snapshot never changes identity */
21
+ const NO_SETTINGS: Record<string, unknown> = {};
22
+
23
+ interface IShown {
24
+ readonly settings: Record<string, unknown>;
25
+ readonly doc: unknown;
26
+ readonly views: unknown;
27
+ readonly result: Record<string, unknown>;
28
+ }
29
+
30
+ const sameValues = (a: Record<string, unknown>, b: Record<string, unknown>): boolean => {
31
+ const keys = Object.keys(a);
32
+ return keys.length === Object.keys(b).length && keys.every((key) => Object.is(a[key], b[key]));
33
+ };
34
+
35
+ /**
36
+ * Brought into being on first use, like the session — it belongs to the WORKBENCH, not to the
37
+ * host that happened to ask first. So it follows the hub's SELECTION facade — the session where
38
+ * the last pick happened — never the asking canvas's own session: with two canvases (a duplicate
39
+ * opens a second one), a draft bound to the first would show the first canvas's selection in the
40
+ * second one's popover and write the second one's edits into the first.
41
+ */
42
+ export function ensureSettingsDraft(di: DiContainer, asking: EditingSession): void {
43
+ if (di.hasByToken(SETTINGS_DRAFT_TOKEN)) return;
44
+ const hub = editingSessionHubOf(di);
45
+ const session = hub?.selection ?? asking;
46
+ // a provider's chrome asks about the page in the canvas being worked in — the ACTIVE one; the
47
+ // last pick may live in another canvas, with another page
48
+ const working = hub?.active ?? asking;
49
+ const listeners = new Set<() => void>();
50
+ let shown: IShown | null = null;
51
+ const refs = ensureRefsEditing(di);
52
+ const trees = ensurePrefabTrees(di);
53
+ // a provider's module arrived, or a prefab's tree: what the form shows may resolve differently now
54
+ const changed = () => {
55
+ shown = null;
56
+ for (const listener of [...listeners]) listener();
57
+ };
58
+ refs.subscribe(changed);
59
+ trees.subscribe(changed);
60
+
61
+ /**
62
+ * The node the draft is about, in the document it lives in. A PREFAB's block selected on a page
63
+ * (prefabParts.ts) lives in the prefab's own document — its settings and the providers its
64
+ * references read are there — and is shown, never written (`part`).
65
+ */
66
+ const current = () => {
67
+ const now = session.snapshot();
68
+ const id = now.selectedNodeId;
69
+ const own = now.doc && id ? findNodeDoc(now.doc, id) : null;
70
+ const part = !own && now.doc && isPartId(id) ? prefabPartOf(now.doc, id, trees.treeOf) : null;
71
+ if (part) return { now, node: part.node, doc: part.prefab.doc as unknown as IRefTreeNode, part: true };
72
+ return { now, node: own, doc: now.doc as unknown as IRefTreeNode | null, part: false };
73
+ };
74
+ // the document a scope question is about: the selected node's, or — a provider named — the
75
+ // working canvas's, with the provider's id
76
+ const scopeOf = (providerId?: string): { doc: IRefTreeNode | null; id: string | null } => {
77
+ if (providerId) return { doc: working.snapshot().doc, id: providerId };
78
+ const { node, doc } = current();
79
+ return { doc, id: node?.id ?? null };
80
+ };
81
+
82
+ di.registerValueOfToken(SETTINGS_DRAFT_TOKEN, {
83
+ snapshot: () => {
84
+ const { now, node, doc } = current();
85
+ if (!node || !doc) return NO_SETTINGS;
86
+ const settings = node.settings ?? NO_SETTINGS;
87
+ if (!Object.values(settings).some(isRefValue)) return settings;
88
+ if (shown && shown.settings === settings && shown.doc === doc && shown.views === now.views) return shown.result;
89
+ const result = refs.resolve(doc, node.id, settings, now.views);
90
+ // another node's edit elsewhere must not hand the form a new object
91
+ if (shown && shown.settings === settings && sameValues(shown.result, result)) {
92
+ shown = { ...shown, doc, views: now.views };
93
+ return shown.result;
94
+ }
95
+ shown = { settings, doc, views: now.views, result };
96
+ return result;
97
+ },
98
+ subscribe: (listener: () => void) => {
99
+ listeners.add(listener);
100
+ const off = session.subscribe(listener);
101
+ // the working canvas too: a chrome follows its page, the form its selection
102
+ const offWorking = working !== session ? working.subscribe(listener) : () => undefined;
103
+ return () => {
104
+ listeners.delete(listener);
105
+ off();
106
+ offWorking();
107
+ };
108
+ },
109
+ update: (patch: Record<string, unknown>) => {
110
+ const { now, node, doc, part } = current();
111
+ if (!node || !doc || part) return;
112
+ session.updateNodesSettings(refs.writePatches(doc, node.id, node.settings ?? {}, patch, now.views));
113
+ },
114
+ binding: (field: string): IFieldBinding => {
115
+ const { node, doc } = current();
116
+ if (!node || !doc) return { provider: null, providers: [] };
117
+ const value = node.settings?.[field];
118
+ const provider = isRefValue(value) ? value.$ref.split('/')[0] : null;
119
+ return { provider, providers: refs.reachable(doc, node.id, field) };
120
+ },
121
+ bind: (field: string, provider: string, key?: string) => {
122
+ const { now, node, doc, part } = current();
123
+ if (!node || !doc || part) return;
124
+ session.updateNodesSettings(refs.bindPatches(doc, node.id, node.settings ?? {}, field, provider, now.views, key));
125
+ },
126
+ unbind: (field: string) => {
127
+ const { node, doc, part } = current();
128
+ if (!node || !doc || part) return;
129
+ session.updateNodesSettings(refs.unbindPatches(doc, node.id, node.settings ?? {}, field));
130
+ },
131
+ bindScope: async () => {
132
+ const start = current();
133
+ if (!start.node || !start.doc || start.part) return 0;
134
+ await refs.loadAll(start.doc);
135
+ // the author may have picked another node — or edited this document — meanwhile
136
+ const { now, node, doc } = current();
137
+ if (!node || !doc || node.id !== start.node.id) return 0;
138
+ const { patches, bound } = refs.bindScopePatches(doc, node.id, now.views);
139
+ session.updateNodesSettings(patches);
140
+ return bound;
141
+ },
142
+ // the provider asked about: the node selected — or another named (a provider's chrome)
143
+ usedKeys: (providerId?: string) => {
144
+ const { doc, id } = scopeOf(providerId);
145
+ return id && doc ? refs.usedKeys(doc, id) : [];
146
+ },
147
+ outline: (providerId?: string) => {
148
+ const { doc, id } = scopeOf(providerId);
149
+ return id && doc ? refs.outline(doc, id) : [];
150
+ },
151
+ hover: (nodeId: string | null) => session.hoverNode(nodeId),
152
+ pageMeta: (providerId?: string) => {
153
+ const now = providerId ? working.snapshot() : current().now;
154
+ // a prefab says nothing about itself — it has no meta to translate
155
+ return now.kind === 'pages' ? now.doc?.meta ?? {} : null;
156
+ },
157
+ setPageMetaWords: (byLang: Readonly<Record<string, IPageMetaWords>>) => {
158
+ if (current().now.kind === 'pages') session.setPageMetaWords(byLang);
159
+ },
160
+ node: () => current().node?.id ?? null,
161
+ });
162
+ }
163
+
164
+ /**
165
+ * The provider views, workbench-wide: a provider's chrome in the canvas corner sets them (i18n:
166
+ * the language being edited), forms read references through them, canvases preview them.
167
+ */
168
+ export function ensureEditorViews(di: DiContainer, session: EditingSession): void {
169
+ if (di.hasByToken(EDITOR_VIEWS_TOKEN)) return;
170
+ const hub = editingSessionHubOf(di);
171
+ di.registerValueOfToken(EDITOR_VIEWS_TOKEN, hub
172
+ ? { snapshot: hub.viewsSnapshot, subscribe: hub.subscribeViews, set: (name: string, view: unknown) => hub.setView(name, view) }
173
+ : {
174
+ snapshot: () => session.snapshot().views,
175
+ subscribe: session.subscribe,
176
+ set: (name: string, view: unknown) => session.setView(name, view),
177
+ });
178
+ }
@@ -0,0 +1,124 @@
1
+ // WHAT DOES NOT FIT, shown where it is edited. A block that describes its settings (its catalog
2
+ // entry's settings schema, ADR-0066) has them checked as they are written: the form's fields that
3
+ // hold a value the block does not take wear a red border, and a plaque above the form names every
4
+ // issue — a click puts the caret in its field. The draft keeps saving as before (the form writes
5
+ // through, nothing is held back); publishing is what refuses settings that do not fit.
6
+ import { useEffect } from 'react';
7
+ import type { RefObject } from 'react';
8
+ import type { INodeSettingsIssue } from '@mosetta/settings/check';
9
+
10
+ /** the form's field an issue is about: the field named by its path, or by a part of it (`values` for `values.t51`) */
11
+ export function fieldOfIssue(path: string, fields: readonly string[]): string | null {
12
+ let best: string | null = null;
13
+ for (const field of fields) {
14
+ if (path !== field && !path.startsWith(`${field}.`)) continue;
15
+ if (!best || field.length > best.length) best = field;
16
+ }
17
+ return best;
18
+ }
19
+
20
+ const isPlain = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null && !Array.isArray(value);
21
+
22
+ /**
23
+ * The patch that takes the setting at `path` out of `settings` — the top key it lies under, with
24
+ * it gone (a key set to undefined is dropped when the draft is saved)
25
+ */
26
+ export function removalPatch(settings: Readonly<Record<string, unknown>>, path: string): Record<string, unknown> {
27
+ const [top, ...rest] = path.split('.');
28
+ const without = (value: unknown, keys: readonly string[]): unknown => {
29
+ if (!keys.length || !isPlain(value)) return undefined;
30
+ const [key, ...deeper] = keys;
31
+ if (!(key in value)) return value;
32
+ const { [key]: inner, ...others } = value;
33
+ if (!deeper.length) return others;
34
+ return { ...others, [key]: without(inner, deeper) };
35
+ };
36
+ return { [top]: rest.length ? without(settings[top], rest) : undefined };
37
+ }
38
+
39
+ const fieldsIn = (root: HTMLElement): HTMLElement[] => [...root.querySelectorAll<HTMLElement>('[data-field]')];
40
+
41
+ const INVALID = 'data-mosetta-invalid';
42
+
43
+ export function SettingsIssues({ issues, container, onRemove }: {
44
+ issues: readonly INodeSettingsIssue[];
45
+ container: RefObject<HTMLElement | null>;
46
+ /** takes a setting the block does not have out of the node; absent — nothing may be changed */
47
+ onRemove?: (path: string) => void;
48
+ }) {
49
+ // the fields an issue is about wear the mark — kept on them when the form renders anew. After the
50
+ // commit, not in its layout phase: the plaque stands BEFORE the form, and the form's element is
51
+ // not in `container` yet when an earlier sibling's layout effect runs
52
+ const paths = issues.map((issue) => issue.path).join('|');
53
+ useEffect(() => {
54
+ const root = container.current;
55
+ if (!root) return undefined;
56
+ const wrong = paths ? paths.split('|') : [];
57
+ const mark = () => {
58
+ const elements = fieldsIn(root);
59
+ const fields = elements.map((el) => el.getAttribute('data-field') ?? '');
60
+ const invalid = new Set(wrong.map((path) => fieldOfIssue(path, fields)).filter((field): field is string => field !== null));
61
+ for (const el of elements) {
62
+ const on = invalid.has(el.getAttribute('data-field') ?? '');
63
+ if (on !== el.hasAttribute(INVALID)) {
64
+ if (on) el.setAttribute(INVALID, '');
65
+ else el.removeAttribute(INVALID);
66
+ }
67
+ }
68
+ };
69
+ mark();
70
+ const view = root.ownerDocument.defaultView;
71
+ const watch = view?.MutationObserver ? new view.MutationObserver(mark) : null;
72
+ watch?.observe(root, { subtree: true, childList: true });
73
+ return () => {
74
+ watch?.disconnect();
75
+ for (const el of root.querySelectorAll(`[${INVALID}]`)) el.removeAttribute(INVALID);
76
+ };
77
+ }, [container, paths]);
78
+
79
+ if (!issues.length) return null;
80
+ const focus = (path: string) => {
81
+ const root = container.current;
82
+ if (!root) return;
83
+ const elements = fieldsIn(root);
84
+ const field = fieldOfIssue(path, elements.map((el) => el.getAttribute('data-field') ?? ''));
85
+ elements.find((el) => el.getAttribute('data-field') === field)?.focus();
86
+ };
87
+ const by = issues.find((issue) => issue.by)?.by;
88
+ return (
89
+ <div className="settings__issues"
90
+ data-test-id="settings-issues"
91
+ role="alert">
92
+ <div className="settings__issues-title">
93
+ {issues.length === 1 ? 'A setting does not fit this block' : `${issues.length} settings do not fit this block`}
94
+ {' '}— the page is not published until it does
95
+ </div>
96
+ <ul className="settings__issues-list">
97
+ {issues.map((issue) => (
98
+ <li key={`${issue.path}:${issue.message}`}
99
+ className="settings__issue-row">
100
+ <button type="button"
101
+ className="settings__issue"
102
+ data-test-id="settings-issue"
103
+ data-issue-path={issue.path}
104
+ data-issue-kind={issue.kind}
105
+ onClick={() => focus(issue.path)}>
106
+ {issue.path ? <code>{issue.path}</code> : null}
107
+ {' '}
108
+ {issue.message}
109
+ </button>
110
+ {/* a setting the block does not have has no field to fix it in — it can only go */}
111
+ {issue.kind === 'unknown' && onRemove ? (
112
+ <button type="button"
113
+ className="settings__issue-remove"
114
+ data-test-id="settings-issue-remove"
115
+ data-tooltip="Take this setting out — the block does not have it"
116
+ onClick={() => onRemove(issue.path)}>Remove</button>
117
+ ) : null}
118
+ </li>
119
+ ))}
120
+ </ul>
121
+ {by ? <div className="settings__issues-note">Checked by the block's version {by}: its own version describes nothing.</div> : null}
122
+ </div>
123
+ );
124
+ }
@@ -0,0 +1,72 @@
1
+ // The THEMES AROUND a node, as the editor sees them — what the styler offers a block to name
2
+ // (`var(--theme-accent)`) and what the canvas's theme control switches. A theme is any node whose
3
+ // settings are theme settings (@mosetta/core theme.ts): a theme block on the node's path from the
4
+ // root, or one a prefab on that path wraps its outlet in (the site's theme prefab around the page).
5
+ import { cleanThemeSettings, themeVariable } from '@mosetta/core';
6
+ import type { IStyleVariable, IThemeSettings, IThemeVariant } from '@mosetta/core';
7
+ import { isOutletReference } from '@mosetta/content';
8
+ import type { IBlueprintNodeDoc } from '@mosetta/content';
9
+
10
+ /** the editor view a theme is previewed under: the canvas's pick, never the reader's */
11
+ export const themeViewName = (storageKey: string): string => `theme:${storageKey}`;
12
+
13
+ export interface IThemeView {
14
+ readonly id: string;
15
+ }
16
+
17
+ type TTreeOf = (reference: string) => IBlueprintNodeDoc | null;
18
+
19
+ /** the nodes from the root down to the node's parent; empty when the node is not in the tree */
20
+ function pathTo(root: IBlueprintNodeDoc, id: string): IBlueprintNodeDoc[] {
21
+ if (root.id === id) return [];
22
+ for (const child of root.children ?? []) {
23
+ const below = pathTo(child, id);
24
+ if (below.length || child.id === id) return [root, ...below];
25
+ }
26
+ return [];
27
+ }
28
+
29
+ /** the themes a prefab wraps its outlet in, outermost first */
30
+ function themesAroundOutlet(root: IBlueprintNodeDoc): IThemeSettings[] {
31
+ const walk = (node: IBlueprintNodeDoc, around: IThemeSettings[]): IThemeSettings[] | null => {
32
+ if (isOutletReference(node.reference)) return around;
33
+ const own = cleanThemeSettings(node.settings);
34
+ const next = own ? [...around, own] : around;
35
+ for (const child of node.children ?? []) {
36
+ const found = walk(child, next);
37
+ if (found) return found;
38
+ }
39
+ return null;
40
+ };
41
+ return walk(root, []) ?? [];
42
+ }
43
+
44
+ /** every theme around the node, outermost first */
45
+ export function themesAround(doc: IBlueprintNodeDoc, nodeId: string, treeOf: TTreeOf): IThemeSettings[] {
46
+ return pathTo(doc, nodeId).flatMap((node) => {
47
+ const own = cleanThemeSettings(node.settings);
48
+ const tree = treeOf(node.reference);
49
+ return [...(own ? [own] : []), ...(tree ? themesAroundOutlet(tree) : [])];
50
+ });
51
+ }
52
+
53
+ /** the theme the author is looking at: the canvas's pick for it, else its default */
54
+ export function shownTheme(settings: IThemeSettings, views: Readonly<Record<string, unknown>>): IThemeVariant {
55
+ const view = views[themeViewName(settings.storageKey)] as IThemeView | null | undefined;
56
+ return settings.themes.find((t) => t.id === view?.id)
57
+ ?? settings.themes.find((t) => t.id === settings.defaultTheme)
58
+ ?? settings.themes[0];
59
+ }
60
+
61
+ /** the variables a style may name around the node — an inner theme overrides an outer one, key by key */
62
+ export function themeVariablesAround(themes: readonly IThemeSettings[], views: Readonly<Record<string, unknown>>): IStyleVariable[] {
63
+ const byKey = new Map<string, IStyleVariable>();
64
+ for (const settings of themes) {
65
+ const shown = shownTheme(settings, views);
66
+ for (const v of settings.variables) {
67
+ const value = shown?.values[v.key];
68
+ byKey.set(v.key, { name: themeVariable(v.key), label: v.label, kind: v.kind, ...(value ? { value } : {}) });
69
+ }
70
+ }
71
+ return [...byKey.values()];
72
+ }
@@ -0,0 +1,47 @@
1
+ // The theme variables around the selected node as the workbench exposes them to the forms it
2
+ // mounts (EDITOR_THEME_VARIABLES_TOKEN): a store the settings host FEEDS from the document, the
3
+ // project's prefabs and the author's views — a block's styler only reads it.
4
+ import { useEffect, useMemo } from 'react';
5
+ import { DI_CONTAINER_TOKEN, DiContainer } from '@mosetta/di';
6
+ import { EDITOR_THEME_VARIABLES_TOKEN, useDi } from '@mosetta/react';
7
+ import type { IThemeVariablesContract } from '@mosetta/react';
8
+ import type { IStyleVariable } from '@mosetta/core';
9
+ import type { IEditingState } from '@mosetta/content';
10
+ import { useProjectPrefabs } from './projectPrefabs';
11
+ import { themesAround, themeVariablesAround } from './themeScope';
12
+
13
+ class ThemeVariablesStore implements IThemeVariablesContract {
14
+ private variables: readonly IStyleVariable[] = [];
15
+
16
+ private readonly listeners = new Set<() => void>();
17
+
18
+ readonly snapshot = () => this.variables;
19
+
20
+ readonly subscribe = (listener: () => void): (() => void) => {
21
+ this.listeners.add(listener);
22
+ return () => {
23
+ this.listeners.delete(listener);
24
+ };
25
+ };
26
+
27
+ set(variables: readonly IStyleVariable[]): void {
28
+ if (JSON.stringify(variables) === JSON.stringify(this.variables)) return;
29
+ this.variables = variables;
30
+ for (const listener of [...this.listeners]) listener();
31
+ }
32
+ }
33
+
34
+ function ensureThemeVariables(di: DiContainer): ThemeVariablesStore {
35
+ if (!di.hasByToken(EDITOR_THEME_VARIABLES_TOKEN)) di.registerValueOfToken(EDITOR_THEME_VARIABLES_TOKEN, new ThemeVariablesStore());
36
+ return di.getByToken(EDITOR_THEME_VARIABLES_TOKEN) as ThemeVariablesStore;
37
+ }
38
+
39
+ /** keeps the workbench's store fed with the variables around the node the settings are about */
40
+ export function useFeedThemeVariables(state: IEditingState): void {
41
+ const store = ensureThemeVariables(useDi(DI_CONTAINER_TOKEN));
42
+ const project = useProjectPrefabs();
43
+ const variables = useMemo(() => (state.doc && state.selectedNodeId
44
+ ? themeVariablesAround(themesAround(state.doc, state.selectedNodeId, (reference) => project.treeOf(reference)?.doc ?? null), state.views)
45
+ : []), [state.doc, state.selectedNodeId, state.views, project]);
46
+ useEffect(() => store.set(variables), [store, variables]);
47
+ }
@@ -0,0 +1,17 @@
1
+ // Where a workbench tooltip goes (tooltips.tsx): below the element it explains, left edges
2
+ // aligned; above it when there is no room below; always inside the window. Viewport pixels.
3
+
4
+ /** the gap between the element and its bubble, and the least distance to the window's edge */
5
+ const GAP = 6;
6
+ const MARGIN = 8;
7
+
8
+ export function placeTooltip(
9
+ anchor: { left: number; top: number; bottom: number },
10
+ bubble: { width: number; height: number },
11
+ view: { width: number; height: number },
12
+ ): { left: number; top: number } {
13
+ const below = anchor.bottom + GAP;
14
+ const top = below + bubble.height <= view.height - MARGIN ? below : Math.max(MARGIN, anchor.top - GAP - bubble.height);
15
+ const left = Math.max(MARGIN, Math.min(anchor.left, view.width - MARGIN - bubble.width));
16
+ return { left, top };
17
+ }
@@ -0,0 +1,131 @@
1
+ // The workbench's TOOLTIPS: one mechanism for every panel. An element says what a click on it
2
+ // will do in `data-tooltip`; the panel wraps its root in <TooltipHost>, which shows that text
3
+ // the moment the pointer (or the keyboard focus) lands on the element — no delay, unlike the
4
+ // browser's native `title`, which waits a second and looks like a different application.
5
+ //
6
+ // Delegated, not per element: the host listens once (React events bubble through portals too,
7
+ // so a dialog a panel portals out still gets its tooltips) and the innermost `data-tooltip` wins —
8
+ // a button inside a clickable row explains the button, the rest of the row explains the row.
9
+ //
10
+ // The bubble is portalled onto the document body, positioned `fixed` next to the element: the
11
+ // panels scroll and clip, and a tooltip drawn inside them would be cut at their edge. Its look is
12
+ // INLINE, not a stylesheet, so it is the same whichever module shows it (a fallback included).
13
+ // Its text size is the workbench scale's (workbenchScale.css): the bubble is wrapped in the scope
14
+ // class of the module around the element — a module's css reaches only inside that class, and
15
+ // the scale's variables with it. No getComputedStyle: it is slow, and in a test DOM it can hang.
16
+ import { CSSProperties, ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
17
+ import { createPortal } from 'react-dom';
18
+ import { placeTooltip } from './tooltipPlacement';
19
+
20
+ export const TOOLTIP_ATTR = 'data-tooltip';
21
+
22
+ function tooltipTargetOf(node: EventTarget | null): HTMLElement | null {
23
+ const el = node && (node as Node).nodeType === 1 ? (node as Element) : (node as Node | null)?.parentElement ?? null;
24
+ const target = el?.closest(`[${TOOLTIP_ATTR}]`) ?? null;
25
+ return target && target.getAttribute(TOOLTIP_ATTR) ? (target as HTMLElement) : null;
26
+ }
27
+
28
+ const BUBBLE_STYLE: CSSProperties = {
29
+ position: 'fixed',
30
+ top: 0,
31
+ left: 0,
32
+ zIndex: 10000,
33
+ maxWidth: 320,
34
+ padding: '4px 8px',
35
+ borderRadius: 5,
36
+ background: '#27272a',
37
+ color: '#ffffff',
38
+ fontFamily: 'ui-sans-serif, system-ui, sans-serif',
39
+ fontSize: 'var(--mosetta-wb-font-xs, 12px)',
40
+ lineHeight: 1.4,
41
+ whiteSpace: 'normal',
42
+ overflowWrap: 'anywhere',
43
+ pointerEvents: 'none',
44
+ boxShadow: '0 4px 12px rgb(0 0 0 / 20%)',
45
+ };
46
+
47
+ /** the scope class of the nearest module around the element ('' outside every module) */
48
+ function scopeClassOf(target: HTMLElement): string {
49
+ for (let el: Element | null = target; el; el = el.parentElement) {
50
+ for (const name of el.classList) {
51
+ if (name.startsWith('_ns_')) return name;
52
+ }
53
+ }
54
+ return '';
55
+ }
56
+
57
+ function Bubble({ target }: { target: HTMLElement }) {
58
+ const ref = useRef<HTMLDivElement>(null);
59
+ const [text, setText] = useState(() => target.getAttribute(TOOLTIP_ATTR) ?? '');
60
+ // the element may change what it says while it is pointed at (an armed trash asks again)
61
+ useEffect(() => {
62
+ const Observer = target.ownerDocument.defaultView?.MutationObserver;
63
+ setText(target.getAttribute(TOOLTIP_ATTR) ?? '');
64
+ if (!Observer) return undefined;
65
+ const observer = new Observer(() => setText(target.getAttribute(TOOLTIP_ATTR) ?? ''));
66
+ observer.observe(target, { attributes: true, attributeFilter: [TOOLTIP_ATTR] });
67
+ return () => observer.disconnect();
68
+ }, [target]);
69
+ // placed before paint, measured as rendered: no frame shows it anywhere else
70
+ useLayoutEffect(() => {
71
+ const el = ref.current;
72
+ const view = target.ownerDocument.defaultView;
73
+ if (!el || !view) return;
74
+ const at = placeTooltip(target.getBoundingClientRect(), el.getBoundingClientRect(), { width: view.innerWidth, height: view.innerHeight });
75
+ el.style.left = `${at.left}px`;
76
+ el.style.top = `${at.top}px`;
77
+ }, [target, text]);
78
+ if (!text) return null;
79
+ return createPortal(
80
+ <div className={scopeClassOf(target)}
81
+ style={{ display: 'contents' }}>
82
+ <div ref={ref}
83
+ style={BUBBLE_STYLE}
84
+ role="tooltip"
85
+ data-test-id="workbench-tooltip">
86
+ {text}
87
+ </div>
88
+ </div>,
89
+ target.ownerDocument.body,
90
+ );
91
+ }
92
+
93
+ /**
94
+ * Shows the `data-tooltip` of whatever element inside it is pointed at or focused. A layout-free
95
+ * wrapper (display: contents) — put it around a panel's root.
96
+ */
97
+ export function TooltipHost({ children }: { children: ReactNode }) {
98
+ const [target, setTarget] = useState<HTMLElement | null>(null);
99
+ const follow = useCallback((node: EventTarget | null) => setTarget(tooltipTargetOf(node)), []);
100
+ // a target that leaves the document (its row was re-rendered away) takes its bubble along
101
+ useEffect(() => {
102
+ if (!target) return undefined;
103
+ const view = target.ownerDocument.defaultView;
104
+ const check = () => {
105
+ if (!target.isConnected) setTarget(null);
106
+ };
107
+ const hide = () => setTarget(null);
108
+ const timer = view?.setInterval(check, 300);
109
+ // anything that moves the element under the pointer makes the bubble lie about where it is
110
+ view?.addEventListener('scroll', hide, true);
111
+ view?.addEventListener('resize', hide);
112
+ return () => {
113
+ if (timer !== undefined) view?.clearInterval(timer);
114
+ view?.removeEventListener('scroll', hide, true);
115
+ view?.removeEventListener('resize', hide);
116
+ };
117
+ }, [target]);
118
+ return (
119
+ <div style={{ display: 'contents' }}
120
+ onPointerOver={(e) => follow(e.target)}
121
+ onPointerLeave={() => setTarget(null)}
122
+ onFocus={(e) => follow(e.target)}
123
+ onBlur={() => setTarget(null)}
124
+ // the click happens — what it will do is no longer news, and the bubble would cover
125
+ // whatever the click opens
126
+ onPointerDown={() => setTarget(null)}>
127
+ {children}
128
+ {target ? <Bubble target={target} /> : null}
129
+ </div>
130
+ );
131
+ }
@@ -0,0 +1,33 @@
1
+ // The MOUNTER's DI face: the mechanism (mountedTree.ts) hung into the container, so that any
2
+ // code with a DiContainer — a panel in any binding, a service, a test — can host someone else's
3
+ // application. `useMountedTree` is merely the react adapter over this.
4
+ //
5
+ // Registered as a CLASS, not a prebuilt value: its one dependency is the modules holder, which
6
+ // every application container carries — so construction is the container's job. (EditingAutosave
7
+ // next door is built by hand for a reason this service does not have: it needs the caller's module
8
+ // reference and a manual server-data handle, which a container cannot know by itself.)
9
+ import { DI_CONTAINER_TOKEN, DiContainer, DiInject, DiService, SingleDiToken } from '@mosetta/di';
10
+ import { TILES_HOLDER_TOKEN } from '@mosetta/runtime/client';
11
+ import { MountedTree } from './mountedTree';
12
+
13
+ export const TREE_MOUNTER_TOKEN = new SingleDiToken<TreeMounter>('TREE_MOUNTER_TOKEN');
14
+
15
+ @DiService(TREE_MOUNTER_TOKEN)
16
+ export class TreeMounter {
17
+ constructor(
18
+ @DiInject(TILES_HOLDER_TOKEN) private readonly holder: typeof TILES_HOLDER_TOKEN.tokenType,
19
+ // the mounter's own container IS the host application's — every mounted application gets it
20
+ // as its parent (IMountOptions.parentDi, the Unity door)
21
+ @DiInject(DI_CONTAINER_TOKEN) private readonly di: DiContainer,
22
+ ) {}
23
+
24
+ create(): MountedTree {
25
+ return new MountedTree(this.holder, this.di);
26
+ }
27
+ }
28
+
29
+ /** brought into being on first use, like the session — it belongs to the workbench, not a panel */
30
+ export function ensureTreeMounter(di: DiContainer): TreeMounter {
31
+ if (!di.hasByToken(TREE_MOUNTER_TOKEN)) di.registerServices(TreeMounter);
32
+ return di.getByToken(TREE_MOUNTER_TOKEN);
33
+ }