@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,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The union of what a mounted DOM story actually PAINTS, in the mount box's
|
|
3
|
+
* own authored pixels — the measurement half of "mount in the truth, frame the
|
|
4
|
+
* subject" for the UI board (the capture lane's alpha-bounds scan is the same
|
|
5
|
+
* decision made on pixels; a live card cannot rasterize per frame, so this
|
|
6
|
+
* walks the DOM instead).
|
|
7
|
+
*
|
|
8
|
+
* The filter matters more than the union: a translated uGUI screen is nested
|
|
9
|
+
* full-bleed wrapper boxes (anchor stretch, transparent) around a few painted
|
|
10
|
+
* leaves, so a naive union of ALL element rects is always the whole mount box
|
|
11
|
+
* and the crop never fires. An element counts only when it paints something a
|
|
12
|
+
* viewer can see: a non-transparent background, a background image, replaced
|
|
13
|
+
* content, a visible border/shadow, or its own text.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** True when this element itself puts pixels on screen. */
|
|
17
|
+
function elementPaints(element: Element): boolean {
|
|
18
|
+
const tag = element.tagName;
|
|
19
|
+
if (
|
|
20
|
+
tag === 'IMG' ||
|
|
21
|
+
tag === 'VIDEO' ||
|
|
22
|
+
tag === 'CANVAS' ||
|
|
23
|
+
tag === 'svg' ||
|
|
24
|
+
tag === 'SVG' ||
|
|
25
|
+
tag === 'PICTURE'
|
|
26
|
+
) {
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
const style = getComputedStyle(element);
|
|
30
|
+
if (style.visibility === 'hidden' || style.display === 'none') return false;
|
|
31
|
+
if (backgroundColorPaints(style.backgroundColor)) return true;
|
|
32
|
+
if (style.backgroundImage && style.backgroundImage !== 'none') return true;
|
|
33
|
+
if (style.boxShadow && style.boxShadow !== 'none') return true;
|
|
34
|
+
if (
|
|
35
|
+
style.borderStyle !== 'none' &&
|
|
36
|
+
style.borderStyle !== '' &&
|
|
37
|
+
(parseFloat(style.borderTopWidth) > 0 ||
|
|
38
|
+
parseFloat(style.borderRightWidth) > 0 ||
|
|
39
|
+
parseFloat(style.borderBottomWidth) > 0 ||
|
|
40
|
+
parseFloat(style.borderLeftWidth) > 0)
|
|
41
|
+
) {
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
for (const node of element.childNodes) {
|
|
45
|
+
if (node.nodeType === Node.TEXT_NODE && (node.textContent ?? '').trim() !== '') return true;
|
|
46
|
+
}
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** `transparent` serializes as `rgba(0, 0, 0, 0)`; anything with alpha paints. */
|
|
51
|
+
function backgroundColorPaints(color: string): boolean {
|
|
52
|
+
if (!color || color === 'transparent') return false;
|
|
53
|
+
const rgba = /^rgba\(\s*[\d.]+\s*,\s*[\d.]+\s*,\s*[\d.]+\s*,\s*([\d.]+)\s*\)$/.exec(color);
|
|
54
|
+
if (rgba) return parseFloat(rgba[1]!) > 0;
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface PaintedBounds {
|
|
59
|
+
readonly x: number;
|
|
60
|
+
readonly y: number;
|
|
61
|
+
readonly width: number;
|
|
62
|
+
readonly height: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Union of the painted descendants' boxes in the mount box's authored px, or
|
|
67
|
+
* `null` when nothing paints. Rects are read through the live client transform
|
|
68
|
+
* (the board zooms its canvas), so the mount box's own client width supplies
|
|
69
|
+
* the scale — self-normalizing, no zoom parameter to drift.
|
|
70
|
+
*/
|
|
71
|
+
export function paintedContentBounds(
|
|
72
|
+
content: HTMLElement,
|
|
73
|
+
rectOf: (element: Element) => DOMRect = (element) => element.getBoundingClientRect(),
|
|
74
|
+
): PaintedBounds | null {
|
|
75
|
+
const home = rectOf(content);
|
|
76
|
+
const authoredWidth = content.offsetWidth;
|
|
77
|
+
if (home.width <= 0 || authoredWidth <= 0) return null;
|
|
78
|
+
const scale = home.width / authoredWidth;
|
|
79
|
+
let left = Number.POSITIVE_INFINITY;
|
|
80
|
+
let top = Number.POSITIVE_INFINITY;
|
|
81
|
+
let right = Number.NEGATIVE_INFINITY;
|
|
82
|
+
let bottom = Number.NEGATIVE_INFINITY;
|
|
83
|
+
for (const element of content.querySelectorAll('*')) {
|
|
84
|
+
if (!elementPaints(element)) continue;
|
|
85
|
+
const rect = rectOf(element);
|
|
86
|
+
if (rect.width <= 0 || rect.height <= 0) continue;
|
|
87
|
+
left = Math.min(left, (rect.left - home.left) / scale);
|
|
88
|
+
top = Math.min(top, (rect.top - home.top) / scale);
|
|
89
|
+
right = Math.max(right, (rect.right - home.left) / scale);
|
|
90
|
+
bottom = Math.max(bottom, (rect.bottom - home.top) / scale);
|
|
91
|
+
}
|
|
92
|
+
if (right <= left || bottom <= top) return null;
|
|
93
|
+
return { x: left, y: top, width: right - left, height: bottom - top };
|
|
94
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/** Lazily install the UI board through host document services. A DOM root
|
|
2
|
+
* supplies its provenance and route; a rootless project presents its component
|
|
3
|
+
* gallery. Captures remember the selected portable story for restoration. */
|
|
4
|
+
|
|
5
|
+
import { designTimeRootDescriptors } from '@volter/sdk/authoring/design-time-roots';
|
|
6
|
+
import { projectStoryBoardDescriptor, rememberedPortableStory } from '../host/portable-stories';
|
|
7
|
+
import type { ComponentBoardContext } from '@volter/sdk/component-board-registry';
|
|
8
|
+
import { editorHost } from '@volter/sdk/host';
|
|
9
|
+
import { getCurrentProject } from '@volter/sdk/active-project';
|
|
10
|
+
import { UI_COMPONENTS_DOCUMENT_ID } from '@volter/sdk/workspace-document-ids';
|
|
11
|
+
import type { WorkspaceDocumentContentProps } from '@volter/sdk/workspace-document-registry';
|
|
12
|
+
|
|
13
|
+
import { UI_COMPONENTS_TITLE } from './ui-board-title';
|
|
14
|
+
|
|
15
|
+
export function installUiBoard({ store, composite }: ComponentBoardContext): void {
|
|
16
|
+
const descriptor =
|
|
17
|
+
designTimeRootDescriptors(composite).find((root) => root.kind === 'dom') ??
|
|
18
|
+
projectStoryBoardDescriptor();
|
|
19
|
+
const host = editorHost();
|
|
20
|
+
const { RootContent } = host.documents;
|
|
21
|
+
const Content = (props: WorkspaceDocumentContentProps) => (
|
|
22
|
+
<RootContent {...props} composite={composite} descriptor={descriptor} store={store} />
|
|
23
|
+
);
|
|
24
|
+
host.workspace.registerAvailableDocument(
|
|
25
|
+
{
|
|
26
|
+
id: UI_COMPONENTS_DOCUMENT_ID,
|
|
27
|
+
title: UI_COMPONENTS_TITLE,
|
|
28
|
+
kind: 'world',
|
|
29
|
+
provenance: {
|
|
30
|
+
rootId: descriptor.worldId,
|
|
31
|
+
...(descriptor.path ? { sourcePath: descriptor.path } : {}),
|
|
32
|
+
},
|
|
33
|
+
Content,
|
|
34
|
+
presentation: () => {
|
|
35
|
+
// Capture the active story so restoring this view selects the same frame.
|
|
36
|
+
const projectRoot = getCurrentProject()?.rootPath;
|
|
37
|
+
const story = projectRoot
|
|
38
|
+
? rememberedPortableStory(projectRoot, descriptor.worldId)
|
|
39
|
+
: undefined;
|
|
40
|
+
return {
|
|
41
|
+
kind: 'workspace',
|
|
42
|
+
id: UI_COMPONENTS_DOCUMENT_ID,
|
|
43
|
+
...(story ? { story } : {}),
|
|
44
|
+
};
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
// An asynchronously discovered board opens without taking focus.
|
|
48
|
+
{
|
|
49
|
+
category: 'canvas',
|
|
50
|
+
default: !composite
|
|
51
|
+
.childAdapters()
|
|
52
|
+
.some((root) => root.kind === 'stage' || root.kind === 'canvas'),
|
|
53
|
+
},
|
|
54
|
+
);
|
|
55
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** The tab reads `UI`, the peer of `Scene` and `3D` in the center strip. The
|
|
2
|
+
* document ID is a fixed id, not this label, because persisted layouts address
|
|
3
|
+
* panels by id (deriving either from the root would churn the panel identity
|
|
4
|
+
* on a rename, stranding the old panel in every layout).
|
|
5
|
+
*
|
|
6
|
+
* Its own module because the registration reads it for the host's absence
|
|
7
|
+
* prose while the DOCUMENT reads it for the tab, and the two halves are
|
|
8
|
+
* deliberately in different closures. */
|
|
9
|
+
export const UI_COMPONENTS_TITLE = 'UI';
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/** Register the UI board for projects with declared DOM stories. The board
|
|
2
|
+
* document is loaded when story discovery reports that medium present. */
|
|
3
|
+
|
|
4
|
+
import { editorHost } from '@volter/sdk/host';
|
|
5
|
+
import { commandLine } from '@volter/sdk/product-command';
|
|
6
|
+
import type { ComponentBoard, ComponentBoardContext } from '@volter/sdk/component-board-registry';
|
|
7
|
+
import { editorConsole } from '@volter/sdk/editor-console';
|
|
8
|
+
import {
|
|
9
|
+
getStoryMediaPresence,
|
|
10
|
+
subscribeStoryMediaPresence,
|
|
11
|
+
} from '../host/stories/story-media-presence';
|
|
12
|
+
import { UI_COMPONENTS_DOCUMENT_ID } from '@volter/sdk/workspace-document-ids';
|
|
13
|
+
import { UI_COMPONENTS_TITLE } from './ui-board-title';
|
|
14
|
+
|
|
15
|
+
export const uiComponentBoard: ComponentBoard = {
|
|
16
|
+
medium: 'dom',
|
|
17
|
+
owner: '@volter/editor-ui/react/ui-board',
|
|
18
|
+
documentId: UI_COMPONENTS_DOCUMENT_ID,
|
|
19
|
+
title: UI_COMPONENTS_TITLE,
|
|
20
|
+
// Story media determine presence; an unsettled registry has no verdict.
|
|
21
|
+
presence: () =>
|
|
22
|
+
!editorHost().stories.ready() ? null : getStoryMediaPresence().dom ? 'present' : 'absent',
|
|
23
|
+
subscribe: (listener) => {
|
|
24
|
+
// Two signals, because the verdict is a function of both: the census
|
|
25
|
+
// (which media the project's stories declare) and the discovery flag that
|
|
26
|
+
// decides whether the census counts yet.
|
|
27
|
+
const stopPresence = subscribeStoryMediaPresence(listener);
|
|
28
|
+
const stopRegistry = editorHost().stories.subscribe(listener);
|
|
29
|
+
return () => {
|
|
30
|
+
stopPresence();
|
|
31
|
+
stopRegistry();
|
|
32
|
+
};
|
|
33
|
+
},
|
|
34
|
+
install: async (context: ComponentBoardContext) => {
|
|
35
|
+
try {
|
|
36
|
+
const { installUiBoard } = await import('./ui-board-document');
|
|
37
|
+
installUiBoard(context);
|
|
38
|
+
} catch (error: unknown) {
|
|
39
|
+
editorConsole.error(
|
|
40
|
+
`Failed to load the ${UI_COMPONENTS_TITLE} board: ${String(error)}`,
|
|
41
|
+
'scene',
|
|
42
|
+
);
|
|
43
|
+
throw error;
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
absentReason: () =>
|
|
47
|
+
`This project has no ${UI_COMPONENTS_TITLE} board (${UI_COMPONENTS_DOCUMENT_ID}): board ` +
|
|
48
|
+
"presence keys on STORIES, not on roots, and none of this project's stories declares the " +
|
|
49
|
+
'"dom" medium. Declare it — a manifest root entry naming the story\'s module, or a ' +
|
|
50
|
+
`\`editor/volter.adapter.ts\` regionInclude that covers it — and the board appears. ${commandLine('console')} ` +
|
|
51
|
+
'names every story it could not place.',
|
|
52
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/** Fit a logical story viewport inside its host while preserving aspect. */
|
|
2
|
+
|
|
3
|
+
/** A width/height pair in whatever unit the caller is working in. */
|
|
4
|
+
export interface PresentationSize {
|
|
5
|
+
readonly width: number;
|
|
6
|
+
readonly height: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Where a logical rect lands inside a container, scaled uniformly and centered. */
|
|
10
|
+
export interface PresentationRect {
|
|
11
|
+
/** Uniform logical→container scale factor. Always finite and > 0. */
|
|
12
|
+
readonly scale: number;
|
|
13
|
+
/** The scaled rect's size in container units. */
|
|
14
|
+
readonly width: number;
|
|
15
|
+
readonly height: number;
|
|
16
|
+
/** The letterbox margins — how far in from the container's top-left the scaled rect starts. */
|
|
17
|
+
readonly offsetX: number;
|
|
18
|
+
readonly offsetY: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function positiveOrZero(value: number): number {
|
|
22
|
+
return Number.isFinite(value) && value > 0 ? value : 0;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Fit `logical` inside `container`, preserving aspect and centering the result.
|
|
27
|
+
*
|
|
28
|
+
* `allowUpscale` is the difference between the two callers this replaces and it is not cosmetic.
|
|
29
|
+
* A DEVICE PREVIEW must never upscale — the point is to see the device's own pixels, so a phone
|
|
30
|
+
* frame in a big panel stays phone-sized. A game's DECLARED resolution must upscale, or a 720x540
|
|
31
|
+
* game sits as a postage stamp in the middle of a maximized panel and the feature reads as broken.
|
|
32
|
+
*
|
|
33
|
+
* A degenerate input (zero, negative, NaN — a panel mid-collapse, a manifest with a 0) yields
|
|
34
|
+
* `scale: 1` and a zero-offset rect rather than an Infinity that would propagate into a CSS
|
|
35
|
+
* transform.
|
|
36
|
+
*/
|
|
37
|
+
export function fitPresentation(
|
|
38
|
+
container: PresentationSize,
|
|
39
|
+
logical: PresentationSize,
|
|
40
|
+
options: { readonly allowUpscale?: boolean } = {},
|
|
41
|
+
): PresentationRect {
|
|
42
|
+
const cw = positiveOrZero(container.width);
|
|
43
|
+
const ch = positiveOrZero(container.height);
|
|
44
|
+
const lw = positiveOrZero(logical.width);
|
|
45
|
+
const lh = positiveOrZero(logical.height);
|
|
46
|
+
if (cw === 0 || ch === 0 || lw === 0 || lh === 0) {
|
|
47
|
+
return { scale: 1, width: lw, height: lh, offsetX: 0, offsetY: 0 };
|
|
48
|
+
}
|
|
49
|
+
const raw = Math.min(cw / lw, ch / lh);
|
|
50
|
+
const scale = options.allowUpscale === true ? raw : Math.min(raw, 1);
|
|
51
|
+
const width = lw * scale;
|
|
52
|
+
const height = lh * scale;
|
|
53
|
+
return {
|
|
54
|
+
scale,
|
|
55
|
+
width,
|
|
56
|
+
height,
|
|
57
|
+
offsetX: (cw - width) / 2,
|
|
58
|
+
offsetY: (ch - height) / 2,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
/** DOM roots offered to playing stages, independent of a product or game loop. */
|
|
2
|
-
import { registerProjectPlayLayers } from '@volter/sdk/kit/project-play-layers';
|
|
3
|
-
import { resolveDomAdapter } from '@volter/editor-game/host/roots/react-root';
|
|
4
|
-
import { activeRealmServices } from '@volter/editor-game/host/realm-services';
|
|
5
|
-
import { fetchGameManifest } from '@volter/sdk/kit/manifest-project';
|
|
6
|
-
export const point = 'workspace.service';
|
|
7
|
-
export function start(): () => void {
|
|
8
|
-
return registerProjectPlayLayers(async ({ projectRoot, epoch, container, onEntries }) => {
|
|
9
|
-
const manifest = await fetchGameManifest();
|
|
10
|
-
const roots = manifest.roots.filter(declaration => declaration.adapter.identity === 'dom');
|
|
11
|
-
const entries = roots.flatMap(declaration => declaration.entry ? [declaration.entry] : []);
|
|
12
|
-
onEntries(entries);
|
|
13
|
-
const realm = await activeRealmServices(projectRoot, epoch);
|
|
14
|
-
const disposals: (() => void)[] = [];
|
|
15
|
-
const dispose = () => { for (const end of disposals.splice(0).reverse()) end(); };
|
|
16
|
-
try {
|
|
17
|
-
for (const declaration of roots) {
|
|
18
|
-
const layer = document.createElement('div');
|
|
19
|
-
Object.assign(layer.style, { position: 'absolute', inset: '0', zIndex: String(declaration.zOrder), pointerEvents: 'none' });
|
|
20
|
-
layer.dataset.rootId = declaration.id;
|
|
21
|
-
container.appendChild(layer);
|
|
22
|
-
disposals.push(() => layer.remove());
|
|
23
|
-
const adapter = await resolveDomAdapter(declaration, realm);
|
|
24
|
-
const mounted = await adapter.mount({ container: layer });
|
|
25
|
-
// Stop can be a parent React commit; unmount the independent root after it.
|
|
26
|
-
disposals.push(() => queueMicrotask(() => mounted.dispose()));
|
|
27
|
-
}
|
|
28
|
-
return { entries, dispose };
|
|
29
|
-
} catch (error) { dispose(); throw error; }
|
|
30
|
-
});
|
|
31
|
-
}
|