@volter/editor-ui 0.5.211 → 0.5.212

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/NOTICE +12 -0
  2. package/contributions/component-board.service.ts +10 -2
  3. package/contributions/design-time-mount.service.ts +39 -2
  4. package/contributions/react-inspector.service.ts +11 -2
  5. package/contributions/story-documents.service.ts +14 -2
  6. package/contributions/ui.page.ts +28 -0
  7. package/package.json +78 -13
  8. package/src/host/authoring/struct-write-pipe.ts +102 -0
  9. package/src/host/components/ResolutionPicker.tsx +83 -0
  10. package/src/host/crash-null-boundary.ts +24 -0
  11. package/src/host/portable-stories.ts +44 -0
  12. package/src/host/projection/dom.ts +40 -0
  13. package/src/host/react-mount-runtime.ts +76 -0
  14. package/src/host/realm-services.ts +53 -0
  15. package/src/host/roots/react-root.ts +82 -0
  16. package/src/host/stories/story-arg-descriptors.ts +50 -0
  17. package/src/host/stories/story-media-presence.ts +89 -0
  18. package/src/host/stories/story-presentation.ts +203 -0
  19. package/src/host/surface-content.ts +44 -0
  20. package/src/react/composition-movie.ts +247 -0
  21. package/src/react/design-time-react-mount.ts +525 -0
  22. package/src/react/react-inspector-section.tsx +2247 -0
  23. package/src/react/react-world-authoring-adapter.ts +2602 -0
  24. package/src/react/story-documents/story-args-section.ts +9 -0
  25. package/src/react/story-documents/story-documents.tsx +1342 -0
  26. package/src/react/story-paint-bounds.ts +94 -0
  27. package/src/react/ui-board-document.tsx +55 -0
  28. package/src/react/ui-board-title.ts +9 -0
  29. package/src/react/ui-component-board.ts +52 -0
  30. package/src/runtime/presentation.ts +60 -0
  31. package/contributions/play-layers.service.ts +0 -31
