@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,131 @@
|
|
|
1
|
+
// Pasting WRITING into a page of BLOCKS. A content manager writes in a document, then pastes:
|
|
2
|
+
// the text arrives as plain text or markdown, and it becomes a run of blocks — Heading, Text,
|
|
3
|
+
// List with Items, Quote with a Text inside. Writing stays writing at the door; the page holds
|
|
4
|
+
// composition only. Inline marks (**bold**, [link](url)) stay inside the strings — that is
|
|
5
|
+
// the block's own grammar, not this splitter's business.
|
|
6
|
+
import type { IBlueprintNodeDoc } from '@mosetta/content';
|
|
7
|
+
|
|
8
|
+
/** the references the splitter builds with — one distribution's text blocks at one version */
|
|
9
|
+
export interface IBlockVocabulary {
|
|
10
|
+
readonly heading: string;
|
|
11
|
+
readonly text: string;
|
|
12
|
+
readonly list: string;
|
|
13
|
+
readonly item: string;
|
|
14
|
+
readonly quote: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type TLine
|
|
18
|
+
= | { kind: 'heading'; level: 1 | 2 | 3 | 4; text: string }
|
|
19
|
+
| { kind: 'item'; ordered: boolean; text: string }
|
|
20
|
+
| { kind: 'quote'; text: string }
|
|
21
|
+
| { kind: 'text'; text: string }
|
|
22
|
+
| { kind: 'blank' };
|
|
23
|
+
|
|
24
|
+
const HEADING_RE = /^(#{1,4})\s+(.+?)\s*#*\s*$/;
|
|
25
|
+
const BULLET_RE = /^\s*[-*•]\s+(.+)$/;
|
|
26
|
+
const NUMBERED_RE = /^\s*\d+[.)]\s+(.+)$/;
|
|
27
|
+
const QUOTE_RE = /^\s*>\s?(.*)$/;
|
|
28
|
+
// a lone image line — a picture is a block of its own kind, not text; it is dropped, not pasted
|
|
29
|
+
const IMAGE_RE = /^\s*!\[[^\]]*]\([^)]*\)\s*$/;
|
|
30
|
+
|
|
31
|
+
function classify(raw: string): TLine {
|
|
32
|
+
const line = raw.replace(/\s+$/, '');
|
|
33
|
+
if (!line.trim() || IMAGE_RE.test(line)) return { kind: 'blank' };
|
|
34
|
+
const heading = HEADING_RE.exec(line);
|
|
35
|
+
if (heading) return { kind: 'heading', level: heading[1].length as 1 | 2 | 3 | 4, text: heading[2] };
|
|
36
|
+
const bullet = BULLET_RE.exec(line);
|
|
37
|
+
if (bullet) return { kind: 'item', ordered: false, text: bullet[1] };
|
|
38
|
+
const numbered = NUMBERED_RE.exec(line);
|
|
39
|
+
if (numbered) return { kind: 'item', ordered: true, text: numbered[1] };
|
|
40
|
+
const quote = QUOTE_RE.exec(line);
|
|
41
|
+
if (quote) return { kind: 'quote', text: quote[1] };
|
|
42
|
+
return { kind: 'text', text: line.trim() };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** the paste has real words in it, not just whitespace */
|
|
46
|
+
export const hasText = (text: string): boolean => text.trim().length > 0;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Plain text or markdown → blocks. Consecutive text lines are ONE paragraph (a hard wrap inside
|
|
50
|
+
* a paragraph is not a paragraph break; a blank line is), consecutive items one list,
|
|
51
|
+
* consecutive quote lines one quote. Ids are placeholders — the session renumbers on paste.
|
|
52
|
+
*/
|
|
53
|
+
export function blocksFromText(text: string, vocab: IBlockVocabulary): IBlueprintNodeDoc[] {
|
|
54
|
+
const blocks: IBlueprintNodeDoc[] = [];
|
|
55
|
+
let seq = 0;
|
|
56
|
+
const id = () => `pasted-${++seq}`;
|
|
57
|
+
const textNode = (value: string): IBlueprintNodeDoc => ({
|
|
58
|
+
id: id(), reference: vocab.text, settings: { text: value, tone: 'default', size: 'default' }, children: [],
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
let paragraph: string[] = [];
|
|
62
|
+
let list: { ordered: boolean; items: string[] } | null = null;
|
|
63
|
+
let quote: string[] = [];
|
|
64
|
+
const flushParagraph = () => {
|
|
65
|
+
if (paragraph.length) blocks.push(textNode(paragraph.join(' ')));
|
|
66
|
+
paragraph = [];
|
|
67
|
+
};
|
|
68
|
+
const flushList = () => {
|
|
69
|
+
if (list) {
|
|
70
|
+
blocks.push({
|
|
71
|
+
id: id(),
|
|
72
|
+
reference: vocab.list,
|
|
73
|
+
settings: { ordered: list.ordered },
|
|
74
|
+
children: list.items.map((item) => ({ id: id(), reference: vocab.item, settings: { text: item }, children: [] })),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
list = null;
|
|
78
|
+
};
|
|
79
|
+
const flushQuote = () => {
|
|
80
|
+
if (quote.length) {
|
|
81
|
+
// blank quote lines split it into paragraphs inside the one quotation
|
|
82
|
+
const paragraphs = quote.join('\n').split(/\n\s*\n/).map((p) => p.replace(/\s*\n\s*/g, ' ').trim()).filter(Boolean);
|
|
83
|
+
blocks.push({ id: id(), reference: vocab.quote, settings: { cite: '' }, children: paragraphs.map(textNode) });
|
|
84
|
+
}
|
|
85
|
+
quote = [];
|
|
86
|
+
};
|
|
87
|
+
const flushAll = () => {
|
|
88
|
+
flushParagraph();
|
|
89
|
+
flushList();
|
|
90
|
+
flushQuote();
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
for (const raw of text.replace(/\r\n?/g, '\n').split('\n')) {
|
|
94
|
+
const line = classify(raw);
|
|
95
|
+
switch (line.kind) {
|
|
96
|
+
case 'blank':
|
|
97
|
+
flushParagraph();
|
|
98
|
+
flushList();
|
|
99
|
+
if (quote.length) quote.push('');
|
|
100
|
+
break;
|
|
101
|
+
case 'heading':
|
|
102
|
+
flushAll();
|
|
103
|
+
blocks.push({ id: id(), reference: vocab.heading, settings: { text: line.text, level: line.level }, children: [] });
|
|
104
|
+
break;
|
|
105
|
+
case 'item':
|
|
106
|
+
flushParagraph();
|
|
107
|
+
flushQuote();
|
|
108
|
+
if (list && list.ordered !== line.ordered) flushList();
|
|
109
|
+
list ??= { ordered: line.ordered, items: [] };
|
|
110
|
+
list.items.push(line.text);
|
|
111
|
+
break;
|
|
112
|
+
case 'quote':
|
|
113
|
+
flushParagraph();
|
|
114
|
+
flushList();
|
|
115
|
+
quote.push(line.text);
|
|
116
|
+
break;
|
|
117
|
+
case 'text':
|
|
118
|
+
flushList();
|
|
119
|
+
if (quote.length && quote[quote.length - 1] !== '') {
|
|
120
|
+
quote.push(line.text); // a lazy continuation line belongs to the quote above it
|
|
121
|
+
} else {
|
|
122
|
+
flushQuote();
|
|
123
|
+
paragraph.push(line.text);
|
|
124
|
+
}
|
|
125
|
+
break;
|
|
126
|
+
default:
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
flushAll();
|
|
130
|
+
return blocks;
|
|
131
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// A PREFAB's OPEN FIELDS, from both sides.
|
|
2
|
+
//
|
|
3
|
+
// On a page, a prefab node is an INSTANCE: its blocks are the prefab's and not changed here, but
|
|
4
|
+
// the settings the prefab opens — a button's text, its address — each page sets for itself. The
|
|
5
|
+
// form lists them with the prefab's own value as the hint; an emptied field falls back to it.
|
|
6
|
+
//
|
|
7
|
+
// In the prefab, a block's settings can be OPENED: "each page sets this". The prefab's document
|
|
8
|
+
// keeps the list (node, setting, label) — one undoable step per change, like any other edit.
|
|
9
|
+
import { EditingSession, FIELDS_SETTING, findNodeDoc } from '@mosetta/content';
|
|
10
|
+
import type { IBlueprintNodeDoc, IPrefabField } from '@mosetta/content';
|
|
11
|
+
import type { IPrefabTree } from './contentCommon';
|
|
12
|
+
|
|
13
|
+
type TFieldValues = Record<string, Record<string, unknown>>;
|
|
14
|
+
|
|
15
|
+
/** settings that are not a block's content: the module versions it was placed with, its styles */
|
|
16
|
+
const NOT_OPENABLE = new Set(['versions', 'styles', FIELDS_SETTING]);
|
|
17
|
+
|
|
18
|
+
/** a scalar setting as an input shows it; anything else shows nothing */
|
|
19
|
+
const shown = (value: unknown): string => (typeof value === 'string' || typeof value === 'number' ? String(value) : '');
|
|
20
|
+
|
|
21
|
+
const humanize = (setting: string): string => setting.replace(/([A-Z])/g, ' $1').replace(/^./, (c) => c.toUpperCase());
|
|
22
|
+
|
|
23
|
+
function FieldInput({ value, fallback, onChange, testId }: {
|
|
24
|
+
value: unknown;
|
|
25
|
+
fallback: unknown;
|
|
26
|
+
onChange: (value: unknown) => void;
|
|
27
|
+
testId: string;
|
|
28
|
+
}) {
|
|
29
|
+
if (typeof fallback === 'boolean') {
|
|
30
|
+
return (
|
|
31
|
+
<input type="checkbox"
|
|
32
|
+
data-test-id={testId}
|
|
33
|
+
checked={typeof value === 'boolean' ? value : fallback}
|
|
34
|
+
onChange={(e) => onChange(e.target.checked)} />
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
return (
|
|
38
|
+
<input className="settings__meta-input"
|
|
39
|
+
data-test-id={testId}
|
|
40
|
+
type={typeof fallback === 'number' ? 'number' : 'text'}
|
|
41
|
+
value={shown(value)}
|
|
42
|
+
placeholder={shown(fallback)}
|
|
43
|
+
onChange={(e) => {
|
|
44
|
+
const raw = e.target.value;
|
|
45
|
+
// emptied: the prefab's own value comes back
|
|
46
|
+
if (raw === '') onChange(undefined);
|
|
47
|
+
else onChange(typeof fallback === 'number' ? Number(raw) : raw);
|
|
48
|
+
}} />
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* An instance of a prefab on a page: the fields it opens, and nothing else. With `only` — one of
|
|
54
|
+
* the prefab's blocks, selected on the page to be seen — the fields of that block alone, or nothing.
|
|
55
|
+
*/
|
|
56
|
+
export function PrefabInstanceForm({ session, node, prefab, only }: {
|
|
57
|
+
session: EditingSession;
|
|
58
|
+
node: IBlueprintNodeDoc;
|
|
59
|
+
prefab: IPrefabTree | null;
|
|
60
|
+
only?: string;
|
|
61
|
+
}) {
|
|
62
|
+
if (!prefab) {
|
|
63
|
+
return (
|
|
64
|
+
<div className="settings__hint"
|
|
65
|
+
data-prefab-unknown>
|
|
66
|
+
A prefab of a library: it is placed as it is, with nothing to set here.
|
|
67
|
+
</div>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
const fields = (prefab.doc.fields ?? []).filter((field) => only === undefined || field.node === only);
|
|
71
|
+
if (!fields.length && only !== undefined) return null;
|
|
72
|
+
if (!fields.length) {
|
|
73
|
+
return (
|
|
74
|
+
<div className="settings__hint"
|
|
75
|
+
data-prefab-closed={prefab.name}>
|
|
76
|
+
The prefab {prefab.name} opens no fields — every page shows it as it is. Open some in the
|
|
77
|
+
prefab itself (the Prefabs panel) to set them page by page.
|
|
78
|
+
</div>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
const values = (node.settings?.[FIELDS_SETTING] ?? {}) as TFieldValues;
|
|
82
|
+
const write = (field: IPrefabField, value: unknown) => {
|
|
83
|
+
const block = { ...values[field.node] };
|
|
84
|
+
if (value === undefined) delete block[field.setting];
|
|
85
|
+
else block[field.setting] = value;
|
|
86
|
+
const next = { ...values, [field.node]: block };
|
|
87
|
+
if (!Object.keys(block).length) delete next[field.node];
|
|
88
|
+
session.updateNodeSettings(node.id, { [FIELDS_SETTING]: next });
|
|
89
|
+
};
|
|
90
|
+
return (
|
|
91
|
+
<div className="settings__meta"
|
|
92
|
+
data-test-id="settings-prefab-fields"
|
|
93
|
+
data-prefab={prefab.name}>
|
|
94
|
+
<div className="settings__target">{only !== undefined ? 'This page sets' : `Prefab ${prefab.name}`}</div>
|
|
95
|
+
{fields.map((field) => {
|
|
96
|
+
const fallback = findNodeDoc(prefab.doc, field.node)?.settings?.[field.setting];
|
|
97
|
+
return (
|
|
98
|
+
<label key={`${field.node}.${field.setting}`}
|
|
99
|
+
className="settings__meta-field">
|
|
100
|
+
<span className="settings__meta-label">{field.label}</span>
|
|
101
|
+
<FieldInput value={values[field.node]?.[field.setting]}
|
|
102
|
+
fallback={fallback}
|
|
103
|
+
testId={`prefab-field-${field.node}-${field.setting}`}
|
|
104
|
+
onChange={(value) => write(field, value)} />
|
|
105
|
+
</label>
|
|
106
|
+
);
|
|
107
|
+
})}
|
|
108
|
+
</div>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** in a prefab: which of this block's settings each page sets for itself */
|
|
113
|
+
export function OpenFieldsForm({ session, doc, node }: {
|
|
114
|
+
session: EditingSession;
|
|
115
|
+
doc: IBlueprintNodeDoc & { fields?: readonly IPrefabField[] };
|
|
116
|
+
node: IBlueprintNodeDoc;
|
|
117
|
+
}) {
|
|
118
|
+
const settings = Object.entries(node.settings ?? {})
|
|
119
|
+
.filter(([key, value]) => !NOT_OPENABLE.has(key) && (value === null || typeof value !== 'object'));
|
|
120
|
+
if (!settings.length) return null;
|
|
121
|
+
const fields = doc.fields ?? [];
|
|
122
|
+
const openOf = (setting: string) => fields.find((f) => f.node === node.id && f.setting === setting);
|
|
123
|
+
const replace = (setting: string, next: IPrefabField | null) => {
|
|
124
|
+
const others = fields.filter((f) => !(f.node === node.id && f.setting === setting));
|
|
125
|
+
session.setPrefabFields(next ? [...others, next] : others);
|
|
126
|
+
};
|
|
127
|
+
return (
|
|
128
|
+
<div className="settings__meta"
|
|
129
|
+
data-test-id="settings-open-fields">
|
|
130
|
+
<div className="settings__target"
|
|
131
|
+
data-tooltip="An open setting is set by each page that places this prefab; the rest stays the prefab's">
|
|
132
|
+
Each page sets
|
|
133
|
+
</div>
|
|
134
|
+
{settings.map(([setting]) => {
|
|
135
|
+
const open = openOf(setting);
|
|
136
|
+
return (
|
|
137
|
+
<div key={setting}
|
|
138
|
+
className="settings__meta-field">
|
|
139
|
+
<label className="settings__meta-flag">
|
|
140
|
+
<input type="checkbox"
|
|
141
|
+
data-test-id={`open-field-${setting}`}
|
|
142
|
+
checked={Boolean(open)}
|
|
143
|
+
onChange={(e) => replace(setting, e.target.checked ? { node: node.id, setting, label: humanize(setting) } : null)} />
|
|
144
|
+
<span className="settings__meta-label">{humanize(setting)}</span>
|
|
145
|
+
</label>
|
|
146
|
+
{open ? (
|
|
147
|
+
<input className="settings__meta-input"
|
|
148
|
+
data-test-id={`open-field-label-${setting}`}
|
|
149
|
+
value={open.label}
|
|
150
|
+
placeholder="what the page's form calls it"
|
|
151
|
+
onChange={(e) => replace(setting, { ...open, label: e.target.value || humanize(setting) })} />
|
|
152
|
+
) : null}
|
|
153
|
+
</div>
|
|
154
|
+
);
|
|
155
|
+
})}
|
|
156
|
+
</div>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// A PREFAB's own blocks on a page — PARTS. A prefab node on a page places the prefab's live tree;
|
|
2
|
+
// the preview expands it, each block under `<prefabNode>/<block>`. Those blocks are the prefab's,
|
|
3
|
+
// not the page's: they are SEEN here — selected, framed, their settings shown read-only, with the
|
|
4
|
+
// way to the prefab where they are changed — never edited. The open fields the prefab gives a page
|
|
5
|
+
// are the exception, and they are the prefab node's settings anyway.
|
|
6
|
+
//
|
|
7
|
+
// A part's settings live in the prefab's document, and so do the providers its references read
|
|
8
|
+
// (a layout's i18n): whatever reads a part reads it against the prefab's own tree, by its own id.
|
|
9
|
+
import { SingleDiToken } from '@mosetta/di';
|
|
10
|
+
import type { DiContainer } from '@mosetta/di';
|
|
11
|
+
import { findNodeDoc } from '@mosetta/content';
|
|
12
|
+
import type { EditingSession, IBlueprintNodeDoc } from '@mosetta/content';
|
|
13
|
+
import { unitOwnerOf, UNIT_ID_SEPARATOR } from '@mosetta/content/units';
|
|
14
|
+
import type { IPrefabTree } from './contentCommon';
|
|
15
|
+
import type { Workspace } from '../workbench/workspace';
|
|
16
|
+
|
|
17
|
+
export interface IPrefabPart {
|
|
18
|
+
/** the prefab node on the page */
|
|
19
|
+
readonly owner: IBlueprintNodeDoc;
|
|
20
|
+
/** the prefab's live tree — its own document */
|
|
21
|
+
readonly prefab: IPrefabTree;
|
|
22
|
+
/** the block, as the prefab's document holds it (its own id) */
|
|
23
|
+
readonly node: IBlueprintNodeDoc;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type TPrefabTreeOf = (reference: string) => IPrefabTree | null;
|
|
27
|
+
|
|
28
|
+
/** whether an id names a prefab's block rather than a node of the page */
|
|
29
|
+
export const isPartId = (id: string | null | undefined): id is string => typeof id === 'string' && unitOwnerOf(id) !== null;
|
|
30
|
+
|
|
31
|
+
/** the part an id names on the page — null for the page's own node, an unknown prefab or block */
|
|
32
|
+
export function prefabPartOf(doc: IBlueprintNodeDoc, id: string, treeOf: TPrefabTreeOf): IPrefabPart | null {
|
|
33
|
+
const ownerId = unitOwnerOf(id);
|
|
34
|
+
if (ownerId === null) return null;
|
|
35
|
+
const owner = findNodeDoc(doc, ownerId);
|
|
36
|
+
const prefab = owner ? treeOf(owner.reference) : null;
|
|
37
|
+
const node = prefab ? findNodeDoc(prefab.doc, id.slice(ownerId.length + UNIT_ID_SEPARATOR.length)) : null;
|
|
38
|
+
return owner && prefab && node ? { owner, prefab, node } : null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The prefab trees the workbench knows, for code without hooks (the settings draft reads a part's
|
|
43
|
+
* settings through it). Fed by whoever holds the project's prefabs (useProjectPrefabs); told
|
|
44
|
+
* whenever they change.
|
|
45
|
+
*/
|
|
46
|
+
export class PrefabTrees {
|
|
47
|
+
private treeOfNow: TPrefabTreeOf = () => null;
|
|
48
|
+
|
|
49
|
+
private readonly listeners = new Set<() => void>();
|
|
50
|
+
|
|
51
|
+
readonly treeOf: TPrefabTreeOf = (reference) => this.treeOfNow(reference);
|
|
52
|
+
|
|
53
|
+
feed(treeOf: TPrefabTreeOf): void {
|
|
54
|
+
if (treeOf === this.treeOfNow) return;
|
|
55
|
+
this.treeOfNow = treeOf;
|
|
56
|
+
for (const listener of [...this.listeners]) listener();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
readonly subscribe = (listener: () => void): (() => void) => {
|
|
60
|
+
this.listeners.add(listener);
|
|
61
|
+
return () => {
|
|
62
|
+
this.listeners.delete(listener);
|
|
63
|
+
};
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const PREFAB_TREES_TOKEN = new SingleDiToken<PrefabTrees>('PREFAB_TREES_TOKEN');
|
|
68
|
+
|
|
69
|
+
/** one per workbench, brought into being on first use */
|
|
70
|
+
export function ensurePrefabTrees(di: DiContainer): PrefabTrees {
|
|
71
|
+
if (!di.hasByToken(PREFAB_TREES_TOKEN)) di.registerValueOfToken(PREFAB_TREES_TOKEN, new PrefabTrees());
|
|
72
|
+
return di.getByToken(PREFAB_TREES_TOKEN);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** how long the prefab may take to open before the wish to select its block is dropped */
|
|
76
|
+
const OPEN_PATIENCE_MS = 15000;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Where a part is changed: its prefab opens in the workbench (its live revision, or its newest
|
|
80
|
+
* draft while nothing is published) — and once the prefab's document is in the canvas being
|
|
81
|
+
* worked in (`working`, the hub's active session), the block is selected there.
|
|
82
|
+
*/
|
|
83
|
+
export function openInPrefab(workspace: Workspace, working: EditingSession, part: IPrefabPart): void {
|
|
84
|
+
const name = part.prefab.name;
|
|
85
|
+
const nodeId = part.node.id;
|
|
86
|
+
workspace.open({ kind: 'prefabs', page: name });
|
|
87
|
+
const arrived = (): boolean => {
|
|
88
|
+
const now = working.snapshot();
|
|
89
|
+
return now.kind === 'prefabs' && now.pageName === name && now.doc !== null && findNodeDoc(now.doc, nodeId) !== null;
|
|
90
|
+
};
|
|
91
|
+
// selected outside the session's own notification — a pick inside it would nest one in another
|
|
92
|
+
const select = () => queueMicrotask(() => working.selectNode(nodeId));
|
|
93
|
+
if (arrived()) {
|
|
94
|
+
select();
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
let off = (): void => undefined;
|
|
98
|
+
const timer = setTimeout(() => off(), OPEN_PATIENCE_MS);
|
|
99
|
+
off = working.subscribe(() => {
|
|
100
|
+
if (!arrived()) return;
|
|
101
|
+
off();
|
|
102
|
+
clearTimeout(timer);
|
|
103
|
+
select();
|
|
104
|
+
});
|
|
105
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// The SPECIMEN's environment (IEditorSide.previewServices): the real panels render against a
|
|
2
|
+
// real session — seeded with a small workbench page — instead of forking their markup into mocks
|
|
3
|
+
// (code-conventions §7a: a preview is the component, only its WORLD is arranged).
|
|
4
|
+
import { DiService } from '@mosetta/di';
|
|
5
|
+
import { EDITING_SESSION_TOKEN, EditingSession, IPageDoc } from '@mosetta/content';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The editor's version, as this very module's address names it: a module is served under its full
|
|
9
|
+
* id (`…/modules/_ns_mosetta-editor_t_editor_n_treeView_v_0_0_2/…`), and every module of the editor
|
|
10
|
+
* shares the editor's version. Read from sources (a test) there is no such address: `0_0_1`.
|
|
11
|
+
*/
|
|
12
|
+
const EDITOR_VERSION = /\/modules\/_ns_mosetta-editor_t_[a-z]+_n_[A-Za-z0-9]+_v_([A-Za-z0-9_]+)\//.exec(import.meta.url)?.[1] ?? '0_0_1';
|
|
13
|
+
|
|
14
|
+
const editorRef = (name: string) => `_ns_mosetta-editor_t_component_n_${name}_v_${EDITOR_VERSION}`;
|
|
15
|
+
|
|
16
|
+
// References are the editor's OWN modules: present in every workbench catalog, so a settings
|
|
17
|
+
// form's entry lookup resolves. A branch, a named slot and a selection — enough for every panel
|
|
18
|
+
// to have something true to say.
|
|
19
|
+
const DEMO_PAGE: IPageDoc = {
|
|
20
|
+
id: 'workbench',
|
|
21
|
+
reference: editorRef('page'),
|
|
22
|
+
pattern: '/',
|
|
23
|
+
children: [
|
|
24
|
+
{ id: 'pages-1', reference: editorRef('pagesPanel'), children: [] },
|
|
25
|
+
{ id: 'canvas-1', reference: editorRef('canvas'), children: [] },
|
|
26
|
+
{ id: 'tree-1', reference: editorRef('treeView'), slot: 'side', children: [] },
|
|
27
|
+
],
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A session already looking at something: the outline has a tree to draw, the settings panel
|
|
32
|
+
* has a selected node — the canvas one, which ships no editor facet, so the panel shows its id,
|
|
33
|
+
* the ssr toggle and the no-form hint. Opened clean (not dirty): the autosave that comes along
|
|
34
|
+
* with any session use never has a reason to write.
|
|
35
|
+
*/
|
|
36
|
+
export class PreviewEditingSession extends EditingSession {
|
|
37
|
+
constructor() {
|
|
38
|
+
super();
|
|
39
|
+
this.openPage('workbench', 'preview-specimen', DEMO_PAGE);
|
|
40
|
+
this.selectNode('canvas-1');
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// the decorator applied as a plain call: module sources build without the legacy-decorator
|
|
45
|
+
// transform, and a facet must stay buildable by the module pipeline as-is
|
|
46
|
+
DiService(EDITING_SESSION_TOKEN)(PreviewEditingSession);
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// The project's content config as the workbench exposes it to the facets it mounts
|
|
2
|
+
// (EDITOR_PROJECT_CONFIG_TOKEN): a store the panels FEED from the server action they already
|
|
3
|
+
// run — a provider's form or chrome (i18n: which languages the project serves) only reads it.
|
|
4
|
+
import { useEffect } from 'react';
|
|
5
|
+
import { DI_CONTAINER_TOKEN, DiContainer } from '@mosetta/di';
|
|
6
|
+
import { EDITOR_PROJECT_CONFIG_TOKEN, useDi } from '@mosetta/react';
|
|
7
|
+
import type { IProjectConfigContract } from '@mosetta/react';
|
|
8
|
+
import { useServerAction } from '@mosetta/react/server-action';
|
|
9
|
+
import { projectConfigToken } from './contentCommon';
|
|
10
|
+
|
|
11
|
+
class ProjectConfigStore implements IProjectConfigContract {
|
|
12
|
+
private config: Readonly<Record<string, unknown>> | null = null;
|
|
13
|
+
|
|
14
|
+
private readonly listeners = new Set<() => void>();
|
|
15
|
+
|
|
16
|
+
readonly snapshot = () => this.config;
|
|
17
|
+
|
|
18
|
+
readonly subscribe = (listener: () => void): (() => void) => {
|
|
19
|
+
this.listeners.add(listener);
|
|
20
|
+
return () => {
|
|
21
|
+
this.listeners.delete(listener);
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
set(config: Readonly<Record<string, unknown>>): void {
|
|
26
|
+
if (JSON.stringify(config) === JSON.stringify(this.config)) return;
|
|
27
|
+
this.config = config;
|
|
28
|
+
for (const listener of [...this.listeners]) listener();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function ensureProjectConfig(di: DiContainer): ProjectConfigStore {
|
|
33
|
+
if (!di.hasByToken(EDITOR_PROJECT_CONFIG_TOKEN)) di.registerValueOfToken(EDITOR_PROJECT_CONFIG_TOKEN, new ProjectConfigStore());
|
|
34
|
+
return di.getByToken(EDITOR_PROJECT_CONFIG_TOKEN) as ProjectConfigStore;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** reads the config (one shared, cached query) and keeps the workbench's store fed with it */
|
|
38
|
+
export function useFeedProjectConfig(): void {
|
|
39
|
+
const di = useDi(DI_CONTAINER_TOKEN);
|
|
40
|
+
const store = ensureProjectConfig(di);
|
|
41
|
+
const config = useServerAction({ token: projectConfigToken, input: {} });
|
|
42
|
+
const data = config.isSuccess() && config.serverData.ok ? config.serverData.config : null;
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
if (data) store.set(data);
|
|
45
|
+
}, [store, data]);
|
|
46
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// The project's OWN prefabs as the editor offers and shows them: the project's namespace (a prefab
|
|
2
|
+
// of the project is referenced under it), the published prefabs and their live trees. One set of
|
|
3
|
+
// shared, cached queries — the palette offers them, the tree shows their blocks read-only, an
|
|
4
|
+
// instance's form reads their open fields. A library's prefab (the workbench) is not the project's:
|
|
5
|
+
// nothing here knows its tree, and it is shown by its name only.
|
|
6
|
+
import { useEffect, useMemo, useRef } from 'react';
|
|
7
|
+
import type { DiContainer } from '@mosetta/di';
|
|
8
|
+
import { useServerAction } from '@mosetta/react/server-action';
|
|
9
|
+
import { EServerActionStart } from '@mosetta/server-action/client';
|
|
10
|
+
import { projectNamespaceOf, unitOf } from '@mosetta/content';
|
|
11
|
+
import { IPrefabTree, listPagesToken, prefabTreesToken, projectConfigToken } from './contentCommon';
|
|
12
|
+
import { ensurePrefabTrees } from './prefabParts';
|
|
13
|
+
|
|
14
|
+
export interface IProjectPrefabs {
|
|
15
|
+
/** the project's namespace — null until the config is in, or when it names none */
|
|
16
|
+
readonly namespace: string | null;
|
|
17
|
+
/** the published prefabs with their live trees, by name */
|
|
18
|
+
readonly prefabs: readonly IPrefabTree[];
|
|
19
|
+
/** the tree a prefab node embeds, when the prefab is the project's and published */
|
|
20
|
+
treeOf(reference: string): IPrefabTree | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const MANUAL = { start: EServerActionStart.START_MANUAL } as const;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* `enabled` must not change over the hook's life: a query's start mode is fixed at its first
|
|
27
|
+
* render (a specimen asks no server, the real workbench always does).
|
|
28
|
+
*/
|
|
29
|
+
export function useProjectPrefabs(enabled = true): IProjectPrefabs {
|
|
30
|
+
const start = enabled ? {} : MANUAL;
|
|
31
|
+
const config = useServerAction({ token: projectConfigToken, input: {}, ...start });
|
|
32
|
+
const listing = useServerAction({ token: listPagesToken, input: { kind: 'prefabs' }, ...start });
|
|
33
|
+
// the names hold while the listing is asked again (an edit invalidates it): the trees' query
|
|
34
|
+
// keeps its key instead of flickering through an empty one
|
|
35
|
+
const known = useRef<string[]>([]);
|
|
36
|
+
if (listing.isSuccess() && listing.serverData.ok) {
|
|
37
|
+
const names = listing.serverData.pages.filter((p) => p.meta.live != null).map((p) => p.name).sort();
|
|
38
|
+
if (names.join('|') !== known.current.join('|')) known.current = names;
|
|
39
|
+
}
|
|
40
|
+
const names = known.current;
|
|
41
|
+
const trees = useServerAction({ token: prefabTreesToken, input: { names }, ...start });
|
|
42
|
+
const namespace = config.isSuccess() && config.serverData.ok ? projectNamespaceOf(config.serverData.config) : null;
|
|
43
|
+
const prefabs = useMemo(() => (trees.isSuccess() && trees.serverData.ok ? trees.serverData.prefabs : []), [trees]);
|
|
44
|
+
return useMemo(() => ({
|
|
45
|
+
namespace,
|
|
46
|
+
prefabs,
|
|
47
|
+
treeOf(reference: string) {
|
|
48
|
+
const unit = unitOf(reference);
|
|
49
|
+
return unit && unit.namespace === namespace ? prefabs.find((p) => p.name === unit.name) ?? null : null;
|
|
50
|
+
},
|
|
51
|
+
}), [namespace, prefabs]);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** the trees handed to the workbench's PrefabTrees — code without hooks reads a prefab's blocks there */
|
|
55
|
+
export function useFeedPrefabTrees(di: DiContainer, project: IProjectPrefabs): void {
|
|
56
|
+
useEffect(() => ensurePrefabTrees(di).feed(project.treeOf), [di, project]);
|
|
57
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/* the publish dialog: a fixed overlay — asked from a list row or from the canvas bar alike */
|
|
2
|
+
.publish-modal__overlay {
|
|
3
|
+
position: fixed;
|
|
4
|
+
inset: 0;
|
|
5
|
+
z-index: 60;
|
|
6
|
+
display: flex;
|
|
7
|
+
align-items: flex-start;
|
|
8
|
+
justify-content: center;
|
|
9
|
+
padding-top: 12vh;
|
|
10
|
+
background: rgb(24 24 27 / 35%);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
.publish-modal {
|
|
14
|
+
display: flex;
|
|
15
|
+
flex-direction: column;
|
|
16
|
+
gap: 8px;
|
|
17
|
+
width: min(440px, calc(100vw - 32px));
|
|
18
|
+
padding: 14px 16px 16px;
|
|
19
|
+
border-radius: 8px;
|
|
20
|
+
font: var(--mosetta-wb-font, 14px)/1.45 ui-sans-serif, system-ui, sans-serif;
|
|
21
|
+
color: #27272a;
|
|
22
|
+
background: #fff;
|
|
23
|
+
box-shadow: 0 12px 32px rgb(0 0 0 / 25%);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.publish-modal__title {
|
|
27
|
+
font-size: calc(var(--mosetta-wb-font, 14px) + 2px);
|
|
28
|
+
font-weight: 600;
|
|
29
|
+
color: #18181b;
|
|
30
|
+
overflow: hidden;
|
|
31
|
+
text-overflow: ellipsis;
|
|
32
|
+
white-space: nowrap;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.publish-modal__text {
|
|
36
|
+
color: #52525b;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.publish-modal__label {
|
|
40
|
+
margin-top: 4px;
|
|
41
|
+
font-size: var(--mosetta-wb-font-xs, 12px);
|
|
42
|
+
font-weight: 500;
|
|
43
|
+
color: #71717a;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.publish-modal__input {
|
|
47
|
+
height: calc(var(--mosetta-wb-icon-box, 24px) + 6px);
|
|
48
|
+
padding: 0 10px;
|
|
49
|
+
border: 1px solid #d4d4d8;
|
|
50
|
+
border-radius: 6px;
|
|
51
|
+
font: inherit;
|
|
52
|
+
outline: none;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.publish-modal__input:focus {
|
|
56
|
+
border-color: #2563eb;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.publish-modal__hint {
|
|
60
|
+
font-size: var(--mosetta-wb-font-xs, 12px);
|
|
61
|
+
color: #a1a1aa;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.publish-modal__error {
|
|
65
|
+
padding: 6px 8px;
|
|
66
|
+
border-radius: 6px;
|
|
67
|
+
font-size: var(--mosetta-wb-font-sm, 13px);
|
|
68
|
+
color: #b91c1c;
|
|
69
|
+
background: #fef2f2;
|
|
70
|
+
white-space: pre-wrap;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.publish-modal__buttons {
|
|
74
|
+
display: flex;
|
|
75
|
+
justify-content: flex-end;
|
|
76
|
+
gap: 8px;
|
|
77
|
+
margin-top: 6px;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
.publish-modal__button {
|
|
81
|
+
height: calc(var(--mosetta-wb-icon-box, 24px) + 4px);
|
|
82
|
+
padding: 0 12px;
|
|
83
|
+
border: 1px solid #d4d4d8;
|
|
84
|
+
border-radius: 6px;
|
|
85
|
+
font: inherit;
|
|
86
|
+
color: #27272a;
|
|
87
|
+
background: #fff;
|
|
88
|
+
cursor: pointer;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.publish-modal__button:hover {
|
|
92
|
+
background: #f4f4f5;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.publish-modal__button--primary {
|
|
96
|
+
border-color: #2563eb;
|
|
97
|
+
color: #fff;
|
|
98
|
+
background: #2563eb;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.publish-modal__button--primary:hover {
|
|
102
|
+
border-color: #1d4ed8;
|
|
103
|
+
background: #1d4ed8;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
.publish-modal__button:disabled {
|
|
107
|
+
opacity: 0.6;
|
|
108
|
+
cursor: default;
|
|
109
|
+
}
|