@volter/editor-ui 0.5.210 → 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
package/NOTICE CHANGED
@@ -2,3 +2,15 @@ Copyright 2026 Volter AI, Inc.
2
2
 
3
3
  This package is developed in the public volter-ai/editor repository.
4
4
  LICENSE contains its AGPL-3.0-only license.
5
+
6
+ The files under src/ were imported from volter-ai/volter-engine at commit
7
+ 09c2749ce, and retain the AGPL-3.0-only license in LICENSE.
8
+
9
+ React authoring was transferred from packages/dom/src and
10
+ packages/dom/contributions. Realm and React tool services retain their
11
+ source licenses from that same revision.
12
+
13
+ Modules moved under src/host/ from @volter/sdk's src/kit/ because only this package
14
+ imports them (docs/adr/sdk-plugin-api.md, decisions 1 and 4) are Volter AI, Inc.'s
15
+ own work and are AGPL-3.0-only here, as editor source. The @volter/sdk releases
16
+ that carried them remain Apache-2.0.
@@ -1,2 +1,10 @@
1
- /** Existing DOM authoring tool, composed without the game transport. */
2
- export { point, start } from '@volter/editor-game/contributions/react/component-board.service.ts';
1
+ /** Register the React UI component board with the editor host. */
2
+
3
+ import { registerComponentBoard } from '@volter/sdk/component-board-registry';
4
+ import { uiComponentBoard } from '../src/react/ui-component-board.ts';
5
+
6
+ export const point = 'workspace.service';
7
+
8
+ export function start(): () => void {
9
+ return registerComponentBoard(uiComponentBoard);
10
+ }
@@ -1,2 +1,39 @@
1
- /** Existing DOM authoring tool, composed without the game transport. */
2
- export { point, start } from '@volter/editor-game/contributions/react/design-time-mount.service.ts';
1
+ /** Register React DOM design-time mounting and story-change invalidation. Runtime modules load only when a DOM surface is present. */
2
+
3
+ import { registerDesignTimeMount } from '@volter/sdk/authoring/design-time-mount-registry';
4
+
5
+ export const point = 'workspace.service';
6
+
7
+ export function start(): () => void {
8
+ return registerDesignTimeMount({
9
+ kind: 'dom',
10
+ owner: '@volter/editor-ui/react/design-time-mount',
11
+ // The `ResolvedAdapter['identity']` a mount failure is reported under —
12
+ // the literal the host reports it under.
13
+ identity: 'default-react',
14
+ // Deliberately absent: a dom layer's design-time hover/press testing of
15
+ // real UI competes with authoring gestures, so it stays behind the `eye`
16
+ // row's `interactive` toggle. The canvas medium declares the opposite.
17
+ mount: async (candidate, layer, context) => {
18
+ const { mountReactDesignLayer } = await import('../src/react/design-time-react-mount.ts');
19
+ return mountReactDesignLayer(candidate, layer, context);
20
+ },
21
+ reprojectWhen: (invalidate) => {
22
+ let stopped = false;
23
+ let stop: (() => void) | null = null;
24
+ // The hook's own module is the mount's, so binding it eagerly would
25
+ // load the board's whole graph for a project that has no dom root. The
26
+ // host only calls this when a dom candidate exists, so loading here is
27
+ // exactly the right moment.
28
+ void import('../src/react/design-time-react-mount.ts').then((module) => {
29
+ if (stopped) return;
30
+ stop = module.reprojectWhenStoriesRepublish(invalidate);
31
+ });
32
+ return () => {
33
+ stopped = true;
34
+ stop?.();
35
+ stop = null;
36
+ };
37
+ },
38
+ });
39
+ }
@@ -1,2 +1,11 @@
1
- /** Existing DOM authoring tool, composed without the game transport. */
2
- export { point, start } from '@volter/editor-game/contributions/react/react-inspector.service.ts';
1
+ /** Register React and DOM layout, appearance and story inspector sections. */
2
+
3
+ import { ensureReactInspectorSectionRegistered } from '../src/react/react-inspector-section.tsx';
4
+
5
+ export const point = 'workspace.service';
6
+
7
+ export function start(): () => void {
8
+ ensureReactInspectorSectionRegistered();
9
+ // The HMR registration group owns the section for the module lifetime.
10
+ return () => {};
11
+ }
@@ -1,2 +1,14 @@
1
- /** Existing DOM authoring tool, composed without the game transport. */
2
- export { point, start } from '@volter/editor-game/contributions/react/story-documents.service.ts';
1
+ /** Register React story documents, argument inspection and story utilities. */
2
+
3
+ import { ensureStoryContributionsRegistered } from '../src/react/story-documents/story-documents.tsx';
4
+
5
+ export const point = 'workspace.service';
6
+
7
+ export function start(): () => void {
8
+ // The module registers its opener and restorer at module scope; this call
9
+ // installs the story-scoped Inspector section and utilities. It is idempotent and returns nothing; the
10
+ // registration group it owns is HMR-safe by construction.
11
+ ensureStoryContributionsRegistered();
12
+ // Registrations are HMR-owned and open documents outlive the contribution pass.
13
+ return () => {};
14
+ }
@@ -0,0 +1,28 @@
1
+ /** editor-ui's page half. It registers the composition render (`story-render-movie`, which starts a React
2
+ * composition rendering frame by frame into a movie, and `story-render-status`, `../src/react/composition-movie.ts`). It
3
+ * activates on its commands (`package.json#contributes.volter.activationEvents`). */
4
+ import type { PluginContext } from '@volter/sdk/plugins';
5
+ import { registerVerbCommands } from '@volter/sdk/commands';
6
+ import { compositionRenders, startCompositionRender } from '../src/react/composition-movie.ts';
7
+ const frameNumber = (args: Record<string, unknown>, key: string): number | undefined => {
8
+ const value = args[key];
9
+ if (value === undefined) return undefined;
10
+ if (typeof value !== 'number' || !Number.isInteger(value)) throw new Error(`${key} is a whole number.`);
11
+ return value;
12
+ };
13
+ export function activate(context: PluginContext): void {
14
+ registerVerbCommands(context, {
15
+ 'story-render-movie': async (args) => {
16
+ const { story, out } = args;
17
+ if (typeof story !== 'string' || story === '') return { ok: false, error: 'story-render-movie needs `story`: a story id, or an export name only one story has.' };
18
+ if (out !== undefined && typeof out !== 'string') return { ok: false, error: '`out` is a project path.' };
19
+ const render = await startCompositionRender({ story, out, from: frameNumber(args, 'from'), to: frameNumber(args, 'to'), width: frameNumber(args, 'width') });
20
+ return { ok: true, data: { ...render } };
21
+ },
22
+ 'story-render-status': async (args) => {
23
+ const { story } = args;
24
+ if (typeof story !== 'string') return { ok: false, error: 'story-render-status needs the `story` id story-render-movie answered.' };
25
+ return { ok: true, data: { ...(await compositionRenders.status(story)) } };
26
+ },
27
+ });
28
+ }
package/package.json CHANGED
@@ -1,10 +1,14 @@
1
1
  {
2
2
  "name": "@volter/editor-ui",
3
- "version": "0.5.210",
3
+ "publisher": "volter",
4
+ "version": "0.5.212",
5
+ "engines": {
6
+ "vscode": "*"
7
+ },
4
8
  "author": "Volter AI, Inc.",
5
9
  "license": "AGPL-3.0-only",
6
10
  "type": "module",
7
- "description": "React DOM UI boards and project layers on playing stages.",
11
+ "description": "React DOM UI authoring for Cyclotron: the React root and realm, component boards, story documents and the React inspector.",
8
12
  "repository": {
9
13
  "type": "git",
10
14
  "url": "git+https://github.com/volter-ai/editor.git",
@@ -20,24 +24,85 @@
20
24
  "./contributions/*": "./contributions/*",
21
25
  "./package.json": "./package.json"
22
26
  },
23
- "volter": {
24
- "contributions": [
25
- "./contributions/component-board.service.ts",
26
- "./contributions/design-time-mount.service.ts",
27
- "./contributions/play-layers.service.ts",
28
- "./contributions/react-inspector.service.ts",
29
- "./contributions/story-documents.service.ts"
30
- ]
27
+ "contributes": {
28
+ "volter": {
29
+ "activationEvents": [
30
+ "onCommand:volter.story-render-movie",
31
+ "onCommand:volter.story-render-status"
32
+ ],
33
+ "page": [
34
+ "./contributions/ui.page.ts"
35
+ ],
36
+ "editors": [
37
+ {
38
+ "kind": "workspace:ui-components"
39
+ },
40
+ {
41
+ "kind": "story",
42
+ "views": [
43
+ "story:isolated",
44
+ "story:docs"
45
+ ]
46
+ }
47
+ ],
48
+ "services": [
49
+ "./contributions/component-board.service.ts",
50
+ {
51
+ "module": "./contributions/design-time-mount.service.ts",
52
+ "designTimeMount": "dom"
53
+ },
54
+ "./contributions/react-inspector.service.ts",
55
+ "./contributions/story-documents.service.ts"
56
+ ]
57
+ },
58
+ "commands": [
59
+ {
60
+ "command": "volter.story-render-movie",
61
+ "title": "story-render-movie",
62
+ "category": "UI Board",
63
+ "volter": {
64
+ "derivedRefresh": "none"
65
+ }
66
+ },
67
+ {
68
+ "command": "volter.story-render-status",
69
+ "title": "story-render-status",
70
+ "category": "UI Board",
71
+ "volter": {
72
+ "derivedRefresh": "none"
73
+ }
74
+ }
75
+ ],
76
+ "menus": {
77
+ "commandPalette": [
78
+ {
79
+ "command": "volter.story-render-movie",
80
+ "when": "false"
81
+ },
82
+ {
83
+ "command": "volter.story-render-status",
84
+ "when": "false"
85
+ }
86
+ ]
87
+ }
31
88
  },
32
89
  "dependencies": {
33
- "@volter/sdk": "0.5.210",
34
- "@volter/editor-game": "0.5.210",
35
- "@volter/project": "0.5.210"
90
+ "@volter/editor-movie": "0.5.212",
91
+ "@volter/sdk": "0.5.212",
92
+ "@volter/project": "0.5.212",
93
+ "@volter/editor-threejs": "0.5.212",
94
+ "@volter/editor-react": "0.5.212",
95
+ "@fortawesome/free-solid-svg-icons": "^7.2.0",
96
+ "axe-core": "^4.12.1",
97
+ "storybook": "10.6.0"
36
98
  },
37
99
  "peerDependencies": {
38
100
  "react": "^19.0.0",
39
101
  "react-dom": "^19.0.0"
40
102
  },
103
+ "scripts": {
104
+ "typecheck": "tsc -p tsconfig.json"
105
+ },
41
106
  "publishConfig": {
42
107
  "access": "public"
43
108
  }
@@ -0,0 +1,102 @@
1
+ /** Shared structural source-write resolution. The backend owns whole-file
2
+ * history; this pipe reports source-structure or its unavailable destination.
3
+ * Callers provide OID resolution, destination text, and the changed callback. */
4
+
5
+ import type { SourceWriteBackend } from '@volter/sdk/ui-source/source-write-backend';
6
+ import { resolvesLiveOnly, runWritePipe, type WriteAck, type WriteResolution } from '@volter/sdk/write-pipe';
7
+
8
+ /** The `writeStruct` options bag, spelled once (the backend's own shape). */
9
+ export type StructOpOptions = Parameters<SourceWriteBackend['writeStruct']>[2];
10
+
11
+ interface StructWritePipeConfig {
12
+ /** Report refusals and no-op writes with the adapter's prefix. */
13
+ report(message: string): void;
14
+ /** The live-only floor's reason when no struct door is bound. */
15
+ readonly noWriterReason: string;
16
+ /** Read the adapter backend when resolving a structural write. */
17
+ backend(): SourceWriteBackend | null | undefined;
18
+ /** Ran after a LANDED structural write, before the ack resolves — the
19
+ * lane's own reconcile/notify choreography. */
20
+ onChanged(): void | Promise<void>;
21
+ }
22
+
23
+ interface StructWritePipe {
24
+ /** A refusal through the same resolution path as structural writes. */
25
+ structRefusal(reason: string): Promise<WriteAck>;
26
+ /** One `writeStruct` op against a resolved oid; `null` oid refuses by name. */
27
+ structOp(
28
+ oid: string | null | undefined,
29
+ subjectLabel: string,
30
+ op: string,
31
+ opts: StructOpOptions,
32
+ destination: string,
33
+ ): Promise<WriteAck>;
34
+ /** Delete OIDs resolved from one source snapshot. */
35
+ deleteMany(oids: readonly string[], destination: string): Promise<WriteAck>;
36
+ }
37
+
38
+ export function createStructWritePipe(config: StructWritePipeConfig): StructWritePipe {
39
+ const pipedStruct = (
40
+ write: () => Promise<boolean>,
41
+ op: string,
42
+ bound: boolean,
43
+ destination: string,
44
+ ): Promise<WriteAck> =>
45
+ runWritePipe({
46
+ resolve: (): WriteResolution =>
47
+ bound
48
+ ? { reaches: 'writer', destination, write }
49
+ : resolvesLiveOnly(config.noWriterReason),
50
+ record: () => undefined,
51
+ report: (reason) => config.report(`"${op}" stays live-only — ${reason}.`),
52
+ });
53
+
54
+ const structRefusal: StructWritePipe['structRefusal'] = (reason) =>
55
+ runWritePipe({
56
+ resolve: () => resolvesLiveOnly(reason),
57
+ record: () => undefined,
58
+ report: config.report,
59
+ });
60
+
61
+ return {
62
+ structRefusal,
63
+ structOp: (oid, subjectLabel, op, opts, destination) => {
64
+ if (!oid) {
65
+ return structRefusal(`"${op}" refused: "${subjectLabel}" has no source stamp.`);
66
+ }
67
+ return pipedStruct(
68
+ async () => {
69
+ const backend = config.backend()!;
70
+ const res = await backend.writeStruct(oid, op, opts);
71
+ if (!res.changed) {
72
+ config.report(
73
+ `struct "${op}" refused/no-op for oid "${oid}": ${res.error ?? 'no change'}`,
74
+ );
75
+ return false;
76
+ }
77
+ await config.onChanged();
78
+ return true;
79
+ },
80
+ op,
81
+ config.backend() != null,
82
+ destination,
83
+ );
84
+ },
85
+ deleteMany: (oids, destination) =>
86
+ pipedStruct(
87
+ async () => {
88
+ const backend = config.backend()!;
89
+ const res = await backend.writeStructMany(oids, 'delete');
90
+ if (!res.changed) {
91
+ config.report(`batch "delete" refused/no-op: ${res.error ?? 'no change'}`);
92
+ return false;
93
+ }
94
+ await config.onChanged();
95
+ return true;
96
+ },
97
+ 'delete (batch)',
98
+ config.backend() != null,
99
+ destination,
100
+ ),
101
+ };
102
+ }
@@ -0,0 +1,83 @@
1
+ import { faDesktop } from '@fortawesome/free-solid-svg-icons';
2
+ import { AnchoredMenu, Button, EditorIcon, MenuItem, Text } from '@volter/sdk/widgets';
3
+ import { useRef, useState } from 'react';
4
+
5
+ export interface Resolution {
6
+ label: string;
7
+ width: number | null;
8
+ height: number | null;
9
+ }
10
+
11
+ const RESOLUTIONS: Resolution[] = [
12
+ { label: 'Fill', width: null, height: null },
13
+ { label: 'iPhone SE — 375×667', width: 375, height: 667 },
14
+ { label: 'iPhone 14 — 390×844', width: 390, height: 844 },
15
+ { label: 'iPhone 14 Pro Max — 430×932', width: 430, height: 932 },
16
+ { label: 'iPad Mini — 768×1024', width: 768, height: 1024 },
17
+ { label: 'iPad Pro 11″ — 834×1194', width: 834, height: 1194 },
18
+ { label: 'Laptop — 1366×768', width: 1366, height: 768 },
19
+ { label: 'Desktop — 1920×1080', width: 1920, height: 1080 },
20
+ ];
21
+
22
+ /** Preview-resolution picker for isolated story documents. */
23
+ export function ResolutionPicker({
24
+ resolution,
25
+ onResolutionChange,
26
+ scale,
27
+ }: {
28
+ resolution: Resolution;
29
+ onResolutionChange: (resolution: Resolution) => void;
30
+ scale: number;
31
+ }) {
32
+ const [open, setOpen] = useState(false);
33
+ const ref = useRef<HTMLButtonElement>(null);
34
+
35
+ const isFill = resolution.width === null;
36
+ const label = isFill ? 'Fill' : `${resolution.width}×${resolution.height}`;
37
+
38
+ return (
39
+ <div className="volter-playbar-popover-anchor">
40
+ <Button
41
+ ref={ref}
42
+ variant="ghost"
43
+ size="comfortable"
44
+ aria-haspopup="menu"
45
+ aria-expanded={open}
46
+ aria-controls={open ? 'volter-resolution-picker-menu' : undefined}
47
+ onClick={() => setOpen((value) => !value)}
48
+ title="Change preview resolution"
49
+ >
50
+ <EditorIcon icon={faDesktop} size="md" />
51
+ <Text variant="code">{label}</Text>
52
+ {!isFill && scale < 1 && (
53
+ <Text variant="caption" tone="dim">
54
+ {Math.round(scale * 100)}%
55
+ </Text>
56
+ )}
57
+ </Button>
58
+ {open && (
59
+ <AnchoredMenu
60
+ id="volter-resolution-picker-menu"
61
+ anchorRef={ref}
62
+ className="volter-resolution-menu"
63
+ gap={4}
64
+ onDismiss={() => setOpen(false)}
65
+ >
66
+ {RESOLUTIONS.map((candidate) => (
67
+ <MenuItem
68
+ key={candidate.label}
69
+ role="menuitemradio"
70
+ aria-checked={candidate.label === resolution.label}
71
+ onSelect={() => {
72
+ onResolutionChange(candidate);
73
+ setOpen(false);
74
+ }}
75
+ >
76
+ {candidate.label}
77
+ </MenuItem>
78
+ ))}
79
+ </AnchoredMenu>
80
+ )}
81
+ </div>
82
+ );
83
+ }
@@ -0,0 +1,24 @@
1
+ /** Report render and lifecycle failures, then unmount the failed subtree.
2
+ * Sibling mounts keep running. React's error-boundary API requires a class. */
3
+
4
+ import { Component, type ReactNode } from 'react';
5
+
6
+ export class CrashNullBoundary extends Component<
7
+ {
8
+ onCaught: (error: unknown) => void;
9
+ /** Optional because `createElement` callers pass children VARIADICALLY. */
10
+ children?: ReactNode;
11
+ },
12
+ { crashed: boolean }
13
+ > {
14
+ override state = { crashed: false };
15
+ static getDerivedStateFromError(): { crashed: boolean } {
16
+ return { crashed: true };
17
+ }
18
+ override componentDidCatch(error: unknown): void {
19
+ this.props.onCaught(error);
20
+ }
21
+ override render(): ReactNode {
22
+ return this.state.crashed ? null : this.props.children;
23
+ }
24
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * THE UI BOARD'S STORY MEMORY, and the rootless board's descriptor: editor-ui's own state, which its
3
+ * board mount writes and its board document reads.
4
+ */
5
+ import type { DesignTimeRootDescriptor } from '@volter/sdk/authoring/design-time-mount-registry';
6
+
7
+ /** Play tears the design-time adapter down, so the selected CSF state
8
+ * must live one level above that adapter to survive Stop's rebuild. Keyed by
9
+ * project + root so equal root ids in different projects never bleed. */
10
+ const selectedPortableStoryByRoot = new Map<string, string>();
11
+
12
+ function portableStorySelectionKey(projectRoot: string, rootId: string): string {
13
+ return `${projectRoot}\0${rootId}`;
14
+ }
15
+
16
+ /** The story a board ROOT last applied (undefined before any selection) —
17
+ * the view-address door reads this so a presented workspace document can
18
+ * say which frame it is on (`EditorViewDocument`'s workspace `story`). */
19
+ export function rememberedPortableStory(projectRoot: string, rootId: string): string | undefined {
20
+ return selectedPortableStoryByRoot.get(portableStorySelectionKey(projectRoot, rootId));
21
+ }
22
+
23
+ /** The write half, for the registered mount that owns the board: it applies
24
+ * the story, so it is what knows which frame the root is on. */
25
+ export function rememberPortableStory(projectRoot: string, rootId: string, storyId: string): void {
26
+ selectedPortableStoryByRoot.set(portableStorySelectionKey(projectRoot, rootId), storyId);
27
+ }
28
+
29
+ /** Rootless UI-board mount ID. It keys session presentation and is a valid
30
+ * project-relative history display name, without a URI-scheme colon. */
31
+ const PROJECT_STORY_BOARD_ROOT_ID = 'ui-components-board';
32
+
33
+ /** The rootless UI board's descriptor — no `path`, because there is no root
34
+ * entry to derive a default story from; the board opens on its first story
35
+ * the same way it does for a root whose entry names no story. */
36
+ export function projectStoryBoardDescriptor(): DesignTimeRootDescriptor {
37
+ return {
38
+ worldId: PROJECT_STORY_BOARD_ROOT_ID,
39
+ kind: 'dom',
40
+ path: undefined,
41
+ zOrder: 0,
42
+ pausable: true,
43
+ };
44
+ }
@@ -0,0 +1,40 @@
1
+ /** Project mounted data-oid elements for hierarchy, selection and picking.
2
+ * Unstamped wrappers are transparent; SVG elements own their whole subtree. */
3
+
4
+ import type { ProjectedNode } from '@volter/sdk/projection-types';
5
+
6
+ interface OidWalkable {
7
+ readonly tagName: string;
8
+ readonly children: ArrayLike<OidWalkable>;
9
+ getAttribute(name: string): string | null;
10
+ }
11
+
12
+ /** The host root is excluded. The first occurrence keeps its source OID;
13
+ * subsequent occurrences receive #n suffixes in depth-first DOM order. */
14
+ export function projectOidDom<TEl extends OidWalkable>(root: TEl): {
15
+ nodes: Map<string, ProjectedNode<TEl>>;
16
+ rootIds: string[];
17
+ } {
18
+ const nodes = new Map<string, ProjectedNode<TEl>>();
19
+ const rootIds: string[] = [];
20
+ const occurrence = new Map<string, number>();
21
+
22
+ const visitChildren = (parent: TEl, ownerId: string | null): void => {
23
+ for (const el of Array.from(parent.children) as TEl[]) {
24
+ const oid = el.getAttribute('data-oid');
25
+ let id: string | null = null;
26
+ if (oid) {
27
+ const n = occurrence.get(oid) ?? 0;
28
+ occurrence.set(oid, n + 1);
29
+ id = n === 0 ? oid : `${oid}#${n}`;
30
+ nodes.set(id, { object: el, id, parentId: ownerId, childIds: [] });
31
+ if (ownerId === null) rootIds.push(id);
32
+ else nodes.get(ownerId)?.childIds.push(id);
33
+ }
34
+ if (el.tagName.toLowerCase() !== 'svg') visitChildren(el, id ?? ownerId);
35
+ }
36
+ };
37
+
38
+ visitChildren(root, null);
39
+ return { nodes, rootIds };
40
+ }
@@ -0,0 +1,76 @@
1
+ /** Resolve the React and React DOM instance used by the project: loaded from the project's own graph, so component hooks and createRoot share one dispatcher. */
2
+
3
+ import type {
4
+ createElement,
5
+ useEffect as reactUseEffect,
6
+ useRef as reactUseRef,
7
+ } from 'react';
8
+ import type { flushSync } from 'react-dom';
9
+ import type { createRoot } from 'react-dom/client';
10
+ import { REACT_WORLD_RUNTIME_PATH } from '@volter/sdk/host';
11
+
12
+ /**
13
+ * The pieces of "react itself" a react-world mount needs: `createElement`/`createRoot`
14
+ * to build and render `<Entry />`, and the hooks the editor's own wrappers use.
15
+ */
16
+ export interface ReactRootMountRuntime {
17
+ createElement: typeof createElement;
18
+ createRoot: typeof createRoot;
19
+ useEffect: typeof reactUseEffect;
20
+ useRef: typeof reactUseRef;
21
+ /** `react-dom`'s synchronous-flush escape hatch — the design-time react
22
+ * layer mount needs it from the SAME react-dom peer as `createRoot` above
23
+ * (both are entry points of the same installed package). */
24
+ flushSync: typeof flushSync;
25
+ }
26
+
27
+ /** Load and validate the project-rooted Vite runtime doorway. Its React hooks,
28
+ * createRoot and flushSync must come from the project's own module graph. */
29
+ async function loadPackagedReactRootMountRuntime(): Promise<ReactRootMountRuntime> {
30
+ const mod = (await import(/* @vite-ignore */ REACT_WORLD_RUNTIME_PATH)) as {
31
+ createElement?: unknown;
32
+ createRoot?: unknown;
33
+ useEffect?: unknown;
34
+ useRef?: unknown;
35
+ flushSync?: unknown;
36
+ };
37
+ if (
38
+ typeof mod.createElement !== 'function' ||
39
+ typeof mod.createRoot !== 'function' ||
40
+ typeof mod.useEffect !== 'function' ||
41
+ typeof mod.useRef !== 'function' ||
42
+ typeof mod.flushSync !== 'function'
43
+ ) {
44
+ throw new Error(
45
+ "The packaged runtime's synthetic react-world-runtime module " +
46
+ `("${REACT_WORLD_RUNTIME_PATH}") did not export createElement/createRoot/useEffect/useRef/flushSync ` +
47
+ 'as functions — see vite-plugin-module-doorways.ts.',
48
+ );
49
+ }
50
+ return {
51
+ createElement: mod.createElement as typeof createElement,
52
+ createRoot: mod.createRoot as typeof createRoot,
53
+ useEffect: mod.useEffect as typeof reactUseEffect,
54
+ useRef: mod.useRef as typeof reactUseRef,
55
+ flushSync: mod.flushSync as typeof flushSync,
56
+ };
57
+ }
58
+
59
+ let cachedPackagedReactRootMountRuntime: Promise<ReactRootMountRuntime> | null = null;
60
+
61
+ /**
62
+ * Resolve the react-world mount's own react. A react world is an ISOLATED
63
+ * `createRoot` (its own DOM subtree / react tree) that does NOT need to share
64
+ * react with the editor's own UI — it only needs to be internally consistent
65
+ * (the editor's wrapper and the project's entry component sharing ONE react
66
+ * instance), which loading it from the project's own graph gives.
67
+ *
68
+ * Memoized: this performs at most one dynamic import per editor session however
69
+ * many react roots mount.
70
+ */
71
+ export async function resolveReactRootMountRuntime(): Promise<ReactRootMountRuntime> {
72
+ if (!cachedPackagedReactRootMountRuntime) {
73
+ cachedPackagedReactRootMountRuntime = loadPackagedReactRootMountRuntime();
74
+ }
75
+ return cachedPackagedReactRootMountRuntime;
76
+ }