@@ -0,0 +1,53 @@
1
+ /** Project module loading and React mounting, bound to one project and mount epoch. */
2
+
3
+ import {
4
+ type ProjectMountEpoch,
5
+ projectEntryImportUrl,
6
+ } from '@volter/sdk/project-module-url';
7
+ import { type ReactRootMountRuntime, resolveReactRootMountRuntime } from './react-mount-runtime';
8
+
9
+ /**
10
+ * The realm's services, bound to ONE project root and ONE mount epoch — a
11
+ * realm value is "how THIS project's modules are reached in THIS mount
12
+ * generation", which is why it is constructed per resolve rather than being a
13
+ * module-level singleton.
14
+ */
15
+ export interface RealmServices {
16
+ /**
17
+ * Import one root's entry module.
18
+ *
19
+ * The entry path is resolved within this realm's project and mount epoch.
20
+ */
21
+ loadEntryModule(
22
+ entry: string,
23
+ ): Promise<Record<string, unknown>>;
24
+ /** The react-world mount's own react/react-dom. */
25
+ readonly reactDomRuntime: () => Promise<ReactRootMountRuntime>;
26
+ }
27
+
28
+ const SHARED_RUNTIMES = {
29
+ reactDomRuntime: resolveReactRootMountRuntime,
30
+ } as const;
31
+
32
+ /** Import project entries through /@fs/ with this mount epoch's cache key. */
33
+ function fsLoader(projectRoot: string, epoch: ProjectMountEpoch) {
34
+ return async (
35
+ entry: string,
36
+ ): Promise<Record<string, unknown>> =>
37
+ (await import(
38
+ /* @vite-ignore */ projectEntryImportUrl(projectRoot, entry, epoch)
39
+ )) as Record<string, unknown>;
40
+ }
41
+
42
+ /**
43
+ * Construct this session's realm for one project and one mount epoch.
44
+ */
45
+ export async function activeRealmServices(
46
+ projectRoot: string,
47
+ epoch: ProjectMountEpoch,
48
+ ): Promise<RealmServices> {
49
+ return {
50
+ loadEntryModule: fsLoader(projectRoot, epoch),
51
+ ...SHARED_RUNTIMES,
52
+ };
53
+ }
@@ -0,0 +1,82 @@
1
+ /** Mount a project's React DOM root with the project's own React instance. */
2
+
3
+ import type { MountedReactRoot, RootAdapter } from '@volter/project/adapter';
4
+ import type { DomHostContext } from '@volter/project/adapter/host-context';
5
+ import type { ResolvedAdapterRoot } from '@volter/project/manifest/load';
6
+ import type { ComponentType } from 'react';
7
+ import type { RealmServices } from '../realm-services';
8
+
9
+ import { markMountCssScope } from '@volter/sdk/mount-css-scope';
10
+
11
+ type ReactEntryDeclaration = Pick<ResolvedAdapterRoot, 'id'> & { entry: string };
12
+
13
+ /**
14
+ * The shape a react world's `entry` module must satisfy: default-export a
15
+ * React component. Validated loudly at import time — a project whose entry
16
+ * exports the wrong shape gets a named error naming the world and the export
17
+ * it actually found, not a cryptic "X is not a function" deep inside
18
+ * `createRoot`/`render`.
19
+ */
20
+ function loadReactEntryComponent(
21
+ world: ReactEntryDeclaration,
22
+ mod: { default?: unknown },
23
+ ): ComponentType {
24
+ if (typeof mod.default !== 'function') {
25
+ throw new Error(
26
+ `resolveReactAdapterRootComponent: world "${world.id}" (dom) entry module does not ` +
27
+ `default-export a component — got ${typeof mod.default}. A react world's \`entry\` must ` +
28
+ 'default-export a React component.',
29
+ );
30
+ }
31
+ return mod.default as ComponentType;
32
+ }
33
+
34
+ /** Load and validate the React component shared by the UI board and Play. */
35
+ export async function resolveReactAdapterRootComponent(
36
+ world: ReactEntryDeclaration,
37
+ realm: RealmServices,
38
+ ): Promise<ComponentType> {
39
+ const mod = await realm.loadEntryModule(world.entry);
40
+ return loadReactEntryComponent(world, mod);
41
+ }
42
+
43
+ /** Mount into the host-owned DOM layer. */
44
+ function reactMount(
45
+ world: ResolvedAdapterRoot,
46
+ Entry: ComponentType,
47
+ realm: RealmServices,
48
+ ): RootAdapter<'dom'> {
49
+ return {
50
+ id: world.id,
51
+ async mount(host: DomHostContext): Promise<MountedReactRoot> {
52
+ // The host owns this DOM surface, so its served project CSS must have
53
+ // a scope root even when the entry component has no special wrapper.
54
+ markMountCssScope(host.container);
55
+ // Entry hooks and mounting share the project's React runtime.
56
+ const runtime = await realm.reactDomRuntime();
57
+ const root = runtime.createRoot(host.container);
58
+ const render = () => {
59
+ root.render(runtime.createElement(Entry));
60
+ };
61
+ // The authoring adapter reads the committed OID tree as soon as mount resolves.
62
+ runtime.flushSync(render);
63
+ return {
64
+ kind: 'dom',
65
+ container: host.container,
66
+ drivesOwnLoop: false,
67
+ dispose(): void {
68
+ root.unmount();
69
+ },
70
+ };
71
+ },
72
+ };
73
+ }
74
+
75
+ /** A first-party `dom` root. */
76
+ export async function resolveDomAdapter(
77
+ world: ResolvedAdapterRoot & { entry: string },
78
+ realm: RealmServices,
79
+ ): Promise<RootAdapter<'dom'>> {
80
+ const Entry = await resolveReactAdapterRootComponent(world, realm);
81
+ return reactMount(world, Entry, realm);
82
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * ONE rule for "which portable-story args are editable, and as what" —
3
+ * extracted verbatim from `authoring/react-world-authoring-adapter.ts`'s
4
+ * private `portableStoryArgDescriptors` (the Storybook-Controls semantic it has
5
+ * always applied to a story node in the React hierarchy), so the three-story
6
+ * board's per-story document describes its args the SAME way instead of
7
+ * inventing a second answer.
8
+ *
9
+ * Storybook's own Controls semantic: a story's arg is editable when it carries
10
+ * a primitive value the author can meaningfully type; anything else (a
11
+ * callback, a symbol, an unset arg) stays out of the visual form while
12
+ * remaining present in the complete args object the composed story is rendered
13
+ * with. Non-primitive OBJECT/array values are surfaced as `json`, which is what
14
+ * the generic inspector's JSON widget is for.
15
+ */
16
+
17
+ import type { PropertyDescriptor } from '@volter/project/adapter';
18
+
19
+ /**
20
+ * Descriptors for one composed story's args, under `pathPrefix`.
21
+ *
22
+ * `pathPrefix` is the caller's, because the path namespace belongs to whoever
23
+ * owns the inspector node the descriptors are read back through — the React
24
+ * world adapter keys its story nodes under `story.arg.`, and the three-story
25
+ * document uses its own.
26
+ */
27
+ export function storyArgPropertyDescriptors(
28
+ args: Record<string, unknown>,
29
+ pathPrefix: string,
30
+ ): PropertyDescriptor[] {
31
+ return Object.entries(args).flatMap(([name, value]) => {
32
+ if (typeof value === 'function' || typeof value === 'symbol' || value === undefined) return [];
33
+ const type: PropertyDescriptor['type'] =
34
+ typeof value === 'boolean'
35
+ ? 'boolean'
36
+ : typeof value === 'number'
37
+ ? 'number'
38
+ : typeof value === 'string'
39
+ ? 'string'
40
+ : 'json';
41
+ return [
42
+ {
43
+ path: `${pathPrefix}${name}`,
44
+ label: name,
45
+ type,
46
+ group: 'Args',
47
+ },
48
+ ];
49
+ });
50
+ }
@@ -0,0 +1,89 @@
1
+ /** Board presence follows declared story media, independent of runtime roots.
2
+ * An empty registry clears every medium. */
3
+
4
+ import { editorHost } from '@volter/sdk/host';
5
+ import { declaredStoryMedium, type StoryMedium } from '@volter/sdk/stories/story-declared-medium';
6
+
7
+ /** Which component boards the project's stories call for. */
8
+ export interface StoryMediaPresence {
9
+ /** ≥1 story declared as `stage` — the 3D board's existence. */
10
+ readonly stage: boolean;
11
+ /** ≥1 story declared as `canvas` — the 2D board's existence. */
12
+ readonly canvas: boolean;
13
+ /** ≥1 story declared as `dom` — the UI board's existence. */
14
+ readonly dom: boolean;
15
+ }
16
+
17
+ const NONE: StoryMediaPresence = { stage: false, canvas: false, dom: false };
18
+
19
+ let _presence: StoryMediaPresence = NONE;
20
+ const _listeners = new Set<() => void>();
21
+ let _subscribed = false;
22
+
23
+ /** The current verdict (stable reference between changes). */
24
+ export function getStoryMediaPresence(): StoryMediaPresence {
25
+ ensureSubscribed();
26
+ return _presence;
27
+ }
28
+
29
+ /** Subscribe to verdict changes. Returns an unsubscribe function. */
30
+ export function subscribeStoryMediaPresence(listener: () => void): () => void {
31
+ ensureSubscribed();
32
+ _listeners.add(listener);
33
+ return () => {
34
+ _listeners.delete(listener);
35
+ };
36
+ }
37
+
38
+ function ensureSubscribed(): void {
39
+ if (_subscribed) return;
40
+ _subscribed = true;
41
+ editorHost().stories.subscribe(() => {
42
+ recomputeStoryMediaPresence();
43
+ });
44
+ recomputeStoryMediaPresence();
45
+ }
46
+
47
+ function publish(next: StoryMediaPresence): void {
48
+ if (
49
+ next.stage === _presence.stage &&
50
+ next.canvas === _presence.canvas &&
51
+ next.dom === _presence.dom
52
+ )
53
+ return;
54
+ _presence = next;
55
+ for (const listener of _listeners) listener();
56
+ }
57
+
58
+ function classifyDeclared(story: { readonly modulePath: string }): StoryMedium | undefined {
59
+ const declared = declaredStoryMedium({
60
+ modulePath: story.modulePath,
61
+ regions: editorHost().stories.regions(),
62
+ });
63
+ if (declared.medium) return declared.medium;
64
+ editorHost().stories.reportUndeclaredMedium(story.modulePath, declared.reason);
65
+ return undefined;
66
+ }
67
+
68
+ /** Publish media declared by the current registry. */
69
+ function recomputeStoryMediaPresence(): void {
70
+ const stories = editorHost().stories.modules().flatMap((module_) =>
71
+ module_.ok
72
+ ? module_.stories.map((story) => ({ ...story, modulePath: module_.modulePath }))
73
+ : [],
74
+ );
75
+
76
+ // An empty registry removes every component board.
77
+ if (stories.length === 0) {
78
+ publish(NONE);
79
+ return;
80
+ }
81
+
82
+ const found: Record<StoryMedium, boolean> = { stage: false, canvas: false, dom: false };
83
+ for (const story of stories) {
84
+ const medium = classifyDeclared(story);
85
+ if (medium) found[medium] = true;
86
+ }
87
+ const { stage, canvas, dom } = found;
88
+ publish({ stage, canvas, dom });
89
+ }
@@ -0,0 +1,203 @@
1
+ /**
2
+ * PORTABLE CSF'S ANSWER to the board substrate's presentation contract
3
+ * (`@volter/sdk/authoring/story-board-presentation`): Storybook's slash
4
+ * title/path model organizes the frames, and Storybook's viewport parameters
5
+ * size them.
6
+ *
7
+ * The TYPES are the host's — `react-story-board.ts` is host chrome's
8
+ * substrate and declares what it needs, the same way `design-time-mount-registry.ts`
9
+ * declares `LayerMountResult` for the mounts that fill it. This module
10
+ * IMPLEMENTS them and re-exports them so a reader following a story does not
11
+ * have to know which side owns which half.
12
+ */
13
+
14
+ import { MINIMAL_VIEWPORTS } from 'storybook/viewport';
15
+ import type {
16
+ ResolvedStoryPresentation,
17
+ StoryBoardPresentation,
18
+ StoryLayout,
19
+ StoryPresentationIndex,
20
+ StoryPresentationInput,
21
+ StoryPresentationTreeNode,
22
+ } from '@volter/sdk/authoring/story-board-presentation';
23
+ import { deriveStoryGroupPath, formatStoryGroupPath } from '@volter/sdk/stories/story-grouping';
24
+
25
+ export type {
26
+ StoryBoardPresentation,
27
+ StoryPresentationIndex,
28
+ StoryPresentationInput,
29
+ } from '@volter/sdk/authoring/story-board-presentation';
30
+
31
+ /** The name and size read from a story's `parameters.viewport.options`.
32
+ * Internal: the board sees the resolved `StoryViewportChoice`, never this. */
33
+ interface StoryViewportOption {
34
+ readonly name: string;
35
+ readonly styles: { readonly width: string; readonly height: string };
36
+ }
37
+
38
+ function stableNodeId(role: 'folder' | 'component', path: readonly string[]): string {
39
+ return `portable-${role}:${path.map((part) => encodeURIComponent(part)).join('/')}`;
40
+ }
41
+
42
+ /**
43
+ * Build the one Storybook navigation index used by both the hierarchy and
44
+ * the Figma-style board. Storybook's slash title/path model remains the
45
+ * organization source: first segment is the category, middle segments are
46
+ * folders, the final segment is the component, and named exports are stories.
47
+ */
48
+ export function createStoryPresentationIndex(
49
+ stories: readonly StoryPresentationInput[],
50
+ ): StoryPresentationIndex {
51
+ const mutableNodes = new Map<string, StoryPresentationTreeNode & { childIds: string[] }>();
52
+ const rootIds: string[] = [];
53
+ const storyParentIds = new Map<string, string>();
54
+ const sections = new Map<string, { id: string; label: string; storyIds: string[] }>();
55
+
56
+ for (const story of stories) {
57
+ const group = deriveStoryGroupPath({
58
+ modulePath: story.modulePath,
59
+ ...(story.title ? { title: story.title } : {}),
60
+ });
61
+ const fullPath = [...group.segments, group.leaf];
62
+ let parentId: string | null = null;
63
+ for (let index = 0; index < fullPath.length; index++) {
64
+ const path = fullPath.slice(0, index + 1);
65
+ const component = index === fullPath.length - 1;
66
+ const id = stableNodeId(component ? 'component' : 'folder', path);
67
+ if (!mutableNodes.has(id)) {
68
+ mutableNodes.set(id, {
69
+ id,
70
+ label: path.at(-1)!,
71
+ role: component ? 'component' : 'folder',
72
+ kind: component ? 'component' : 'group',
73
+ typeLabel: component ? 'Component' : index === 0 ? 'Category' : 'Folder',
74
+ parentId,
75
+ childIds: [],
76
+ ...(component ? { secondaryLabel: story.modulePath } : {}),
77
+ });
78
+ if (parentId) mutableNodes.get(parentId)?.childIds.push(id);
79
+ else rootIds.push(id);
80
+ }
81
+ parentId = id;
82
+ }
83
+
84
+ const componentId = parentId!;
85
+ mutableNodes.get(componentId)?.childIds.push(story.id);
86
+ storyParentIds.set(story.id, componentId);
87
+ const section = sections.get(componentId);
88
+ if (section) section.storyIds.push(story.id);
89
+ else {
90
+ sections.set(componentId, {
91
+ id: componentId,
92
+ label: formatStoryGroupPath(group),
93
+ storyIds: [story.id],
94
+ });
95
+ }
96
+ }
97
+
98
+ return {
99
+ sections: [...sections.values()],
100
+ nodes: [...mutableNodes.values()],
101
+ rootIds,
102
+ storyParentIds,
103
+ };
104
+ }
105
+
106
+ function object(value: unknown): Record<string, unknown> | null {
107
+ return value && typeof value === 'object' ? (value as Record<string, unknown>) : null;
108
+ }
109
+
110
+ function pixels(value: unknown): number | null {
111
+ if (typeof value !== 'string') return null;
112
+ const parsed = Number.parseFloat(value);
113
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
114
+ }
115
+
116
+ function viewportOptions(
117
+ parameters: Readonly<Record<string, unknown>>,
118
+ ): Record<string, StoryViewportOption> {
119
+ const custom = object(object(parameters['viewport'])?.['options']);
120
+ const options: Record<string, StoryViewportOption> = { ...MINIMAL_VIEWPORTS };
121
+ if (!custom) return options;
122
+ for (const [id, raw] of Object.entries(custom)) {
123
+ const option = object(raw);
124
+ const styles = object(option?.['styles']);
125
+ if (
126
+ !option ||
127
+ !styles ||
128
+ typeof option['name'] !== 'string' ||
129
+ typeof styles['width'] !== 'string' ||
130
+ typeof styles['height'] !== 'string'
131
+ ) {
132
+ continue;
133
+ }
134
+ options[id] = {
135
+ name: option['name'],
136
+ styles: { width: styles['width'], height: styles['height'] },
137
+ };
138
+ }
139
+ return options;
140
+ }
141
+
142
+ function viewportGlobal(globals: Readonly<Record<string, unknown>>): {
143
+ value: string | null;
144
+ isRotated: boolean;
145
+ } {
146
+ const viewport = globals['viewport'];
147
+ if (typeof viewport === 'string') return { value: viewport || null, isRotated: false };
148
+ const value = object(viewport);
149
+ return {
150
+ value: typeof value?.['value'] === 'string' && value['value'] ? value['value'] : null,
151
+ isRotated: value?.['isRotated'] === true,
152
+ };
153
+ }
154
+
155
+ /** Resolve only Storybook-native layout/viewport values into an artboard. */
156
+ function resolveStoryPresentation(
157
+ story: StoryPresentationInput,
158
+ fallback: { readonly width: number; readonly height: number },
159
+ sessionViewportId?: string | null,
160
+ sessionRotated?: boolean,
161
+ ): ResolvedStoryPresentation {
162
+ const parameters = story.parameters ?? {};
163
+ const globals = story.globals ?? {};
164
+ const authoredLayout = parameters['layout'];
165
+ const layout: StoryLayout =
166
+ authoredLayout === 'centered' || authoredLayout === 'fullscreen' || authoredLayout === 'padded'
167
+ ? authoredLayout
168
+ : 'padded';
169
+ const options = viewportOptions(parameters);
170
+ const choices = Object.entries(options).flatMap(([id, option]) => {
171
+ const width = pixels(option.styles.width);
172
+ const height = pixels(option.styles.height);
173
+ return width && height ? [{ id, label: option.name, width, height }] : [];
174
+ });
175
+ const authoredViewport = viewportGlobal(globals);
176
+ const selectedViewportId =
177
+ sessionViewportId === undefined ? authoredViewport.value : sessionViewportId;
178
+ const choice = choices.find((candidate) => candidate.id === selectedViewportId);
179
+ const baseWidth = choice?.width ?? fallback.width;
180
+ const baseHeight = choice?.height ?? fallback.height;
181
+ const rotated = sessionRotated ?? authoredViewport.isRotated;
182
+ return {
183
+ layout,
184
+ choices,
185
+ selectedViewportId: choice?.id ?? null,
186
+ locked: story.viewportLocked === true,
187
+ rotated,
188
+ width: rotated ? baseHeight : baseWidth,
189
+ height: rotated ? baseWidth : baseHeight,
190
+ };
191
+ }
192
+
193
+ /**
194
+ * The board substrate's {@link StoryBoardPresentation} for one set of stories
195
+ * — what a caller hands `createReactStoryBoard` instead of letting it reach
196
+ * for this module. `index` is built once; `resolve` runs inside the board's
197
+ * layout pass, per story and per viewport override.
198
+ */
199
+ export function storyBoardPresentation(
200
+ index: StoryPresentationIndex,
201
+ ): StoryBoardPresentation {
202
+ return { index, resolve: resolveStoryPresentation };
203
+ }
@@ -0,0 +1,44 @@
1
+ const RENDERABLE_DOM_TAGS = new Set([
2
+ 'canvas',
3
+ 'svg',
4
+ 'img',
5
+ 'picture',
6
+ 'video',
7
+ 'input',
8
+ 'button',
9
+ ]);
10
+ const NON_RENDERING_DOM_TAGS = new Set(['script', 'style', 'template']);
11
+
12
+ function elementIsHidden(element: Element): boolean {
13
+ if (element.hasAttribute('hidden')) return true;
14
+ if (typeof getComputedStyle !== 'function') return false;
15
+ const style = getComputedStyle(element);
16
+ return style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0';
17
+ }
18
+
19
+ function styledBoxRenders(element: Element): boolean {
20
+ if (typeof getComputedStyle !== 'function') return false;
21
+ const style = getComputedStyle(element);
22
+ const rect = element.getBoundingClientRect();
23
+ if (!(rect.width > 0 && rect.height > 0)) return false;
24
+ const hasBackground =
25
+ style.backgroundImage !== 'none' ||
26
+ (style.backgroundColor !== 'transparent' && style.backgroundColor !== 'rgba(0, 0, 0, 0)');
27
+ return hasBackground || style.borderTopStyle !== 'none' || style.outlineStyle !== 'none';
28
+ }
29
+
30
+ function domNodeRenders(node: Node): boolean {
31
+ if (node.nodeType === Node.TEXT_NODE) return Boolean(node.textContent?.trim());
32
+ if (!(node instanceof Element)) return false;
33
+ const tag = node.tagName.toLowerCase();
34
+ if (NON_RENDERING_DOM_TAGS.has(tag) || elementIsHidden(node)) return false;
35
+ if (RENDERABLE_DOM_TAGS.has(tag)) return true;
36
+ for (const child of node.childNodes) if (domNodeRenders(child)) return true;
37
+ return styledBoxRenders(node);
38
+ }
39
+
40
+ /** Structural DOM content test used after React has committed into a root. */
41
+ export function domHasRenderableContent(root: HTMLElement): boolean {
42
+ for (const child of root.childNodes) if (domNodeRenders(child)) return true;
43
+ return false;
44
+ }