@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
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
/** Mount project React components and portable stories in the UI board. The mounted root uses the project React instance and the source authoring adapter. */
|
|
2
|
+
|
|
3
|
+
import { editorHost } from '@volter/sdk/host';
|
|
4
|
+
import type {
|
|
5
|
+
DesignTimeMountContext,
|
|
6
|
+
DesignTimeRootDescriptor,
|
|
7
|
+
LayerMountResult,
|
|
8
|
+
} from '@volter/sdk/authoring/design-time-mount-registry';
|
|
9
|
+
import { rememberedPortableStory, rememberPortableStory } from '../host/portable-stories';
|
|
10
|
+
import { formatMountFailureMessage } from '@volter/sdk/mount-failure-report';
|
|
11
|
+
import type { ReactStoryBoardSelectionIntent } from '@volter/sdk/authoring/react-story-board';
|
|
12
|
+
import { createReactStoryBoard } from '@volter/sdk/authoring/react-story-board';
|
|
13
|
+
import {
|
|
14
|
+
getRootCanvasViewport,
|
|
15
|
+
setRootCanvasViewport,
|
|
16
|
+
} from '@volter/sdk/world-canvas-viewport-state';
|
|
17
|
+
import { recordAuthoringConsumerUse } from '@volter/sdk/authoring-seam-evidence';
|
|
18
|
+
import { CrashNullBoundary } from '../host/crash-null-boundary';
|
|
19
|
+
import { readProjectTextFile } from '@volter/sdk/api/project-source';
|
|
20
|
+
import { editorConsole } from '@volter/sdk/editor-console';
|
|
21
|
+
import { resolveReactRootMountRuntime } from '../host/react-mount-runtime';
|
|
22
|
+
import { activeRealmServices } from '../host/realm-services';
|
|
23
|
+
import { resolveReactAdapterRootComponent } from '../host/roots/react-root';
|
|
24
|
+
import { scopedProjectStylesState } from '@volter/sdk/scoped-project-css';
|
|
25
|
+
import { componentIdentityName } from '@volter/sdk/stories/compose-project-stories';
|
|
26
|
+
import { mountIsolatedStory } from '@volter/sdk/stories/StoryPreviewMount';
|
|
27
|
+
import {
|
|
28
|
+
createStoryPresentationIndex,
|
|
29
|
+
storyBoardPresentation,
|
|
30
|
+
} from '../host/stories/story-presentation';
|
|
31
|
+
import type { ProjectPreviewStory } from '@volter/sdk/stories/story-types';
|
|
32
|
+
import { subscribeProjectModuleChange } from '@volter/sdk/project-module-changes';
|
|
33
|
+
import { domStoryBoardMembers } from '@volter/editor-threejs/kit/stories/three-story-model';
|
|
34
|
+
import { getDesignTokens } from '@volter/sdk/ui-source/inspect';
|
|
35
|
+
import { tierSourceWriteBackend } from '@volter/sdk/ui-source/tier-source-write-backend';
|
|
36
|
+
import { recordViewportFirstFrame } from '@volter/sdk/viewport-activation-timings';
|
|
37
|
+
import { beginProjectMountEpoch } from '@volter/sdk/project-module-url';
|
|
38
|
+
import type { ComponentType } from 'react';
|
|
39
|
+
import { type OidElementLike, ReactRootAuthoringAdapter } from './react-world-authoring-adapter';
|
|
40
|
+
import { paintedContentBounds } from './story-paint-bounds';
|
|
41
|
+
|
|
42
|
+
/** Set by the DOM re-projection right before it remounts a
|
|
43
|
+
* DOM candidate: the registry just published, so the mount reads it as is. */
|
|
44
|
+
let skipStoryRefreshForRemount = false;
|
|
45
|
+
|
|
46
|
+
/** Board chrome is also document navigation: apply the story and make its
|
|
47
|
+
* structural row the store selection so global editor surfaces route back to
|
|
48
|
+
* this React adapter rather than the previously selected world. */
|
|
49
|
+
function selectPortableStory(adapter: ReactRootAuthoringAdapter | null, storyId: string): void {
|
|
50
|
+
if (!adapter) return;
|
|
51
|
+
recordAuthoringConsumerUse({
|
|
52
|
+
adapter,
|
|
53
|
+
seam: 'editor.stories.apply',
|
|
54
|
+
stage: 'effect',
|
|
55
|
+
detail: `the design board applied portable story ${storyId}`,
|
|
56
|
+
run: () => adapter.stories.apply(storyId, storyId),
|
|
57
|
+
});
|
|
58
|
+
recordAuthoringConsumerUse({
|
|
59
|
+
adapter,
|
|
60
|
+
seam: 'editor.selection.set',
|
|
61
|
+
stage: 'effect',
|
|
62
|
+
detail: `the design board selected portable story ${storyId}`,
|
|
63
|
+
run: () => adapter.selection.set([storyId]),
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Seed the session's dom-story mount box from the project's DECLARED
|
|
69
|
+
* resolution, once — the same rung the story CAPTURE lane reads: the screen a
|
|
70
|
+
* UI story's anchors resolve against is a manifest declaration, not a magic
|
|
71
|
+
* "Fill". Seeded only while the session viewport is still untouched, so a
|
|
72
|
+
* size (or Fill) the user picked afterwards always stands, and a project
|
|
73
|
+
* declaring nothing keeps Fill.
|
|
74
|
+
*/
|
|
75
|
+
let rootViewportSeeded = false;
|
|
76
|
+
async function seedRootViewportFromManifest(): Promise<void> {
|
|
77
|
+
if (rootViewportSeeded) return;
|
|
78
|
+
rootViewportSeeded = true;
|
|
79
|
+
const current = getRootCanvasViewport();
|
|
80
|
+
if (current.width !== null || current.height !== null) return;
|
|
81
|
+
try {
|
|
82
|
+
const text = await readProjectTextFile('volter.project.json');
|
|
83
|
+
if (!text) return;
|
|
84
|
+
const manifest = JSON.parse(text) as {
|
|
85
|
+
resolution?: { width?: unknown; height?: unknown };
|
|
86
|
+
};
|
|
87
|
+
const width = manifest.resolution?.width;
|
|
88
|
+
const height = manifest.resolution?.height;
|
|
89
|
+
if (typeof width !== 'number' || typeof height !== 'number') return;
|
|
90
|
+
if (width < 64 || height < 64 || width > 4096 || height > 4096) return;
|
|
91
|
+
setRootCanvasViewport(width, height);
|
|
92
|
+
} catch {
|
|
93
|
+
// An unreadable manifest declares nothing; Fill stands.
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
type EntryResolution = { ok: true; Entry: ComponentType } | { ok: false; error: unknown };
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Load a React root's entry component ONCE per layer mount, without throwing.
|
|
100
|
+
* Both of this layer's paths need it: the derived default story is joined
|
|
101
|
+
* against this component's identity, and the no-story fallback renders it.
|
|
102
|
+
* A failure is carried rather than thrown so the story board can still open
|
|
103
|
+
* over a project whose entry is broken; only the fallback (which has nothing
|
|
104
|
+
* else to render) rethrows it.
|
|
105
|
+
*/
|
|
106
|
+
async function resolveEntryComponent(
|
|
107
|
+
world: Parameters<typeof resolveReactAdapterRootComponent>[0],
|
|
108
|
+
projectRoot: string,
|
|
109
|
+
): Promise<EntryResolution> {
|
|
110
|
+
try {
|
|
111
|
+
// Design-time preview layer: its own mount, therefore its own epoch —
|
|
112
|
+
// and therefore its own realm value over that epoch.
|
|
113
|
+
const realm = await activeRealmServices(projectRoot, beginProjectMountEpoch());
|
|
114
|
+
const Entry = await resolveReactAdapterRootComponent(world, realm);
|
|
115
|
+
return { ok: true, Entry };
|
|
116
|
+
} catch (error) {
|
|
117
|
+
return { ok: false, error };
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Mount portable stories as board frames, or the entry component when no
|
|
122
|
+
* story is assigned. flushSync commits the fallback before its source adapter
|
|
123
|
+
* adopts the DOM. The adapter observes mounted elements and never mounts them. */
|
|
124
|
+
export async function mountReactDesignLayer(
|
|
125
|
+
candidate: DesignTimeRootDescriptor,
|
|
126
|
+
layer: HTMLElement,
|
|
127
|
+
context: DesignTimeMountContext,
|
|
128
|
+
): Promise<LayerMountResult> {
|
|
129
|
+
const { projectRootPath: projectRoot, store, activationDocumentId } = context;
|
|
130
|
+
// Discover the project-wide story gallery before requiring an entry.
|
|
131
|
+
// Rootless UI boards use stories alone; Play mounts the manifest entry.
|
|
132
|
+
// Registry-driven remounts already have fresh story modules.
|
|
133
|
+
if (skipStoryRefreshForRemount) skipStoryRefreshForRemount = false;
|
|
134
|
+
else await editorHost().stories.refresh({ rootPath: projectRoot });
|
|
135
|
+
// …every story DECLARED as DOM. The registry is project-global and
|
|
136
|
+
// deliberately unfiltered, so THIS board filters its own membership the
|
|
137
|
+
// way the `3D` board filters its own — otherwise a
|
|
138
|
+
// three story is handed to `react-dom`, which renders `<group>`/`<primitive>`
|
|
139
|
+
// as unknown tags into an empty frame.
|
|
140
|
+
const previewStories = editorHost().stories.previewStories();
|
|
141
|
+
const portableStories = domStoryBoardMembers(previewStories);
|
|
142
|
+
|
|
143
|
+
// Join the entry to its default story by component name and source directory.
|
|
144
|
+
// Story imports and entry imports use different cache-busted URLs, so their
|
|
145
|
+
// component objects need not share identity.
|
|
146
|
+
const entryPath = candidate.path;
|
|
147
|
+
const entry: EntryResolution = entryPath
|
|
148
|
+
? await resolveEntryComponent(
|
|
149
|
+
{ id: candidate.worldId, entry: entryPath },
|
|
150
|
+
projectRoot,
|
|
151
|
+
)
|
|
152
|
+
: {
|
|
153
|
+
ok: false,
|
|
154
|
+
error: new Error(
|
|
155
|
+
`design-time react layer "${candidate.worldId}": no \`entry\` declared — nothing to mount.`,
|
|
156
|
+
),
|
|
157
|
+
};
|
|
158
|
+
const entryComponentName = entry.ok ? componentIdentityName(entry.Entry) : undefined;
|
|
159
|
+
const derivedDefaultPortableStory = entryComponentName
|
|
160
|
+
? editorHost().stories.componentPreviewStory(entryComponentName, candidate.path)
|
|
161
|
+
: null;
|
|
162
|
+
const defaultPortableStory =
|
|
163
|
+
portableStories.find((story) => story.id === derivedDefaultPortableStory?.id) ?? null;
|
|
164
|
+
// The board exists because the PROJECT has stories, never because the
|
|
165
|
+
// derivation matched: an entry that fails to import, or a project whose
|
|
166
|
+
// stories name other components, still gets its whole gallery (the entry
|
|
167
|
+
// is only what picks the INITIAL frame). Without this the derivation would
|
|
168
|
+
// silently gate the entire design surface — a broken entry would take every
|
|
169
|
+
// story down with it.
|
|
170
|
+
if (portableStories.length > 0) {
|
|
171
|
+
const rememberedStoryId = rememberedPortableStory(projectRoot, candidate.worldId);
|
|
172
|
+
// Prefer the remembered choice, then the derived default, then discovery order.
|
|
173
|
+
const initialPortableStory =
|
|
174
|
+
portableStories.find((story) => story.id === rememberedStoryId) ??
|
|
175
|
+
defaultPortableStory ??
|
|
176
|
+
portableStories[0]!;
|
|
177
|
+
// A rootless board has no entry component and opens on its first story.
|
|
178
|
+
if (candidate.path && !defaultPortableStory) {
|
|
179
|
+
editorConsole.warn(
|
|
180
|
+
`[design-time-layers] React root "${candidate.worldId}" has no story naming its entry ` +
|
|
181
|
+
`component${entryComponentName ? ` "${entryComponentName}"` : ''} — the board opens on ` +
|
|
182
|
+
`"${initialPortableStory.label}". Give a *.stories.tsx a \`meta.component\` of this ` +
|
|
183
|
+
"root's entry component to choose its default preview.",
|
|
184
|
+
'authoring',
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
let disposed = false;
|
|
188
|
+
let adapter: ReactRootAuthoringAdapter | null = null;
|
|
189
|
+
// A story apply can originate from either hierarchy navigation or board
|
|
190
|
+
// interaction. Hierarchy has no pending board intent and therefore
|
|
191
|
+
// recenters; board clicks explicitly choose highlight-only or zoom.
|
|
192
|
+
let pendingBoardSelectionIntent: ReactStoryBoardSelectionIntent | null = null;
|
|
193
|
+
const mountedByStory = new Map<
|
|
194
|
+
string,
|
|
195
|
+
NonNullable<Awaited<ReturnType<typeof mountIsolatedStory>>>
|
|
196
|
+
>();
|
|
197
|
+
const storyPresentationIndex = createStoryPresentationIndex(portableStories);
|
|
198
|
+
await seedRootViewportFromManifest();
|
|
199
|
+
const board = createReactStoryBoard(
|
|
200
|
+
layer,
|
|
201
|
+
portableStories.map((story) => ({
|
|
202
|
+
id: story.id,
|
|
203
|
+
label: story.label,
|
|
204
|
+
modulePath: story.modulePath,
|
|
205
|
+
...(story.title ? { title: story.title } : {}),
|
|
206
|
+
parameters: story.parameters,
|
|
207
|
+
...(story.globals ? { globals: story.globals } : {}),
|
|
208
|
+
...(story.viewportLocked !== undefined ? { viewportLocked: story.viewportLocked } : {}),
|
|
209
|
+
})),
|
|
210
|
+
initialPortableStory.id,
|
|
211
|
+
(storyId, intent) => {
|
|
212
|
+
pendingBoardSelectionIntent = intent;
|
|
213
|
+
selectPortableStory(adapter, storyId);
|
|
214
|
+
},
|
|
215
|
+
// The board never reaches for the CSF implementation itself
|
|
216
|
+
// (`@volter/sdk/authoring/story-board-presentation`): this lane hands it
|
|
217
|
+
// one, reusing the index the React adapter is also constructed against.
|
|
218
|
+
storyBoardPresentation(storyPresentationIndex),
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
/** Frame the painted subject unless the story requests fullscreen or fills
|
|
222
|
+
* its viewport. Measure after commit and again after lazy content can settle. */
|
|
223
|
+
const measureContent = (story: ProjectPreviewStory): void => {
|
|
224
|
+
if (disposed) return;
|
|
225
|
+
const frame = board.frames.get(story.id);
|
|
226
|
+
if (!frame) return;
|
|
227
|
+
const layout = (story.parameters as { layout?: unknown } | undefined)?.layout;
|
|
228
|
+
if (layout === 'fullscreen') {
|
|
229
|
+
board.setMeasuredFrame(story.id, null);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
const mount = { width: frame.content.offsetWidth, height: frame.content.offsetHeight };
|
|
233
|
+
const bounds = mount.width > 0 ? paintedContentBounds(frame.content) : null;
|
|
234
|
+
if (!bounds) {
|
|
235
|
+
board.setMeasuredFrame(story.id, null);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
const PAD = 8;
|
|
239
|
+
const MIN = 48;
|
|
240
|
+
let x0 = Math.max(0, bounds.x - PAD);
|
|
241
|
+
let y0 = Math.max(0, bounds.y - PAD);
|
|
242
|
+
let x1 = Math.min(mount.width, bounds.x + bounds.width + PAD);
|
|
243
|
+
let y1 = Math.min(mount.height, bounds.y + bounds.height + PAD);
|
|
244
|
+
if (x1 - x0 < MIN) {
|
|
245
|
+
const grow = (MIN - (x1 - x0)) / 2;
|
|
246
|
+
x0 = Math.max(0, x0 - grow);
|
|
247
|
+
x1 = Math.min(mount.width, x0 + MIN);
|
|
248
|
+
}
|
|
249
|
+
if (y1 - y0 < MIN) {
|
|
250
|
+
const grow = (MIN - (y1 - y0)) / 2;
|
|
251
|
+
y0 = Math.max(0, y0 - grow);
|
|
252
|
+
y1 = Math.min(mount.height, y0 + MIN);
|
|
253
|
+
}
|
|
254
|
+
if (x1 - x0 >= mount.width * 0.9 && y1 - y0 >= mount.height * 0.9) {
|
|
255
|
+
board.setMeasuredFrame(story.id, null);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
board.setMeasuredFrame(story.id, {
|
|
259
|
+
frame: { width: Math.round(x1 - x0), height: Math.round(y1 - y0) },
|
|
260
|
+
mount,
|
|
261
|
+
offset: { x: Math.round(x0), y: Math.round(y0) },
|
|
262
|
+
});
|
|
263
|
+
};
|
|
264
|
+
const scheduleContentMeasure = (story: ProjectPreviewStory): void => {
|
|
265
|
+
setTimeout(() => measureContent(story), 0);
|
|
266
|
+
setTimeout(() => measureContent(story), 1_000);
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
const mountPortable = async (
|
|
270
|
+
story: ProjectPreviewStory,
|
|
271
|
+
args: Record<string, unknown> = story.args,
|
|
272
|
+
): Promise<void> => {
|
|
273
|
+
const content = board.frames.get(story.id)?.content;
|
|
274
|
+
if (!content) return;
|
|
275
|
+
try {
|
|
276
|
+
const next = await mountIsolatedStory(
|
|
277
|
+
content,
|
|
278
|
+
story.modulePath,
|
|
279
|
+
story.name,
|
|
280
|
+
story.Component,
|
|
281
|
+
args,
|
|
282
|
+
);
|
|
283
|
+
// A newer args update may claim this story's frame while its loader is
|
|
284
|
+
// still in flight. The portable mount reports that supersession as
|
|
285
|
+
// null; keep the newer handle intact.
|
|
286
|
+
if (!next) return;
|
|
287
|
+
if (disposed) {
|
|
288
|
+
next.unmount();
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
mountedByStory.set(story.id, next);
|
|
292
|
+
board.clearError(story.id);
|
|
293
|
+
scheduleContentMeasure(story);
|
|
294
|
+
} catch (error) {
|
|
295
|
+
const message = formatMountFailureMessage(error);
|
|
296
|
+
board.setError(story.id, message);
|
|
297
|
+
editorConsole.error(
|
|
298
|
+
`[design-time-layers] React root "${candidate.worldId}" failed mounting portable ` +
|
|
299
|
+
`story "${story.name}": ${message}`,
|
|
300
|
+
'authoring',
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
store.notifyIngestEdit();
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
// Each story owns its own React root and failure boundary. One broken
|
|
307
|
+
// loader therefore marks only its frame instead of blanking the board.
|
|
308
|
+
await Promise.all(portableStories.map((story) => mountPortable(story)));
|
|
309
|
+
// …and the board says so when the project's game CSS could not be applied.
|
|
310
|
+
// `mountIsolatedStory` has already resolved that state by now (it awaits
|
|
311
|
+
// the same per-project install before its first render), so this reads it
|
|
312
|
+
// rather than racing it. A first-party project reports nothing: `note` is
|
|
313
|
+
// non-null only where the host has EVIDENCE of missing page-level styling
|
|
314
|
+
// (`scoped-project-css.ts`).
|
|
315
|
+
board.setNote(scopedProjectStylesState().note);
|
|
316
|
+
// A root document opened because the user clicked a hierarchy story must
|
|
317
|
+
// arrive focused on that remembered frame. A first visit with no prior
|
|
318
|
+
// choice keeps the board's fit-all overview.
|
|
319
|
+
if (rememberedStoryId) board.activate(initialPortableStory.id, true);
|
|
320
|
+
|
|
321
|
+
const byId = new Map(portableStories.map((story) => [story.id, story]));
|
|
322
|
+
// The adapter deliberately walks only the focused story's live DOM. All
|
|
323
|
+
// frames stay rendered, but Inspector/OID selection has one unambiguous
|
|
324
|
+
// source tree for its source edits.
|
|
325
|
+
const focusedDomRoot: OidElementLike = {
|
|
326
|
+
tagName: 'DIV',
|
|
327
|
+
get children() {
|
|
328
|
+
return board.activeContent().children;
|
|
329
|
+
},
|
|
330
|
+
getAttribute: (name) => layer.getAttribute(name),
|
|
331
|
+
getBoundingClientRect: () => layer.getBoundingClientRect(),
|
|
332
|
+
get style() {
|
|
333
|
+
return layer.style;
|
|
334
|
+
},
|
|
335
|
+
get textContent() {
|
|
336
|
+
return board.activeContent().textContent;
|
|
337
|
+
},
|
|
338
|
+
};
|
|
339
|
+
// The serving route determines whether this mount has a source-write backend.
|
|
340
|
+
const boardWriteBackend = await tierSourceWriteBackend();
|
|
341
|
+
adapter = new ReactRootAuthoringAdapter(focusedDomRoot, store, {
|
|
342
|
+
...(boardWriteBackend ? { writeBackend: boardWriteBackend } : {}),
|
|
343
|
+
// The facade root above is a plain object, so the adapter's own
|
|
344
|
+
// scope-root token lookup cannot run — resolve tokens from the ACTIVE
|
|
345
|
+
// frame's real element instead (its game-CSS scope root carries the
|
|
346
|
+
// project's custom properties; the editor page root never does).
|
|
347
|
+
designTokens: () => {
|
|
348
|
+
const content = board.activeContent();
|
|
349
|
+
const scopeElement =
|
|
350
|
+
content.closest('[data-volter-mount-styles]') ??
|
|
351
|
+
content.querySelector('[data-volter-mount-styles]') ??
|
|
352
|
+
content;
|
|
353
|
+
return getDesignTokens(
|
|
354
|
+
getComputedStyle(scopeElement),
|
|
355
|
+
);
|
|
356
|
+
},
|
|
357
|
+
portableStories: portableStories.map((story) => ({
|
|
358
|
+
id: story.id,
|
|
359
|
+
label: story.label,
|
|
360
|
+
args: story.args,
|
|
361
|
+
name: story.name,
|
|
362
|
+
modulePath: story.modulePath,
|
|
363
|
+
})),
|
|
364
|
+
portableHierarchy: storyPresentationIndex,
|
|
365
|
+
activePortableStoryId: initialPortableStory.id,
|
|
366
|
+
onPortableStoryApplied: (storyId) => {
|
|
367
|
+
const story = (storyId ? byId.get(storyId) : null) ?? initialPortableStory;
|
|
368
|
+
rememberPortableStory(projectRoot, candidate.worldId, story.id);
|
|
369
|
+
const intent = pendingBoardSelectionIntent;
|
|
370
|
+
pendingBoardSelectionIntent = null;
|
|
371
|
+
// Direct provider calls come from hierarchy navigation and retain
|
|
372
|
+
// auto-center. A board single-click only changes active/selection;
|
|
373
|
+
// its double-click is the explicit zoom gesture.
|
|
374
|
+
board.activate(story.id, intent === null || intent === 'zoom');
|
|
375
|
+
},
|
|
376
|
+
onPortableStoryArgsChanged: (storyId, args) => {
|
|
377
|
+
const story = byId.get(storyId);
|
|
378
|
+
if (!story) return;
|
|
379
|
+
void mountPortable(story, args).catch((error: unknown) => {
|
|
380
|
+
editorConsole.error(
|
|
381
|
+
`[design-time-layers] React root "${candidate.worldId}" failed updating portable ` +
|
|
382
|
+
`story "${story.name}" args: ${formatMountFailureMessage(error)}`,
|
|
383
|
+
'authoring',
|
|
384
|
+
);
|
|
385
|
+
});
|
|
386
|
+
},
|
|
387
|
+
});
|
|
388
|
+
if (activationDocumentId) {
|
|
389
|
+
requestAnimationFrame(() => recordViewportFirstFrame(activationDocumentId));
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
return {
|
|
393
|
+
adapter,
|
|
394
|
+
dispose: () => {
|
|
395
|
+
disposed = true;
|
|
396
|
+
adapter.disposeReactRootAdapter();
|
|
397
|
+
for (const mounted of mountedByStory.values()) mounted.unmount();
|
|
398
|
+
mountedByStory.clear();
|
|
399
|
+
board.dispose();
|
|
400
|
+
},
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Without stories, the entry is the document's sole renderable subject.
|
|
405
|
+
if (!entry.ok) throw entry.error instanceof Error ? entry.error : new Error(String(entry.error));
|
|
406
|
+
|
|
407
|
+
editorConsole.warn(
|
|
408
|
+
`[design-time-layers] React root "${candidate.worldId}" has no portable CSF preview — the ` +
|
|
409
|
+
'project declares no UI stories. Add a *.stories.tsx; give it a `meta.component` of ' +
|
|
410
|
+
`this root's entry component${entryComponentName ? ` (${entryComponentName}, ${candidate.path})` : ''} ` +
|
|
411
|
+
'to make it this root’s default preview. Falling back to rendering the entry bare.',
|
|
412
|
+
'authoring',
|
|
413
|
+
);
|
|
414
|
+
|
|
415
|
+
const Entry = entry.Entry;
|
|
416
|
+
// Entry hooks and mounting use the same project React runtime as reactMount
|
|
417
|
+
// in host/roots/react-root.ts.
|
|
418
|
+
const { createElement, createRoot, flushSync } = await resolveReactRootMountRuntime();
|
|
419
|
+
const root = createRoot(layer);
|
|
420
|
+
const render = (onCaught: (error: unknown) => void): void => {
|
|
421
|
+
root.render(
|
|
422
|
+
createElement(CrashNullBoundary, {
|
|
423
|
+
key: '__entry__',
|
|
424
|
+
onCaught,
|
|
425
|
+
children: createElement(Entry),
|
|
426
|
+
}),
|
|
427
|
+
);
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
let caught: unknown = null;
|
|
431
|
+
flushSync(() => {
|
|
432
|
+
render((error: unknown) => {
|
|
433
|
+
caught = error;
|
|
434
|
+
});
|
|
435
|
+
});
|
|
436
|
+
if (caught !== null) {
|
|
437
|
+
root.unmount();
|
|
438
|
+
throw caught instanceof Error ? caught : new Error(String(caught));
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Same route-decided recorder as the board branch above.
|
|
442
|
+
const writeBackend = await tierSourceWriteBackend();
|
|
443
|
+
const adapter = new ReactRootAuthoringAdapter(layer, store, {
|
|
444
|
+
...(writeBackend ? { writeBackend } : {}),
|
|
445
|
+
});
|
|
446
|
+
if (activationDocumentId) {
|
|
447
|
+
requestAnimationFrame(() => recordViewportFirstFrame(activationDocumentId));
|
|
448
|
+
}
|
|
449
|
+
return {
|
|
450
|
+
adapter,
|
|
451
|
+
dispose: () => {
|
|
452
|
+
// Release the adapter's HMR reconcile subscription before dropping its DOM.
|
|
453
|
+
adapter.disposeReactRootAdapter();
|
|
454
|
+
root.unmount();
|
|
455
|
+
},
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** A cheap identity for the project's current story MEMBERSHIP — which
|
|
460
|
+
* stories exist, not their content. An unreadable module counts as its own
|
|
461
|
+
* member, so a `.stories.tsx` that starts and stops parsing still moves the
|
|
462
|
+
* signature. */
|
|
463
|
+
function storyMembershipSignature(): string {
|
|
464
|
+
return editorHost().stories.modules()
|
|
465
|
+
.map((module_) =>
|
|
466
|
+
module_.ok ? module_.stories.map((story) => story.id).join(',') : `!${module_.modulePath}`,
|
|
467
|
+
)
|
|
468
|
+
.join('|');
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** Reproject when story membership changes. Content changes use react-refresh
|
|
472
|
+
* when present; without it, a source write marks the next ready registry
|
|
473
|
+
* publication for reprojection. Ignore refresh-start notifications carrying
|
|
474
|
+
* the previous module set and preserve the old layer until its replacement settles. */
|
|
475
|
+
export function reprojectWhenStoriesRepublish(invalidate: () => void): () => void {
|
|
476
|
+
// The module set the board was last mounted from, and — separately — the
|
|
477
|
+
// MEMBERSHIP of that set (which stories exist, not their content).
|
|
478
|
+
let mountedModules: readonly unknown[] | null = editorHost().stories.ready()
|
|
479
|
+
? editorHost().stories.modules()
|
|
480
|
+
: null;
|
|
481
|
+
let mountedMembership = mountedModules === null ? null : storyMembershipSignature();
|
|
482
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
483
|
+
let contentWritten = false;
|
|
484
|
+
const stopWrites = subscribeProjectModuleChange((changed) => {
|
|
485
|
+
// (a project code module, wherever the project keeps its code: a std game's is in client/ and shared/)
|
|
486
|
+
const path = changed.replaceAll('\\', '/');
|
|
487
|
+
if (!/\.(?:[cm]?tsx?|jsx?)$/.test(path) || /(?:^|\/)node_modules\//.test(path)) return;
|
|
488
|
+
const page = globalThis as { $RefreshReg$?: unknown };
|
|
489
|
+
if (typeof page.$RefreshReg$ !== 'function') contentWritten = true;
|
|
490
|
+
});
|
|
491
|
+
const stop = editorHost().stories.subscribe(() => {
|
|
492
|
+
if (!editorHost().stories.ready()) return;
|
|
493
|
+
const modules = editorHost().stories.modules();
|
|
494
|
+
const membership = storyMembershipSignature();
|
|
495
|
+
if (mountedModules === null) {
|
|
496
|
+
// The mount's own publish: remember it, re-project nothing.
|
|
497
|
+
mountedModules = modules;
|
|
498
|
+
mountedMembership = membership;
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
const membershipChanged = membership !== mountedMembership;
|
|
502
|
+
const contentChanged = contentWritten && modules !== mountedModules;
|
|
503
|
+
mountedModules = modules;
|
|
504
|
+
mountedMembership = membership;
|
|
505
|
+
if (!membershipChanged && !contentChanged) return;
|
|
506
|
+
contentWritten = false;
|
|
507
|
+
if (timer !== null) clearTimeout(timer);
|
|
508
|
+
timer = setTimeout(() => {
|
|
509
|
+
timer = null;
|
|
510
|
+
// The next mount consumes this publication before its first await.
|
|
511
|
+
// Suppress another discovery refresh, then clear the flag next task.
|
|
512
|
+
skipStoryRefreshForRemount = true;
|
|
513
|
+
invalidate();
|
|
514
|
+
setTimeout(() => {
|
|
515
|
+
skipStoryRefreshForRemount = false;
|
|
516
|
+
}, 0);
|
|
517
|
+
}, 250);
|
|
518
|
+
});
|
|
519
|
+
return () => {
|
|
520
|
+
if (timer !== null) clearTimeout(timer);
|
|
521
|
+
timer = null;
|
|
522
|
+
stop();
|
|
523
|
+
stopWrites();
|
|
524
|
+
};
|
|
525
|
+
}
|