@mosetta/editor 0.0.0-stage → 0.0.29
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 +190 -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,379 @@
|
|
|
1
|
+
// THE BLOCK'S PAGE (ADR-0060) — a story of Storybook's kind, for a block of a distribution: the
|
|
2
|
+
// block LIVE on a stage, its real settings form always open beside it, and its VARIANTS — the
|
|
3
|
+
// states its editor facet declares — as a wall of live specimens; one clicked goes into the stage.
|
|
4
|
+
//
|
|
5
|
+
// The PLAYGROUND is the form's own store (blockVariants.ts) behind a door of its own: the form is
|
|
6
|
+
// mounted with a parent container that holds that store and the project's config, nothing else —
|
|
7
|
+
// no references to bind, no media to upload, no document. Whatever is turned here stays here —
|
|
8
|
+
// unless taken away: "Copy block" puts the block, as it stands, on the editor's clipboard (a paste
|
|
9
|
+
// on a page places it), "Copy as JSON" hands the developer what to paste into `variants`.
|
|
10
|
+
//
|
|
11
|
+
// The stage, the form and every specimen are mounted applications (ADR-0034), the way the picker's
|
|
12
|
+
// preview and the settings popover are: the same root, the same `previewServices`.
|
|
13
|
+
import { useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
|
|
14
|
+
import { DI_CONTAINER_TOKEN, DiContainer } from '@mosetta/di';
|
|
15
|
+
import { EDITOR_PROJECT_CONFIG_TOKEN, SETTINGS_DRAFT_TOKEN, useComponent, useDi } from '@mosetta/react';
|
|
16
|
+
import { TILES_HOLDER_TOKEN } from '@mosetta/runtime/client';
|
|
17
|
+
import type { IMountPreview } from '@mosetta/runtime/client';
|
|
18
|
+
import type { ICatalogEntry, IDistributionEntry } from '@mosetta/content';
|
|
19
|
+
import { editorReferenceOf, previewMountFor, previewRootOf } from './componentPreview';
|
|
20
|
+
import type { IComponentFacet, IEditorFacet } from './componentPreview';
|
|
21
|
+
import { hostedPage, hostFallbackOf, loadModule } from './mountedTree';
|
|
22
|
+
import type { IMountSpec } from './mountedTree';
|
|
23
|
+
import { formRootOf } from './formRoot';
|
|
24
|
+
import { useFeedProjectConfig } from './projectConfig';
|
|
25
|
+
import { useMountedTree } from './useMountedTree';
|
|
26
|
+
import { useSeen } from './scaledStage';
|
|
27
|
+
import { atOwnVersion, framed, frameOf, PlaygroundDraft, specimensOf, variantSource } from './blockVariants';
|
|
28
|
+
import { serializeBlocks } from './editingClipboard';
|
|
29
|
+
import { noticesOf } from './editingNotices';
|
|
30
|
+
import type { ISpecimen, IVariantFrame } from './blockVariants';
|
|
31
|
+
import { FORM_HOST_CLASS, SETTINGS_TABS, shownTab, tabsIn } from '../canvas/settingsTabs';
|
|
32
|
+
import type { TSettingsTab } from '../canvas/settingsTabs';
|
|
33
|
+
import './blockPage.css';
|
|
34
|
+
|
|
35
|
+
/** how long "Copied" stands on the button */
|
|
36
|
+
const COPIED_MS = 1600;
|
|
37
|
+
|
|
38
|
+
/** what went to the clipboard last: the block (to paste on a page), or its settings as code */
|
|
39
|
+
type TCopied = 'block' | 'variant' | null;
|
|
40
|
+
|
|
41
|
+
type TFacets
|
|
42
|
+
= | { readonly status: 'looking' }
|
|
43
|
+
| { readonly status: 'failed' }
|
|
44
|
+
| { readonly status: 'ready'; readonly editor: IEditorFacet | undefined; readonly defaults: Record<string, unknown> };
|
|
45
|
+
|
|
46
|
+
/** the block's two facets: what its editor declares, and its default settings */
|
|
47
|
+
function useBlockFacets(component: ICatalogEntry): TFacets {
|
|
48
|
+
const holder = useDi(TILES_HOLDER_TOKEN);
|
|
49
|
+
const [facets, setFacets] = useState<TFacets>({ status: 'looking' });
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
let alive = true;
|
|
52
|
+
void (async () => {
|
|
53
|
+
try {
|
|
54
|
+
const [editor, block] = await Promise.all([
|
|
55
|
+
component.hasEditor ? loadModule(holder, editorReferenceOf(component.id)) : Promise.resolve(undefined),
|
|
56
|
+
loadModule(holder, component.id),
|
|
57
|
+
]);
|
|
58
|
+
const defaults = (block?.module as IComponentFacet | undefined)?.defaultSettings?.() ?? {};
|
|
59
|
+
const facet = editor?.module as IEditorFacet | undefined;
|
|
60
|
+
if (alive) setFacets({ status: 'ready', editor: facet ? atOwnVersion(facet, component.id) : undefined, defaults });
|
|
61
|
+
} catch {
|
|
62
|
+
if (alive) setFacets({ status: 'failed' });
|
|
63
|
+
}
|
|
64
|
+
})();
|
|
65
|
+
return () => {
|
|
66
|
+
alive = false;
|
|
67
|
+
};
|
|
68
|
+
}, [holder, component.id, component.hasEditor]);
|
|
69
|
+
return facets;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** the block, holding what it holds, in its frame, as the root mounts it (`fallback`: hostFallbackOf) */
|
|
73
|
+
function specimenSpec(root: string, frame: IVariantFrame | null, reference: string, specimen: Pick<ISpecimen, 'settings' | 'children'>, preview: IMountPreview, fallback: string): IMountSpec {
|
|
74
|
+
return { rootReference: root, tree: hostedPage(root, framed(frame, reference, specimen), fallback), preview };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* One variant, live, in a card of the wall — at its own size, as a page would show it (a scaled
|
|
79
|
+
* button is no button); mounted once in sight; a click takes it to the stage.
|
|
80
|
+
*/
|
|
81
|
+
function VariantCard({ specimen, spec, picked, onPick }: { specimen: ISpecimen; spec: IMountSpec; picked: boolean; onPick: () => void }) {
|
|
82
|
+
const cardRef = useRef<HTMLDivElement>(null);
|
|
83
|
+
const seen = useSeen(cardRef);
|
|
84
|
+
const { ref: stage, failed } = useMountedTree(seen ? spec : null);
|
|
85
|
+
return (
|
|
86
|
+
<div ref={cardRef}
|
|
87
|
+
className={`block-page__variant${picked ? ' block-page__variant--picked' : ''}`}
|
|
88
|
+
role="button"
|
|
89
|
+
tabIndex={0}
|
|
90
|
+
aria-pressed={picked}
|
|
91
|
+
data-test-id="block-variant"
|
|
92
|
+
data-variant={specimen.name}
|
|
93
|
+
data-picked={picked || undefined}
|
|
94
|
+
onClick={onPick}
|
|
95
|
+
onKeyDown={(e) => {
|
|
96
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
97
|
+
e.preventDefault();
|
|
98
|
+
onPick();
|
|
99
|
+
}
|
|
100
|
+
}}>
|
|
101
|
+
<div className="block-page__variant-head">
|
|
102
|
+
<span className="block-page__variant-name">{specimen.name}</span>
|
|
103
|
+
{specimen.description ? <span className="block-page__variant-description">{specimen.description}</span> : null}
|
|
104
|
+
</div>
|
|
105
|
+
<div className="block-page__variant-view">
|
|
106
|
+
{failed ? <div className="block-page__note">Could not render this variant</div> : null}
|
|
107
|
+
<div ref={stage}
|
|
108
|
+
className="block-page__variant-stage" />
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The block's settings form, always open — the popover's own frame, tabs and all, around the form
|
|
116
|
+
* mounted behind the playground's door.
|
|
117
|
+
*/
|
|
118
|
+
function PlaygroundSettings({ name, form, hasEditor, failed, changed, onReset, onCopyBlock, onCopyVariant, copied }: {
|
|
119
|
+
name: string;
|
|
120
|
+
form: React.MutableRefObject<HTMLDivElement | null>;
|
|
121
|
+
hasEditor: boolean;
|
|
122
|
+
failed: boolean;
|
|
123
|
+
changed: boolean;
|
|
124
|
+
onReset: () => void;
|
|
125
|
+
onCopyBlock: () => void;
|
|
126
|
+
/** the developer's copy: the settings as JSON, an entry of the facet's `variants` */
|
|
127
|
+
onCopyVariant: () => void;
|
|
128
|
+
/** which copy just went to the clipboard — its button says so for a moment */
|
|
129
|
+
copied: TCopied;
|
|
130
|
+
}) {
|
|
131
|
+
const bodyRef = useRef<HTMLDivElement>(null);
|
|
132
|
+
const [tabs, setTabs] = useState<readonly TSettingsTab[]>([]);
|
|
133
|
+
const [wished, setWished] = useState<TSettingsTab>('main');
|
|
134
|
+
const tab = shownTab(tabs, wished);
|
|
135
|
+
// which tabs the form's sections fill, as the mounted form arrives and changes
|
|
136
|
+
useLayoutEffect(() => {
|
|
137
|
+
const body = bodyRef.current;
|
|
138
|
+
if (!body) return undefined;
|
|
139
|
+
const look = () => {
|
|
140
|
+
const found = tabsIn(body);
|
|
141
|
+
setTabs((prev) => (prev.join('|') === found.join('|') ? prev : found));
|
|
142
|
+
};
|
|
143
|
+
look();
|
|
144
|
+
const view = body.ownerDocument.defaultView;
|
|
145
|
+
const watch = view?.MutationObserver ? new view.MutationObserver(look) : null;
|
|
146
|
+
watch?.observe(body, { subtree: true, childList: true });
|
|
147
|
+
return () => watch?.disconnect();
|
|
148
|
+
}, []);
|
|
149
|
+
return (
|
|
150
|
+
<aside className="block-page__settings"
|
|
151
|
+
data-test-id="block-settings">
|
|
152
|
+
<div className="block-page__settings-head">
|
|
153
|
+
<span className="block-page__settings-name">{name}</span>
|
|
154
|
+
<span className="block-page__settings-note">Playground · nothing is saved</span>
|
|
155
|
+
</div>
|
|
156
|
+
{tabs.length > 1 ? (
|
|
157
|
+
<div className="block-page__tabs"
|
|
158
|
+
role="tablist"
|
|
159
|
+
aria-label="Settings">
|
|
160
|
+
{SETTINGS_TABS.filter((entry) => tabs.includes(entry.id)).map((entry) => (
|
|
161
|
+
<button key={entry.id}
|
|
162
|
+
type="button"
|
|
163
|
+
role="tab"
|
|
164
|
+
className={`block-page__tab${entry.id === tab ? ' block-page__tab--on' : ''}`}
|
|
165
|
+
data-test-id="block-settings-tab"
|
|
166
|
+
data-tab={entry.id}
|
|
167
|
+
aria-selected={entry.id === tab}
|
|
168
|
+
onClick={() => setWished(entry.id)}>
|
|
169
|
+
{entry.label}
|
|
170
|
+
</button>
|
|
171
|
+
))}
|
|
172
|
+
</div>
|
|
173
|
+
) : null}
|
|
174
|
+
<div ref={bodyRef}
|
|
175
|
+
className="block-page__settings-body"
|
|
176
|
+
data-settings-tab={tab}>
|
|
177
|
+
{!hasEditor ? <div className="block-page__note">The block has no settings form</div> : null}
|
|
178
|
+
{failed ? <div className="block-page__note">Could not render the block’s settings</div> : null}
|
|
179
|
+
<div ref={form}
|
|
180
|
+
className={FORM_HOST_CLASS}
|
|
181
|
+
data-test-id="block-settings-form" />
|
|
182
|
+
</div>
|
|
183
|
+
<div className="block-page__settings-foot">
|
|
184
|
+
<div className="block-page__settings-actions">
|
|
185
|
+
<button type="button"
|
|
186
|
+
className="block-page__action"
|
|
187
|
+
data-test-id="block-reset"
|
|
188
|
+
disabled={!changed}
|
|
189
|
+
onClick={onReset}>
|
|
190
|
+
Reset
|
|
191
|
+
</button>
|
|
192
|
+
<button type="button"
|
|
193
|
+
className="block-page__action block-page__action--main"
|
|
194
|
+
data-test-id="block-copy-block"
|
|
195
|
+
title="Copy the block as it stands here — paste it on a page (Ctrl+V)"
|
|
196
|
+
onClick={onCopyBlock}>
|
|
197
|
+
{copied === 'block' ? 'Copied' : 'Copy block'}
|
|
198
|
+
</button>
|
|
199
|
+
</div>
|
|
200
|
+
{/* for the block's DEVELOPER: a small link under the buttons — data, not a block to paste */}
|
|
201
|
+
<button type="button"
|
|
202
|
+
className="block-page__json"
|
|
203
|
+
data-test-id="block-copy-variant"
|
|
204
|
+
title="These settings as JSON — an entry of `variants` in the block's .editor.tsx. To paste on a page, use Copy block"
|
|
205
|
+
onClick={onCopyVariant}>
|
|
206
|
+
{copied === 'variant' ? 'Copied' : 'Copy as JSON'}
|
|
207
|
+
</button>
|
|
208
|
+
</div>
|
|
209
|
+
</aside>
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function BlockPage({ component, distribution, components, libraryRoot, onBack }: {
|
|
214
|
+
component: ICatalogEntry;
|
|
215
|
+
distribution: IDistributionEntry;
|
|
216
|
+
components: readonly ICatalogEntry[];
|
|
217
|
+
/** a root module of the block's own library, when it ships one */
|
|
218
|
+
libraryRoot: string | undefined;
|
|
219
|
+
onBack: () => void;
|
|
220
|
+
}) {
|
|
221
|
+
const self = useComponent().reference;
|
|
222
|
+
const di = useDi(DI_CONTAINER_TOKEN);
|
|
223
|
+
// the project's config, for a form that reads it (i18n: which languages) — fed before the door is made
|
|
224
|
+
useFeedProjectConfig();
|
|
225
|
+
const facets = useBlockFacets(component);
|
|
226
|
+
const editor = facets.status === 'ready' ? facets.editor : undefined;
|
|
227
|
+
const defaults = facets.status === 'ready' ? facets.defaults : null;
|
|
228
|
+
const specimens = useMemo(() => (defaults ? specimensOf(component.id, editor, defaults) : []), [component.id, editor, defaults]);
|
|
229
|
+
const [picked, setPicked] = useState(0);
|
|
230
|
+
const current = specimens[picked] as ISpecimen | undefined;
|
|
231
|
+
|
|
232
|
+
// THE PLAYGROUND: the form's store, behind a door of its own
|
|
233
|
+
const [draft] = useState(() => new PlaygroundDraft({}));
|
|
234
|
+
const [door] = useState(() => {
|
|
235
|
+
const own = new DiContainer();
|
|
236
|
+
own.registerValueOfToken(SETTINGS_DRAFT_TOKEN, draft);
|
|
237
|
+
if (di.hasByToken(EDITOR_PROJECT_CONFIG_TOKEN)) own.registerValueOfToken(EDITOR_PROJECT_CONFIG_TOKEN, di.getByToken(EDITOR_PROJECT_CONFIG_TOKEN));
|
|
238
|
+
return own;
|
|
239
|
+
});
|
|
240
|
+
const settings = useSyncExternalStore(draft.subscribe, draft.snapshot, draft.snapshot);
|
|
241
|
+
// the variant picked goes into the playground — before anything reads it: the form is mounted
|
|
242
|
+
// only once it holds the block's settings
|
|
243
|
+
const [primed, setPrimed] = useState(false);
|
|
244
|
+
useLayoutEffect(() => {
|
|
245
|
+
if (!current) return;
|
|
246
|
+
draft.reset(current.settings);
|
|
247
|
+
setPrimed(true);
|
|
248
|
+
}, [current, draft]);
|
|
249
|
+
|
|
250
|
+
// what fails to render is said in the editor's words, at the editor's own version
|
|
251
|
+
const fallback = hostFallbackOf(self);
|
|
252
|
+
const declared = typeof editor?.preview === 'object' ? editor.preview.reference : undefined;
|
|
253
|
+
const root = declared ?? libraryRoot ?? previewRootOf(self);
|
|
254
|
+
const preview = useMemo(() => previewMountFor(editor, editor?.previewServices), [editor]);
|
|
255
|
+
const frame = useMemo(() => frameOf(component.id, editor), [component.id, editor]);
|
|
256
|
+
const stageSpec = useMemo(
|
|
257
|
+
() => (primed && current ? specimenSpec(root, frame, component.id, { settings, children: current.children }, preview, fallback) : null),
|
|
258
|
+
[primed, current, root, frame, component.id, settings, preview, fallback],
|
|
259
|
+
);
|
|
260
|
+
const { ref: stage, failed: stageFailed } = useMountedTree(stageSpec);
|
|
261
|
+
const variantSpecs = useMemo(
|
|
262
|
+
() => specimens.map((specimen) => specimenSpec(root, frame, component.id, specimen, preview, fallback)),
|
|
263
|
+
[specimens, root, frame, component.id, preview, fallback],
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
const formRoot = useMemo(() => formRootOf(components, component.id, self), [components, component.id, self]);
|
|
267
|
+
const formSpec = useMemo(() => (primed && component.hasEditor
|
|
268
|
+
? {
|
|
269
|
+
rootReference: formRoot,
|
|
270
|
+
tree: hostedPage(formRoot, [{ id: 'playground-form', reference: editorReferenceOf(component.id), settings: { placement: 'settings' }, children: [] }], fallback),
|
|
271
|
+
}
|
|
272
|
+
: null), [primed, component.hasEditor, component.id, formRoot, fallback]);
|
|
273
|
+
const { ref: form, failed: formFailed } = useMountedTree(formSpec, door);
|
|
274
|
+
|
|
275
|
+
const stageFrame = useRef<HTMLElement>(null);
|
|
276
|
+
const changed = current !== undefined && JSON.stringify(settings) !== JSON.stringify(current.settings);
|
|
277
|
+
const [copied, setCopied] = useState<TCopied>(null);
|
|
278
|
+
useEffect(() => {
|
|
279
|
+
if (!copied) return undefined;
|
|
280
|
+
const timer = setTimeout(() => setCopied(null), COPIED_MS);
|
|
281
|
+
return () => clearTimeout(timer);
|
|
282
|
+
}, [copied]);
|
|
283
|
+
const notices = noticesOf(di);
|
|
284
|
+
// a browser may refuse the page its clipboard — said, not swallowed
|
|
285
|
+
const refused = () => notices?.show('refuse', 'The browser did not let the page write the clipboard');
|
|
286
|
+
// the BLOCK, as it stands here, in the editor's own clipboard envelope — a paste on a page puts
|
|
287
|
+
// it there (renumbered), the way a block copied from another page arrives
|
|
288
|
+
const copyBlock = () => {
|
|
289
|
+
if (!current) return;
|
|
290
|
+
const block = { id: 'copied-block', reference: component.id, settings, children: [...current.children] };
|
|
291
|
+
void navigator.clipboard?.writeText(serializeBlocks([block])).then(() => {
|
|
292
|
+
setCopied('block');
|
|
293
|
+
notices?.show('copy', 'Copied the block — paste it on a page');
|
|
294
|
+
}, refused);
|
|
295
|
+
};
|
|
296
|
+
// …and its settings as JSON, for the block's developer: an entry of the facet's `variants`
|
|
297
|
+
const copyVariant = () => {
|
|
298
|
+
if (!defaults || !current) return;
|
|
299
|
+
const text = variantSource(changed || picked === 0 ? 'New variant' : current.name, settings, defaults);
|
|
300
|
+
void navigator.clipboard?.writeText(text).then(() => setCopied('variant'), refused);
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
return (
|
|
304
|
+
<div className="block-page"
|
|
305
|
+
data-test-id="block-page"
|
|
306
|
+
data-component={component.id}
|
|
307
|
+
data-block-page={facets.status}>
|
|
308
|
+
<div className="block-page__header">
|
|
309
|
+
<button type="button"
|
|
310
|
+
className="block-page__back"
|
|
311
|
+
data-test-id="block-page-back"
|
|
312
|
+
onClick={onBack}>← {distribution.title}</button>
|
|
313
|
+
<div className="block-page__title-row">
|
|
314
|
+
{component.iconUrl ? <img className="block-page__icon"
|
|
315
|
+
src={component.iconUrl}
|
|
316
|
+
alt=""
|
|
317
|
+
draggable={false} /> : null}
|
|
318
|
+
<h1 className="block-page__title">{component.reference.name}</h1>
|
|
319
|
+
<span className="block-page__meta">{`${component.reference.namespace}@${component.reference.version}`}</span>
|
|
320
|
+
</div>
|
|
321
|
+
</div>
|
|
322
|
+
{facets.status === 'failed' ? <div className="block-page__note">Could not load the block</div> : null}
|
|
323
|
+
<div className="block-page__main">
|
|
324
|
+
<section ref={stageFrame}
|
|
325
|
+
className="block-page__stage-frame">
|
|
326
|
+
<div className="block-page__stage-head">
|
|
327
|
+
<span className="block-page__stage-name"
|
|
328
|
+
data-test-id="block-stage-variant">{current?.name ?? '…'}</span>
|
|
329
|
+
{changed ? <span className="block-page__changed"
|
|
330
|
+
data-test-id="block-changed">changed</span> : null}
|
|
331
|
+
{current?.description ? <span className="block-page__stage-description">{current.description}</span> : null}
|
|
332
|
+
</div>
|
|
333
|
+
<div className="block-page__stage-view">
|
|
334
|
+
{stageFailed ? <div className="block-page__note">Could not render the block</div> : null}
|
|
335
|
+
<div ref={stage}
|
|
336
|
+
className="block-page__stage"
|
|
337
|
+
data-test-id="block-stage" />
|
|
338
|
+
</div>
|
|
339
|
+
</section>
|
|
340
|
+
<PlaygroundSettings name={component.reference.name}
|
|
341
|
+
form={form}
|
|
342
|
+
hasEditor={component.hasEditor}
|
|
343
|
+
failed={formFailed}
|
|
344
|
+
changed={changed}
|
|
345
|
+
onReset={() => current && draft.reset(current.settings)}
|
|
346
|
+
onCopyBlock={copyBlock}
|
|
347
|
+
onCopyVariant={copyVariant}
|
|
348
|
+
copied={copied} />
|
|
349
|
+
{specimens.length ? (
|
|
350
|
+
<section className="block-page__variants-frame">
|
|
351
|
+
<h2 className="block-page__variants-title">
|
|
352
|
+
Variants
|
|
353
|
+
<span className="block-page__variants-count">{specimens.length}</span>
|
|
354
|
+
</h2>
|
|
355
|
+
{specimens.length === 1 ? (
|
|
356
|
+
<p className="block-page__variants-hint">
|
|
357
|
+
The block declares no variants of its own. Its editor side can list them — see “Copy as JSON”.
|
|
358
|
+
</p>
|
|
359
|
+
) : null}
|
|
360
|
+
<div className="block-page__variants">
|
|
361
|
+
{specimens.map((specimen, index) => (
|
|
362
|
+
<VariantCard key={specimen.name}
|
|
363
|
+
specimen={specimen}
|
|
364
|
+
spec={variantSpecs[index]}
|
|
365
|
+
picked={index === picked}
|
|
366
|
+
onPick={() => {
|
|
367
|
+
if (index === picked) draft.reset(specimen.settings);
|
|
368
|
+
setPicked(index);
|
|
369
|
+
// the stage is where the pick shows: into sight, if it is not
|
|
370
|
+
stageFrame.current?.scrollIntoView?.({ block: 'nearest', behavior: 'smooth' });
|
|
371
|
+
}} />
|
|
372
|
+
))}
|
|
373
|
+
</div>
|
|
374
|
+
</section>
|
|
375
|
+
) : null}
|
|
376
|
+
</div>
|
|
377
|
+
</div>
|
|
378
|
+
);
|
|
379
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/* The block picker (blockPicker.tsx): a search on top, the blocks below — folded by provider while
|
|
2
|
+
the search is empty, one ranked list while it is not. */
|
|
3
|
+
.block-picker {
|
|
4
|
+
display: flex;
|
|
5
|
+
flex-direction: column;
|
|
6
|
+
min-height: 0;
|
|
7
|
+
font-family: ui-sans-serif, system-ui, sans-serif;
|
|
8
|
+
font-size: var(--mosetta-wb-font);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/* the search field wrapper takes the input's place in the column; the input fills it. Scoped
|
|
12
|
+
under the picker: the shared field's own rule (`.search-field { flex: 1 }`, searchField.css)
|
|
13
|
+
comes along in every module that uses it, and whichever sheet loads last would win a tie —
|
|
14
|
+
stretching the field to fill the picker, the search adrift in the middle */
|
|
15
|
+
.block-picker > .block-picker__search-field {
|
|
16
|
+
flex: none;
|
|
17
|
+
margin: 0 8px 6px;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
.block-picker__search {
|
|
21
|
+
flex: none;
|
|
22
|
+
box-sizing: border-box;
|
|
23
|
+
height: calc(var(--mosetta-wb-icon-box) + 4px);
|
|
24
|
+
margin: 0;
|
|
25
|
+
padding: 0 8px;
|
|
26
|
+
border: 1px solid #d4d4d8;
|
|
27
|
+
border-radius: 6px;
|
|
28
|
+
outline: none;
|
|
29
|
+
background: #ffffff;
|
|
30
|
+
color: #27272a;
|
|
31
|
+
font: inherit;
|
|
32
|
+
font-size: var(--mosetta-wb-font-sm);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.block-picker__search:focus {
|
|
36
|
+
border-color: #2563eb;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.block-picker__list,
|
|
40
|
+
.block-picker__leaves {
|
|
41
|
+
list-style: none;
|
|
42
|
+
margin: 0;
|
|
43
|
+
padding: 0;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.block-picker__list {
|
|
47
|
+
min-height: 0;
|
|
48
|
+
overflow-y: auto;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/* One branch per distribution, at the version on offer — and a button, because it folds. A whole
|
|
52
|
+
provider is a lot of rows to scroll past when the block you want is in the next one. Its version
|
|
53
|
+
stands beside the button: a choice, once several are deployed (ADR-0061). */
|
|
54
|
+
.block-picker__branch-head {
|
|
55
|
+
display: flex;
|
|
56
|
+
align-items: center;
|
|
57
|
+
padding-right: 8px;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.block-picker__branch-head:hover {
|
|
61
|
+
background: #f4f4f5;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.block-picker__branch-title {
|
|
65
|
+
display: flex;
|
|
66
|
+
flex: 1;
|
|
67
|
+
min-width: 0;
|
|
68
|
+
align-items: center;
|
|
69
|
+
gap: 4px;
|
|
70
|
+
padding: 3px 10px;
|
|
71
|
+
border: 0;
|
|
72
|
+
background: none;
|
|
73
|
+
color: #3f3f46;
|
|
74
|
+
font: inherit;
|
|
75
|
+
font-weight: 600;
|
|
76
|
+
text-align: left;
|
|
77
|
+
cursor: pointer;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
/* points down when the branch is open, right when it is folded — the fold is the state, the
|
|
82
|
+
chevron only reports it */
|
|
83
|
+
.block-picker__chevron {
|
|
84
|
+
display: flex;
|
|
85
|
+
width: calc(var(--mosetta-wb-icon-sm) - 2px);
|
|
86
|
+
height: calc(var(--mosetta-wb-icon-sm) - 2px);
|
|
87
|
+
align-items: center;
|
|
88
|
+
justify-content: center;
|
|
89
|
+
color: #a1a1aa;
|
|
90
|
+
transform: rotate(90deg);
|
|
91
|
+
transition: transform 120ms ease;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.block-picker__branch[data-collapsed] .block-picker__chevron {
|
|
95
|
+
transform: rotate(0deg);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.block-picker__chevron svg {
|
|
99
|
+
display: block;
|
|
100
|
+
width: 100%;
|
|
101
|
+
height: 100%;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.block-picker__item {
|
|
105
|
+
display: flex;
|
|
106
|
+
align-items: center;
|
|
107
|
+
gap: 8px;
|
|
108
|
+
min-height: 26px;
|
|
109
|
+
padding: 2px 10px 2px 24px;
|
|
110
|
+
cursor: pointer;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/* the ranked list has no branches to indent under */
|
|
114
|
+
.block-picker__list > .block-picker__item {
|
|
115
|
+
padding-left: 10px;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/* the active row — under the pointer, or walked to with the arrows; Enter inserts it. The green
|
|
119
|
+
of the place waiting in the preview: the row answers in that place's colour */
|
|
120
|
+
.block-picker__item--active {
|
|
121
|
+
background: rgb(16 185 129 / 22%);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/* nowhere to insert (a docked palette with no place picked): the rows only show */
|
|
125
|
+
.block-picker__item--inert {
|
|
126
|
+
cursor: default;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
.block-picker__item--inert.block-picker__item--active {
|
|
130
|
+
background: #f4f4f5;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/* a row's picture — the block's icon, before its name: found by shape and colour first */
|
|
134
|
+
.block-picker__icon {
|
|
135
|
+
display: inline-flex;
|
|
136
|
+
flex: none;
|
|
137
|
+
align-items: center;
|
|
138
|
+
justify-content: center;
|
|
139
|
+
width: 18px;
|
|
140
|
+
height: 18px;
|
|
141
|
+
border-radius: 5px;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
.block-picker__icon svg {
|
|
145
|
+
width: 100%;
|
|
146
|
+
height: 100%;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
.block-picker__icon--initial {
|
|
150
|
+
background: #e4e4e7;
|
|
151
|
+
color: #52525b;
|
|
152
|
+
font-size: 10px;
|
|
153
|
+
font-weight: 700;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/* the branch: its distribution's icon and title; its namespace and version, quieter, beside */
|
|
157
|
+
.block-picker__branch-icon {
|
|
158
|
+
flex: none;
|
|
159
|
+
width: 16px;
|
|
160
|
+
height: 16px;
|
|
161
|
+
margin-right: 2px;
|
|
162
|
+
border-radius: 4px;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
.block-picker__branch-id {
|
|
166
|
+
flex: none;
|
|
167
|
+
color: #a1a1aa;
|
|
168
|
+
font-size: var(--mosetta-wb-font-2xs);
|
|
169
|
+
font-weight: 400;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
.block-picker__version {
|
|
173
|
+
flex: none;
|
|
174
|
+
max-width: 120px;
|
|
175
|
+
padding: 0 2px;
|
|
176
|
+
border: 1px solid #e4e4e7;
|
|
177
|
+
border-radius: 4px;
|
|
178
|
+
background: #ffffff;
|
|
179
|
+
color: #52525b;
|
|
180
|
+
font: inherit;
|
|
181
|
+
font-size: var(--mosetta-wb-font-2xs);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
.block-picker__name {
|
|
185
|
+
flex: 1;
|
|
186
|
+
min-width: 0;
|
|
187
|
+
overflow: hidden;
|
|
188
|
+
color: #18181b;
|
|
189
|
+
text-overflow: ellipsis;
|
|
190
|
+
white-space: nowrap;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
.block-picker__name mark {
|
|
194
|
+
padding: 0;
|
|
195
|
+
border-radius: 2px;
|
|
196
|
+
color: inherit;
|
|
197
|
+
background: #fde68a;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/* flat, a row says which provider it comes from — the branch it would have folded under */
|
|
201
|
+
.block-picker__branch-tag {
|
|
202
|
+
flex: none;
|
|
203
|
+
color: #a1a1aa;
|
|
204
|
+
font-size: var(--mosetta-wb-font-2xs);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
.block-picker__note {
|
|
208
|
+
padding: 6px 10px;
|
|
209
|
+
color: #a1a1aa;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
.block-picker__error {
|
|
213
|
+
padding: 6px 10px;
|
|
214
|
+
color: #b00;
|
|
215
|
+
}
|