@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.
- package/CHANGELOG.md +185 -0
- package/LICENSE +21 -0
- package/README.md +10 -2
- package/mosetta.json +6 -0
- package/package.json +35 -3
- package/prefabs/workbench/prefab.json +11 -0
- package/prefabs/workbench/versions/1.json +120 -0
- package/prefabs/workbench/versions/2.json +13 -0
- package/prefabs/workbench/versions/3.json +19 -0
- package/prefabs/workbench/versions/4.json +29 -0
- package/prefabs/workbench/versions/5.json +29 -0
- package/tiles/canvas/canvas.component.tsx +2038 -0
- package/tiles/canvas/canvas.css +973 -0
- package/tiles/canvas/canvasTools.ts +66 -0
- package/tiles/canvas/chromeNodes.ts +35 -0
- package/tiles/canvas/ghosts.ts +33 -0
- package/tiles/canvas/insertPopover.tsx +56 -0
- package/tiles/canvas/popoverPlacement.ts +104 -0
- package/tiles/canvas/popoverSize.ts +87 -0
- package/tiles/canvas/providerChrome.tsx +191 -0
- package/tiles/canvas/scrollWatch.ts +43 -0
- package/tiles/canvas/settingsPopover.tsx +257 -0
- package/tiles/canvas/settingsTabs.ts +78 -0
- package/tiles/canvas/toolPanel.tsx +86 -0
- package/tiles/content/autoBind.ts +61 -0
- package/tiles/content/blockPage.css +390 -0
- package/tiles/content/blockPage.tsx +379 -0
- package/tiles/content/blockPicker.css +215 -0
- package/tiles/content/blockPicker.tsx +501 -0
- package/tiles/content/blockUpgrades.ts +145 -0
- package/tiles/content/blockVariants.ts +185 -0
- package/tiles/content/blockVocabulary.ts +77 -0
- package/tiles/content/catalogVersions.ts +106 -0
- package/tiles/content/componentPreview.css +82 -0
- package/tiles/content/componentPreview.tsx +249 -0
- package/tiles/content/content.server.ts +593 -0
- package/tiles/content/contentCommon.ts +133 -0
- package/tiles/content/disconnected.ts +56 -0
- package/tiles/content/distributions.css +256 -0
- package/tiles/content/distributions.tsx +271 -0
- package/tiles/content/editingAutosave.ts +125 -0
- package/tiles/content/editingClipboard.ts +185 -0
- package/tiles/content/editingConflicts.ts +223 -0
- package/tiles/content/editingHotkeys.ts +59 -0
- package/tiles/content/editingNotices.ts +105 -0
- package/tiles/content/formRoot.ts +18 -0
- package/tiles/content/fuzzySearch.ts +121 -0
- package/tiles/content/mediaUpload.ts +55 -0
- package/tiles/content/moduleSiblings.ts +9 -0
- package/tiles/content/mountedTree.ts +136 -0
- package/tiles/content/nodeSettings.css +483 -0
- package/tiles/content/nodeSettings.tsx +963 -0
- package/tiles/content/pasteText.ts +131 -0
- package/tiles/content/prefabFields.tsx +158 -0
- package/tiles/content/prefabParts.ts +105 -0
- package/tiles/content/previewEnvironment.ts +46 -0
- package/tiles/content/projectConfig.ts +46 -0
- package/tiles/content/projectPrefabs.ts +57 -0
- package/tiles/content/publishModal.css +109 -0
- package/tiles/content/publishModal.tsx +92 -0
- package/tiles/content/refsEditing.ts +412 -0
- package/tiles/content/scaledStage.ts +63 -0
- package/tiles/content/searchField.css +32 -0
- package/tiles/content/searchField.tsx +32 -0
- package/tiles/content/settingsDraft.ts +178 -0
- package/tiles/content/settingsIssues.tsx +124 -0
- package/tiles/content/themeScope.ts +72 -0
- package/tiles/content/themeVariables.ts +47 -0
- package/tiles/content/tooltipPlacement.ts +17 -0
- package/tiles/content/tooltips.tsx +131 -0
- package/tiles/content/treeMounter.ts +33 -0
- package/tiles/content/updatesModal.css +124 -0
- package/tiles/content/updatesModal.tsx +110 -0
- package/tiles/content/useCtrlHeld.ts +24 -0
- package/tiles/content/useEditingSession.ts +115 -0
- package/tiles/content/useMountedTree.ts +47 -0
- package/tiles/content/workbenchScale.css +27 -0
- package/tiles/hostFallback/hostFallback.component.tsx +32 -0
- package/tiles/hostFallback/hostFallback.css +12 -0
- package/tiles/hostFallback/hostFallback.editor.tsx +10 -0
- package/tiles/page/page.component.tsx +7 -0
- package/tiles/page/page.script.tsx +5 -0
- package/tiles/pageModules/pageModules.component.tsx +740 -0
- package/tiles/pageModules/pageModules.css +383 -0
- package/tiles/pageModules/pageModules.icon.svg +1 -0
- package/tiles/pageModules/servedPage.ts +465 -0
- package/tiles/pagesPanel/pagesPanel.component.tsx +1645 -0
- package/tiles/pagesPanel/pagesPanel.css +713 -0
- package/tiles/pagesPanel/pagesPanel.editor.tsx +23 -0
- package/tiles/pagesPanel/pagesSearch.ts +194 -0
- package/tiles/pagesPanel/routeTree.ts +98 -0
- package/tiles/previewBridge/landing.ts +66 -0
- package/tiles/previewBridge/previewAgent.ts +457 -0
- package/tiles/previewBridge/previewBridge.component.tsx +137 -0
- package/tiles/previewBridge/previewBridge.editor.tsx +10 -0
- package/tiles/previewBridge/previewBridgeCommon.ts +172 -0
- package/tiles/previewBridge/standIn.ts +22 -0
- package/tiles/treeView/treeView.component.tsx +514 -0
- package/tiles/treeView/treeView.css +303 -0
- package/tiles/treeView/treeView.editor.tsx +12 -0
- package/tiles/workbench/assistant.css +401 -0
- package/tiles/workbench/assistant.tsx +516 -0
- package/tiles/workbench/assistantChat.ts +165 -0
- package/tiles/workbench/dictation.ts +354 -0
- package/tiles/workbench/projectInfo.ts +72 -0
- package/tiles/workbench/recent.ts +67 -0
- package/tiles/workbench/workbench.component.tsx +476 -0
- package/tiles/workbench/workbench.css +376 -0
- package/tiles/workbench/workbenchAddress.ts +202 -0
- package/tiles/workbench/workspace.ts +775 -0
- package/tiles/workbenchEditor/workbenchEditor.component.tsx +461 -0
- package/tiles/workbenchEditor/workbenchEditor.css +370 -0
- package/tiles/workbenchHome/workbenchHome.component.tsx +196 -0
- package/tiles/workbenchHome/workbenchHome.css +271 -0
- package/tsconfig.json +3 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// "Publish this?" — asked in a modal that names the publication: the revision goes by a name
|
|
2
|
+
// in the versions list from then on (the draft's own words are offered, the author may say what
|
|
3
|
+
// this publication was about instead). Portalled to the document body inside the hosting
|
|
4
|
+
// module's scope class — the pages list and the canvas both ask, each with its own stylesheet.
|
|
5
|
+
import { useEffect, useRef, useState } from 'react';
|
|
6
|
+
import { createPortal } from 'react-dom';
|
|
7
|
+
import { tileScopeClass, useComponent } from '@mosetta/react';
|
|
8
|
+
import { MAX_REVISION_TITLE } from '@mosetta/content';
|
|
9
|
+
import './publishModal.css';
|
|
10
|
+
|
|
11
|
+
export function PublishModal({ host, noun, route, suggested, live, publishing, error, onCancel, onConfirm }: {
|
|
12
|
+
/** the document the dialog goes into — the host module's own */
|
|
13
|
+
host: Document;
|
|
14
|
+
noun: string;
|
|
15
|
+
/** what is being published: the route, or a prefab's name */
|
|
16
|
+
route: string;
|
|
17
|
+
/** the name offered — the draft's words */
|
|
18
|
+
suggested: string;
|
|
19
|
+
/** the live revision this one replaces, or null for the first */
|
|
20
|
+
live: number | null;
|
|
21
|
+
publishing: boolean;
|
|
22
|
+
error: string | null;
|
|
23
|
+
onCancel: () => void;
|
|
24
|
+
onConfirm: (title: string) => void;
|
|
25
|
+
}) {
|
|
26
|
+
const self = useComponent().reference;
|
|
27
|
+
const [title, setTitle] = useState(suggested);
|
|
28
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
inputRef.current?.focus();
|
|
31
|
+
inputRef.current?.select();
|
|
32
|
+
}, []);
|
|
33
|
+
return createPortal(
|
|
34
|
+
<div className={tileScopeClass(self)}
|
|
35
|
+
style={{ display: 'contents' }}>
|
|
36
|
+
<div className="publish-modal__overlay"
|
|
37
|
+
data-test-id="publish-modal"
|
|
38
|
+
onClick={() => {
|
|
39
|
+
if (!publishing) onCancel();
|
|
40
|
+
}}
|
|
41
|
+
onKeyDown={(e) => {
|
|
42
|
+
if (e.key === 'Escape' && !publishing) onCancel();
|
|
43
|
+
}}>
|
|
44
|
+
<div className="publish-modal"
|
|
45
|
+
role="dialog"
|
|
46
|
+
aria-label={`Publish this ${noun}`}
|
|
47
|
+
onClick={(e) => e.stopPropagation()}>
|
|
48
|
+
<div className="publish-modal__title">Publish {route}</div>
|
|
49
|
+
<div className="publish-modal__text">
|
|
50
|
+
{live !== null
|
|
51
|
+
? `This draft becomes the live revision, in place of r${live}.`
|
|
52
|
+
: 'This draft becomes the live revision — the first one readers get.'}
|
|
53
|
+
</div>
|
|
54
|
+
<label className="publish-modal__label"
|
|
55
|
+
htmlFor="publish-modal-title">Name this publication</label>
|
|
56
|
+
<input ref={inputRef}
|
|
57
|
+
id="publish-modal-title"
|
|
58
|
+
className="publish-modal__input"
|
|
59
|
+
data-test-id="publish-modal-title"
|
|
60
|
+
value={title}
|
|
61
|
+
maxLength={MAX_REVISION_TITLE}
|
|
62
|
+
spellCheck={false}
|
|
63
|
+
disabled={publishing}
|
|
64
|
+
onChange={(e) => setTitle(e.target.value)}
|
|
65
|
+
onKeyDown={(e) => {
|
|
66
|
+
if (e.key === 'Enter' && !publishing) onConfirm(title);
|
|
67
|
+
}} />
|
|
68
|
+
<div className="publish-modal__hint">The name stays with the revision in the versions list — what this publication was about.</div>
|
|
69
|
+
{error ? <div className="publish-modal__error"
|
|
70
|
+
data-test-id="publish-modal-error">{error}</div> : null}
|
|
71
|
+
<div className="publish-modal__buttons">
|
|
72
|
+
<button type="button"
|
|
73
|
+
className="publish-modal__button"
|
|
74
|
+
data-test-id="publish-modal-cancel"
|
|
75
|
+
disabled={publishing}
|
|
76
|
+
onClick={onCancel}>
|
|
77
|
+
Cancel
|
|
78
|
+
</button>
|
|
79
|
+
<button type="button"
|
|
80
|
+
className="publish-modal__button publish-modal__button--primary"
|
|
81
|
+
data-test-id="publish-modal-confirm"
|
|
82
|
+
disabled={publishing}
|
|
83
|
+
onClick={() => onConfirm(title)}>
|
|
84
|
+
{publishing ? 'Publishing…' : 'Publish'}
|
|
85
|
+
</button>
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
</div>,
|
|
90
|
+
host.body,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
// REFERENCES, the editor's half. A form edits VALUES; this turns its edits into document changes
|
|
2
|
+
// when a field is a reference: the value is read through the provider's adapter, a write goes to
|
|
3
|
+
// the provider node's settings, binding a field points it at a provider's key (an existing one
|
|
4
|
+
// picked, or a fresh one holding the field's text). Plain logic over the session's document and
|
|
5
|
+
// the modules loaded in this realm — the provider's adapter is the same code the page runs, loaded
|
|
6
|
+
// here like the palette loads a specimen. ONE instance per workbench (ensureRefsEditing): every
|
|
7
|
+
// host of settings — the canvas popover, a settings panel — shares what was loaded.
|
|
8
|
+
import { SingleDiToken } from '@mosetta/di';
|
|
9
|
+
import { unitOf } from '@mosetta/content/units';
|
|
10
|
+
import type { DiContainer } from '@mosetta/di';
|
|
11
|
+
import {
|
|
12
|
+
bindableFields,
|
|
13
|
+
canBind,
|
|
14
|
+
findRefProvider,
|
|
15
|
+
instanceRefProviders,
|
|
16
|
+
isRefValue,
|
|
17
|
+
TILES_HOLDER_TOKEN,
|
|
18
|
+
parseRef,
|
|
19
|
+
providerAddressOf,
|
|
20
|
+
reachableRefProviders,
|
|
21
|
+
refValue,
|
|
22
|
+
resolveRefs,
|
|
23
|
+
treeNodes,
|
|
24
|
+
} from '@mosetta/runtime/client';
|
|
25
|
+
import type { IBindableField, ITilesHolder, IRefContext, IRefEntry, IRefProviderDeclaration, IRefTreeNode, IRefValue, TBindableDeclaration } from '@mosetta/runtime/client';
|
|
26
|
+
|
|
27
|
+
/** what a component module may declare about references */
|
|
28
|
+
interface IReferenceDeclarations {
|
|
29
|
+
readonly provides?: IRefProviderDeclaration;
|
|
30
|
+
readonly bindable?: TBindableDeclaration;
|
|
31
|
+
/** its editor facet has a standing control for the canvas (see IComponentSide.editorChrome) */
|
|
32
|
+
readonly editorChrome?: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** one node's settings change — what EditingSession.updateNodesSettings applies as one step */
|
|
36
|
+
export interface ISettingsPatch {
|
|
37
|
+
readonly nodeId: string;
|
|
38
|
+
readonly patch: Record<string, unknown>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** a node's id within its own document — an expanded prefab's nodes carry their place's id in front */
|
|
42
|
+
const localId = (id: string): string => id.slice(id.lastIndexOf('/') + 1);
|
|
43
|
+
|
|
44
|
+
/** a provider as a field's picker shows it */
|
|
45
|
+
export interface IRefOption {
|
|
46
|
+
/** how the reference addresses it: the role (`i18n`), or the instance (`#prices`) */
|
|
47
|
+
readonly name: string;
|
|
48
|
+
readonly nodeId: string;
|
|
49
|
+
/** where it sits, in words the author recognizes */
|
|
50
|
+
readonly where: string;
|
|
51
|
+
readonly entries: readonly IRefEntry[];
|
|
52
|
+
/** it can keep a NEW value — the field's current text becomes one of its keys */
|
|
53
|
+
readonly canCreate: boolean;
|
|
54
|
+
/** key → the nodes in its scope that read it (`hero-title`, `hero-image.alt`) */
|
|
55
|
+
readonly usedBy: Readonly<Record<string, readonly string[]>>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
type TViews = Readonly<Record<string, unknown>>;
|
|
59
|
+
|
|
60
|
+
// a live unit (a layout, a distribution's prefab) is a reference to a tree, not a component module
|
|
61
|
+
const isUnit = (reference: string): boolean => unitOf(reference) !== null;
|
|
62
|
+
|
|
63
|
+
const ctxOf = (views: TViews, name: string): IRefContext => ({ view: views[name] ?? null, route: null });
|
|
64
|
+
|
|
65
|
+
const shortNameOf = (reference: string): string => /_n_(.+?)_v_/.exec(reference)?.[1] ?? reference;
|
|
66
|
+
|
|
67
|
+
export class RefsEditing {
|
|
68
|
+
private readonly pending = new Set<string>();
|
|
69
|
+
|
|
70
|
+
private readonly failed = new Set<string>();
|
|
71
|
+
|
|
72
|
+
private readonly listeners = new Set<() => void>();
|
|
73
|
+
|
|
74
|
+
constructor(private readonly holder: ITilesHolder | null) {}
|
|
75
|
+
|
|
76
|
+
/** told when a module this realm was loading arrived — whatever was resolved may now differ */
|
|
77
|
+
readonly subscribe = (listener: () => void): (() => void) => {
|
|
78
|
+
this.listeners.add(listener);
|
|
79
|
+
return () => {
|
|
80
|
+
this.listeners.delete(listener);
|
|
81
|
+
};
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* What a component module declares, when it is loaded in this realm; a module not loaded yet
|
|
86
|
+
* starts loading and answers nothing until then (subscribers are told when to ask again).
|
|
87
|
+
*/
|
|
88
|
+
declarations(reference: string): IReferenceDeclarations | null {
|
|
89
|
+
if (!this.holder || isUnit(reference) || this.failed.has(reference)) return null;
|
|
90
|
+
let loaded: { module?: unknown; error?: unknown } | undefined;
|
|
91
|
+
try {
|
|
92
|
+
loaded = this.holder.getModule(reference);
|
|
93
|
+
} catch {
|
|
94
|
+
loaded = undefined;
|
|
95
|
+
}
|
|
96
|
+
if (loaded?.module) return loaded.module;
|
|
97
|
+
if (loaded?.error) return null;
|
|
98
|
+
if (this.holder.loadModule && !this.pending.has(reference)) {
|
|
99
|
+
this.pending.add(reference);
|
|
100
|
+
void this.holder.loadModule(reference)
|
|
101
|
+
.then((module) => {
|
|
102
|
+
if (module?.error) this.failed.add(reference);
|
|
103
|
+
}, () => {
|
|
104
|
+
this.failed.add(reference);
|
|
105
|
+
})
|
|
106
|
+
.finally(() => {
|
|
107
|
+
this.pending.delete(reference);
|
|
108
|
+
for (const listener of [...this.listeners]) listener();
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** the settings a block declares may be references */
|
|
115
|
+
bindable(reference: string): readonly string[] {
|
|
116
|
+
return this.bindableFields(reference).map(({ field }) => field);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** …each with the type of what it holds (null: declared without one) */
|
|
120
|
+
bindableFields(reference: string): readonly IBindableField[] {
|
|
121
|
+
return bindableFields(this.declarations(reference)?.bindable);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** the type a field of a block holds — null when undeclared, or not a bindable field at all */
|
|
125
|
+
private typeOf(reference: string, field: string): IBindableField['type'] {
|
|
126
|
+
return this.bindableFields(reference).find((entry) => entry.field === field)?.type ?? null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** every component module of the tree loaded — what a scope-wide operation must wait for */
|
|
130
|
+
async loadAll(doc: IRefTreeNode): Promise<void> {
|
|
131
|
+
const references = new Set<string>();
|
|
132
|
+
for (const node of treeNodes(doc)) {
|
|
133
|
+
if (!isUnit(node.reference)) references.add(node.reference);
|
|
134
|
+
}
|
|
135
|
+
await Promise.all([...references].map(async (reference) => {
|
|
136
|
+
if (this.declarations(reference) || !this.holder?.loadModule) return;
|
|
137
|
+
await this.holder.loadModule(reference).catch(() => undefined);
|
|
138
|
+
}));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private readonly provides = (reference: string): IRefProviderDeclaration | null => this.declarations(reference)?.provides ?? null;
|
|
142
|
+
|
|
143
|
+
read(doc: IRefTreeNode, nodeId: string, ref: IRefValue, views: TViews): unknown {
|
|
144
|
+
const parsed = parseRef(ref.$ref);
|
|
145
|
+
if (!parsed) return undefined;
|
|
146
|
+
const found = findRefProvider(doc, nodeId, parsed.name, this.provides);
|
|
147
|
+
if (!found) return undefined;
|
|
148
|
+
try {
|
|
149
|
+
return found.provides.refs.read(found.node.settings ?? {}, parsed.key, ctxOf(views, parsed.name));
|
|
150
|
+
} catch {
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** the node's settings with every reference read — what a form shows */
|
|
156
|
+
resolve(doc: IRefTreeNode, nodeId: string, settings: Record<string, unknown>, views: TViews): Record<string, unknown> {
|
|
157
|
+
return resolveRefs(settings, (ref) => this.read(doc, nodeId, ref, views));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* A form's patch as document changes: literal fields change the node itself, a field bound to
|
|
162
|
+
* a provider changes the provider's settings through its adapter. A bound field whose provider
|
|
163
|
+
* is gone becomes a literal — the author's words are never dropped.
|
|
164
|
+
*/
|
|
165
|
+
writePatches(doc: IRefTreeNode, nodeId: string, settings: Record<string, unknown>, patch: Record<string, unknown>, views: TViews): ISettingsPatch[] {
|
|
166
|
+
const own: Record<string, unknown> = {};
|
|
167
|
+
const providers = new Map<string, { node: IRefTreeNode; settings: Record<string, unknown> }>();
|
|
168
|
+
for (const [field, value] of Object.entries(patch)) {
|
|
169
|
+
const current = settings[field];
|
|
170
|
+
const parsed = isRefValue(current) ? parseRef(current.$ref) : null;
|
|
171
|
+
const found = parsed ? findRefProvider(doc, nodeId, parsed.name, this.provides) : null;
|
|
172
|
+
if (!parsed || !found) {
|
|
173
|
+
own[field] = value;
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
const held = providers.get(found.node.id) ?? { node: found.node, settings: found.node.settings ?? {} };
|
|
177
|
+
held.settings = found.provides.refs.write(held.settings, parsed.key, value, ctxOf(views, parsed.name));
|
|
178
|
+
providers.set(found.node.id, held);
|
|
179
|
+
}
|
|
180
|
+
return [
|
|
181
|
+
...(Object.keys(own).length ? [{ nodeId, patch: own }] : []),
|
|
182
|
+
...[...providers.values()].map(({ node, settings: next }) => ({ nodeId: node.id, patch: next })),
|
|
183
|
+
];
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Every provider a node's field could point at: the nearest of each role, then the instances of
|
|
188
|
+
* its document — of them, with a `field` named, only those whose type the field's admits (a
|
|
189
|
+
* provider of words is no source for an address).
|
|
190
|
+
*/
|
|
191
|
+
private candidates(doc: IRefTreeNode, nodeId: string, field?: string) {
|
|
192
|
+
const all = [...reachableRefProviders(doc, nodeId, this.provides), ...instanceRefProviders(doc, nodeId, this.provides)];
|
|
193
|
+
if (field === undefined) return all;
|
|
194
|
+
const node = [...treeNodes(doc)].find((candidate) => candidate.id === nodeId);
|
|
195
|
+
const fieldType = node ? this.typeOf(node.reference, field) : null;
|
|
196
|
+
return all.filter((found) => canBind(fieldType, found.provides.type, true));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** the providers a node can point at, by address — for `field`, the ones of its type */
|
|
200
|
+
reachable(doc: IRefTreeNode, nodeId: string, field?: string): string[] {
|
|
201
|
+
return this.candidates(doc, nodeId, field).map((found) => found.name);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** where a node sits, for a person: its parent's block name, or the page itself */
|
|
205
|
+
private whereOf(doc: IRefTreeNode, nodeId: string): string {
|
|
206
|
+
for (const node of treeNodes(doc)) {
|
|
207
|
+
if (!node.children?.some((child) => child.id === nodeId)) continue;
|
|
208
|
+
return node.id === doc.id ? 'page root' : `in ${shortNameOf(node.reference)}`;
|
|
209
|
+
}
|
|
210
|
+
return nodeId === doc.id ? 'the page' : '';
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** what a field's picker offers: every reachable provider of the field's type, with the values it hands out */
|
|
214
|
+
options(doc: IRefTreeNode, nodeId: string, views: TViews, field?: string): IRefOption[] {
|
|
215
|
+
return this.candidates(doc, nodeId, field).map((found) => {
|
|
216
|
+
let entries: readonly IRefEntry[] = [];
|
|
217
|
+
try {
|
|
218
|
+
entries = found.provides.refs.list?.(found.node.settings ?? {}, ctxOf(views, found.name)) ?? [];
|
|
219
|
+
} catch {
|
|
220
|
+
entries = [];
|
|
221
|
+
}
|
|
222
|
+
const usedBy: Record<string, string[]> = {};
|
|
223
|
+
for (const reader of this.scopeOf(doc, found.node, found.name)) {
|
|
224
|
+
for (const [setting, value] of Object.entries(reader.settings ?? {})) {
|
|
225
|
+
const parsed = isRefValue(value) ? parseRef(value.$ref) : null;
|
|
226
|
+
if (parsed?.name !== found.name) continue;
|
|
227
|
+
const where = `${localId(reader.id)}${setting === 'text' ? '' : `.${setting}`}`;
|
|
228
|
+
usedBy[parsed.key] = [...(usedBy[parsed.key] ?? []), where];
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return {
|
|
232
|
+
name: found.name,
|
|
233
|
+
nodeId: found.node.id,
|
|
234
|
+
where: this.whereOf(doc, found.node.id),
|
|
235
|
+
entries,
|
|
236
|
+
canCreate: typeof found.provides.refs.create === 'function',
|
|
237
|
+
usedBy,
|
|
238
|
+
};
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* `field` becomes a reference to `name`: to `key` when one was picked, otherwise to a fresh key
|
|
244
|
+
* holding the value the field holds now (a provider that cannot keep new values answers nothing).
|
|
245
|
+
*/
|
|
246
|
+
bindPatches(doc: IRefTreeNode, nodeId: string, settings: Record<string, unknown>, field: string, name: string, views: TViews, key?: string): ISettingsPatch[] {
|
|
247
|
+
const found = findRefProvider(doc, nodeId, name, this.provides);
|
|
248
|
+
if (!found) return [];
|
|
249
|
+
if (key !== undefined) return [{ nodeId, patch: { [field]: refValue(name, key) } }];
|
|
250
|
+
const create = found.provides.refs.create;
|
|
251
|
+
if (!create) return [];
|
|
252
|
+
// re-binding a field that already points somewhere starts from the text it SHOWS
|
|
253
|
+
const current = isRefValue(settings[field]) ? this.read(doc, nodeId, settings[field], {}) : settings[field];
|
|
254
|
+
const created = create(found.node.settings ?? {}, current, ctxOf(views, name), { node: localId(nodeId), setting: field });
|
|
255
|
+
return [
|
|
256
|
+
{ nodeId: found.node.id, patch: created.settings },
|
|
257
|
+
{ nodeId, patch: { [field]: refValue(name, created.key) } },
|
|
258
|
+
];
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** `field` becomes a literal again, holding what it shows in the original (no view) */
|
|
262
|
+
unbindPatches(doc: IRefTreeNode, nodeId: string, settings: Record<string, unknown>, field: string): ISettingsPatch[] {
|
|
263
|
+
const current = settings[field];
|
|
264
|
+
if (!isRefValue(current)) return [];
|
|
265
|
+
return [{ nodeId, patch: { [field]: this.read(doc, nodeId, current, {}) ?? '' } }];
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** the provider node a bound field reads from — what the picker's "show" points at */
|
|
269
|
+
providerOf(doc: IRefTreeNode, nodeId: string, value: unknown): string | null {
|
|
270
|
+
const parsed = isRefValue(value) ? parseRef(value.$ref) : null;
|
|
271
|
+
return parsed ? findRefProvider(doc, nodeId, parsed.name, this.provides)?.node.id ?? null : null;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** the nodes whose references named like `provider` resolve to it */
|
|
275
|
+
private scopeOf(doc: IRefTreeNode, provider: IRefTreeNode, name: string): IRefTreeNode[] {
|
|
276
|
+
const out: IRefTreeNode[] = [];
|
|
277
|
+
for (const node of treeNodes(doc)) {
|
|
278
|
+
if (node.id === provider.id) continue;
|
|
279
|
+
if (findRefProvider(doc, node.id, name, this.provides)?.node.id === provider.id) out.push(node);
|
|
280
|
+
}
|
|
281
|
+
return out;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
private providerAt(doc: IRefTreeNode, providerId: string): { node: IRefTreeNode; provides: IRefProviderDeclaration; name: string } | null {
|
|
285
|
+
const node = [...treeNodes(doc)].find((candidate) => candidate.id === providerId);
|
|
286
|
+
const provides = node ? this.provides(node.reference) : null;
|
|
287
|
+
return node && provides ? { node, provides, name: providerAddressOf(provides, node.id) } : null;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Every non-empty literal a block in `providerId`'s scope declares `bindable` — of the
|
|
292
|
+
* provider's type (canBind: nobody looks at each field, so both must say the same type) —
|
|
293
|
+
* becomes a reference to it; the provider's own settings grow one key per field. Modules must
|
|
294
|
+
* be loaded (loadAll) for the declarations to be known; a provider that cannot keep new values
|
|
295
|
+
* binds nothing.
|
|
296
|
+
*/
|
|
297
|
+
bindScopePatches(doc: IRefTreeNode, providerId: string, views: TViews): { patches: ISettingsPatch[]; bound: number } {
|
|
298
|
+
const provider = this.providerAt(doc, providerId);
|
|
299
|
+
const create = provider?.provides.refs.create;
|
|
300
|
+
if (!provider || !create) return { patches: [], bound: 0 };
|
|
301
|
+
let providerSettings = provider.node.settings ?? {};
|
|
302
|
+
const patches: ISettingsPatch[] = [];
|
|
303
|
+
let bound = 0;
|
|
304
|
+
for (const node of this.scopeOf(doc, provider.node, provider.name)) {
|
|
305
|
+
const patch: Record<string, unknown> = {};
|
|
306
|
+
for (const { field, type } of this.bindableFields(node.reference)) {
|
|
307
|
+
if (!canBind(type, provider.provides.type, false)) continue;
|
|
308
|
+
const value = node.settings?.[field];
|
|
309
|
+
if (typeof value !== 'string' || !value.trim()) continue;
|
|
310
|
+
const created = create(providerSettings, value, ctxOf(views, provider.name), { node: localId(node.id), setting: field });
|
|
311
|
+
providerSettings = created.settings;
|
|
312
|
+
patch[field] = refValue(provider.name, created.key);
|
|
313
|
+
bound += 1;
|
|
314
|
+
}
|
|
315
|
+
if (Object.keys(patch).length) patches.push({ nodeId: node.id, patch });
|
|
316
|
+
}
|
|
317
|
+
if (bound) patches.unshift({ nodeId: providerId, patch: providerSettings });
|
|
318
|
+
return { patches, bound };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* The keys of `providerId`'s values its scope references, in the page's order, each with WHAT
|
|
323
|
+
* reads it — the block and its setting (`heading · text`, `button · text`): what a translator
|
|
324
|
+
* needs to know about a string besides the string.
|
|
325
|
+
*/
|
|
326
|
+
outline(doc: IRefTreeNode, providerId: string): { key: string; role: string; nodes: string[] }[] {
|
|
327
|
+
const provider = this.providerAt(doc, providerId);
|
|
328
|
+
if (!provider) return [];
|
|
329
|
+
const roles = new Map<string, { roles: string[]; nodes: string[] }>();
|
|
330
|
+
for (const node of this.scopeOf(doc, provider.node, provider.name)) {
|
|
331
|
+
const block = /_n_(.+?)_v_/.exec(node.reference)?.[1] ?? 'block';
|
|
332
|
+
for (const [field, value] of Object.entries(node.settings ?? {})) {
|
|
333
|
+
const parsed = isRefValue(value) ? parseRef(value.$ref) : null;
|
|
334
|
+
if (parsed?.name !== provider.name) continue;
|
|
335
|
+
const role = `${block} · ${field}`;
|
|
336
|
+
const known = roles.get(parsed.key) ?? { roles: [], nodes: [] };
|
|
337
|
+
if (!known.roles.includes(role)) known.roles.push(role);
|
|
338
|
+
if (!known.nodes.includes(node.id)) known.nodes.push(node.id);
|
|
339
|
+
roles.set(parsed.key, known);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
// the nodes that read a key — where to point the author when its words are missing somewhere
|
|
343
|
+
return [...roles].map(([key, { roles: list, nodes }]) => ({ key, role: list.join(', '), nodes }));
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* The links blocks that just ARRIVED (inserted, pasted — `arrived` and everything under them)
|
|
348
|
+
* get from the providers around them that take new blocks under their wing (a node's
|
|
349
|
+
* `autoBind`): each non-empty literal of a bindable field goes to the nearest such provider of
|
|
350
|
+
* its type, as a fresh key. Modules must be loaded (loadAll); what is not known is left alone.
|
|
351
|
+
*/
|
|
352
|
+
autoBindPatches(doc: IRefTreeNode, arrived: readonly string[], views: TViews): ISettingsPatch[] {
|
|
353
|
+
const nodes = new Map<string, IRefTreeNode>();
|
|
354
|
+
for (const id of arrived) {
|
|
355
|
+
const top = [...treeNodes(doc)].find((candidate) => candidate.id === id);
|
|
356
|
+
if (top) for (const node of treeNodes(top)) nodes.set(node.id, node);
|
|
357
|
+
}
|
|
358
|
+
const providerSettings = new Map<string, Record<string, unknown>>();
|
|
359
|
+
const patches: ISettingsPatch[] = [];
|
|
360
|
+
for (const node of nodes.values()) {
|
|
361
|
+
const fields = this.bindableFields(node.reference);
|
|
362
|
+
if (!fields.length) continue;
|
|
363
|
+
// the providers this node reaches that take new blocks — nearest first, as the lookup orders them
|
|
364
|
+
const keepers = reachableRefProviders(doc, node.id, this.provides)
|
|
365
|
+
.filter((found) => (found.node as { autoBind?: boolean }).autoBind === true && typeof found.provides.refs.create === 'function');
|
|
366
|
+
if (!keepers.length) continue;
|
|
367
|
+
const patch: Record<string, unknown> = {};
|
|
368
|
+
for (const { field, type } of fields) {
|
|
369
|
+
const value = node.settings?.[field];
|
|
370
|
+
if (typeof value !== 'string' || !value.trim()) continue;
|
|
371
|
+
const keeper = keepers.find((found) => canBind(type, found.provides.type, false));
|
|
372
|
+
if (!keeper) continue;
|
|
373
|
+
const before = providerSettings.get(keeper.node.id) ?? keeper.node.settings ?? {};
|
|
374
|
+
const created = keeper.provides.refs.create!(before, value, ctxOf(views, keeper.name), { node: localId(node.id), setting: field });
|
|
375
|
+
providerSettings.set(keeper.node.id, created.settings);
|
|
376
|
+
patch[field] = refValue(keeper.name, created.key);
|
|
377
|
+
}
|
|
378
|
+
if (Object.keys(patch).length) patches.push({ nodeId: node.id, patch });
|
|
379
|
+
}
|
|
380
|
+
return [...[...providerSettings].map(([nodeId, patch]) => ({ nodeId, patch })), ...patches];
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** whether a node provides references that can keep new values — what may take new blocks under its wing */
|
|
384
|
+
canKeep(reference: string): { readonly type: IBindableField['type'] } | null {
|
|
385
|
+
const provides = this.provides(reference);
|
|
386
|
+
return provides && typeof provides.refs.create === 'function' ? { type: provides.type ?? null } : null;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** the keys of `providerId`'s values that some node in its scope references */
|
|
390
|
+
usedKeys(doc: IRefTreeNode, providerId: string): string[] {
|
|
391
|
+
const provider = this.providerAt(doc, providerId);
|
|
392
|
+
if (!provider) return [];
|
|
393
|
+
const keys = new Set<string>();
|
|
394
|
+
for (const node of this.scopeOf(doc, provider.node, provider.name)) {
|
|
395
|
+
for (const value of Object.values(node.settings ?? {})) {
|
|
396
|
+
const parsed = isRefValue(value) ? parseRef(value.$ref) : null;
|
|
397
|
+
if (parsed?.name === provider.name) keys.add(parsed.key);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
return [...keys];
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const REFS_EDITING_TOKEN = new SingleDiToken<RefsEditing>('EDITOR_REFS_EDITING_TOKEN');
|
|
405
|
+
|
|
406
|
+
/** the workbench's one reference editor — the modules it loaded are shared by every host */
|
|
407
|
+
export function ensureRefsEditing(di: DiContainer): RefsEditing {
|
|
408
|
+
if (!di.hasByToken(REFS_EDITING_TOKEN)) {
|
|
409
|
+
di.registerValueOfToken(REFS_EDITING_TOKEN, new RefsEditing(di.hasByToken(TILES_HOLDER_TOKEN) ? di.getByToken(TILES_HOLDER_TOKEN) : null));
|
|
410
|
+
}
|
|
411
|
+
return di.getByToken(REFS_EDITING_TOKEN);
|
|
412
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// A LIVE SPECIMEN in a card (the Distributions wall, a block's variants): the block's application
|
|
2
|
+
// mounted once the card comes into sight — a wall of applications all mounted at once would be paid
|
|
3
|
+
// for blocks nobody scrolls to — laid out at a stage width and scaled to the card's.
|
|
4
|
+
import { useEffect, useState } from 'react';
|
|
5
|
+
import type { RefObject } from 'react';
|
|
6
|
+
import type { DiContainer } from '@mosetta/di';
|
|
7
|
+
import { useMountedTree } from './useMountedTree';
|
|
8
|
+
import type { IMountSpec } from './mountedTree';
|
|
9
|
+
|
|
10
|
+
/** the width a specimen is laid out at — the card shows it scaled to its own width */
|
|
11
|
+
export const STAGE_WIDTH = 520;
|
|
12
|
+
|
|
13
|
+
/** whether the element has come into sight (with a margin) — once seen, always seen */
|
|
14
|
+
export function useSeen(element: RefObject<HTMLElement | null>): boolean {
|
|
15
|
+
const [seen, setSeen] = useState(false);
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
const target = element.current;
|
|
18
|
+
if (!target || seen) return undefined;
|
|
19
|
+
const view = target.ownerDocument.defaultView;
|
|
20
|
+
if (!view?.IntersectionObserver) {
|
|
21
|
+
setSeen(true);
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
const watch = new view.IntersectionObserver((entries) => {
|
|
25
|
+
if (entries.some((entry) => entry.isIntersecting)) setSeen(true);
|
|
26
|
+
}, { rootMargin: '200px' });
|
|
27
|
+
watch.observe(target);
|
|
28
|
+
return () => watch.disconnect();
|
|
29
|
+
}, [element, seen]);
|
|
30
|
+
return seen;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The specimen's application in the stage the returned ref points at, laid out at `STAGE_WIDTH` and
|
|
35
|
+
* scaled to `card`'s width: the scale to apply, and the height the card should give it.
|
|
36
|
+
*/
|
|
37
|
+
export function useScaledStage(card: RefObject<HTMLElement | null>, spec: IMountSpec | null, parentDi?: DiContainer): {
|
|
38
|
+
stage: React.MutableRefObject<HTMLDivElement | null>;
|
|
39
|
+
failed: boolean;
|
|
40
|
+
scale: number;
|
|
41
|
+
height: number;
|
|
42
|
+
} {
|
|
43
|
+
const { ref: stage, failed } = useMountedTree(spec, parentDi);
|
|
44
|
+
const [scale, setScale] = useState(0.5);
|
|
45
|
+
const [height, setHeight] = useState(0);
|
|
46
|
+
const mounted = spec !== null;
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
const frame = card.current;
|
|
49
|
+
const element = stage.current;
|
|
50
|
+
if (!frame || !element || typeof ResizeObserver === 'undefined') return undefined;
|
|
51
|
+
const measure = () => {
|
|
52
|
+
const next = Math.min(1, frame.clientWidth / STAGE_WIDTH);
|
|
53
|
+
setScale(next);
|
|
54
|
+
setHeight(element.scrollHeight * next);
|
|
55
|
+
};
|
|
56
|
+
const observer = new ResizeObserver(measure);
|
|
57
|
+
observer.observe(element);
|
|
58
|
+
observer.observe(frame);
|
|
59
|
+
measure();
|
|
60
|
+
return () => observer.disconnect();
|
|
61
|
+
}, [card, stage, mounted]);
|
|
62
|
+
return { stage, failed, scale, height };
|
|
63
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/* The search field: the glass sits over the input's left padding, out of the pointer's way. The
|
|
2
|
+
wrapper is a flex row standing where the input stood (the input keeps its own class and
|
|
3
|
+
sizing); the input only makes room on the left. */
|
|
4
|
+
.search-field {
|
|
5
|
+
position: relative;
|
|
6
|
+
display: flex;
|
|
7
|
+
align-items: center;
|
|
8
|
+
flex: 1;
|
|
9
|
+
min-width: 0;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
.search-field > input {
|
|
13
|
+
flex: 1;
|
|
14
|
+
min-width: 0;
|
|
15
|
+
width: 100%;
|
|
16
|
+
padding-left: 28px !important;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
.search-field__glass {
|
|
20
|
+
position: absolute;
|
|
21
|
+
top: 50%;
|
|
22
|
+
left: 9px;
|
|
23
|
+
display: flex;
|
|
24
|
+
transform: translateY(-50%);
|
|
25
|
+
color: #a1a1aa;
|
|
26
|
+
pointer-events: none;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.search-field__glass svg {
|
|
30
|
+
width: var(--mosetta-wb-icon-sm, 12px);
|
|
31
|
+
height: var(--mosetta-wb-icon-sm, 12px);
|
|
32
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// A SEARCH FIELD: the input the caller gives, with a magnifying glass in its left corner — the
|
|
2
|
+
// one sign every search box wears (the pages, the blocks, the reference values). The wrapper
|
|
3
|
+
// takes the input's place in the layout; the input keeps its own class and gets the room for
|
|
4
|
+
// the glass from the shared stylesheet.
|
|
5
|
+
import type { ReactNode } from 'react';
|
|
6
|
+
import './searchField.css';
|
|
7
|
+
|
|
8
|
+
const GlassIcon = (
|
|
9
|
+
<svg viewBox="0 0 16 16"
|
|
10
|
+
aria-hidden="true">
|
|
11
|
+
<circle cx="7"
|
|
12
|
+
cy="7"
|
|
13
|
+
r="4.2"
|
|
14
|
+
fill="none"
|
|
15
|
+
stroke="currentColor"
|
|
16
|
+
strokeWidth="1.5" />
|
|
17
|
+
<path d="M10.2 10.2L13.5 13.5"
|
|
18
|
+
fill="none"
|
|
19
|
+
stroke="currentColor"
|
|
20
|
+
strokeWidth="1.6"
|
|
21
|
+
strokeLinecap="round" />
|
|
22
|
+
</svg>
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
export function SearchField({ children, className }: { children: ReactNode; className?: string }) {
|
|
26
|
+
return (
|
|
27
|
+
<span className={`search-field${className ? ` ${className}` : ''}`}>
|
|
28
|
+
<span className="search-field__glass">{GlassIcon}</span>
|
|
29
|
+
{children}
|
|
30
|
+
</span>
|
|
31
|
+
);
|
|
32
|
+
}
|