@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.
- package/NOTICE +12 -0
- package/contributions/component-board.service.ts +10 -2
- package/contributions/design-time-mount.service.ts +39 -2
- package/contributions/react-inspector.service.ts +11 -2
- package/contributions/story-documents.service.ts +14 -2
- package/contributions/ui.page.ts +28 -0
- package/package.json +78 -13
- package/src/host/authoring/struct-write-pipe.ts +102 -0
- package/src/host/components/ResolutionPicker.tsx +83 -0
- package/src/host/crash-null-boundary.ts +24 -0
- package/src/host/portable-stories.ts +44 -0
- package/src/host/projection/dom.ts +40 -0
- package/src/host/react-mount-runtime.ts +76 -0
- package/src/host/realm-services.ts +53 -0
- package/src/host/roots/react-root.ts +82 -0
- package/src/host/stories/story-arg-descriptors.ts +50 -0
- package/src/host/stories/story-media-presence.ts +89 -0
- package/src/host/stories/story-presentation.ts +203 -0
- package/src/host/surface-content.ts +44 -0
- package/src/react/composition-movie.ts +247 -0
- package/src/react/design-time-react-mount.ts +525 -0
- package/src/react/react-inspector-section.tsx +2247 -0
- package/src/react/react-world-authoring-adapter.ts +2602 -0
- package/src/react/story-documents/story-args-section.ts +9 -0
- package/src/react/story-documents/story-documents.tsx +1342 -0
- package/src/react/story-paint-bounds.ts +94 -0
- package/src/react/ui-board-document.tsx +55 -0
- package/src/react/ui-board-title.ts +9 -0
- package/src/react/ui-component-board.ts +52 -0
- package/src/runtime/presentation.ts +60 -0
- 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
|
-
/**
|
|
2
|
-
|
|
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
|
-
/**
|
|
2
|
-
|
|
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
|
-
/**
|
|
2
|
-
|
|
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
|
-
/**
|
|
2
|
-
|
|
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
|
-
"
|
|
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
|
|
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
|
-
"
|
|
24
|
-
"
|
|
25
|
-
"
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
"
|
|
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/
|
|
34
|
-
"@volter/
|
|
35
|
-
"@volter/project": "0.5.
|
|
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
|
+
}
|