@volter/editor-sdk 0.5.57
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/LICENSE +202 -0
- package/NOTICE +8 -0
- package/README.md +19 -0
- package/package.json +90 -0
- package/src/account.ts +210 -0
- package/src/chrome.ts +83 -0
- package/src/client.ts +1547 -0
- package/src/commands.ts +66 -0
- package/src/contributions.ts +985 -0
- package/src/document-probe.ts +237 -0
- package/src/editor-view.ts +220 -0
- package/src/extension.ts +40 -0
- package/src/generations.ts +178 -0
- package/src/host.ts +1167 -0
- package/src/http-transport.browser.ts +14 -0
- package/src/http-transport.node.ts +19 -0
- package/src/index.ts +128 -0
- package/src/layout-arrangements.ts +5 -0
- package/src/layouts.tsx +108 -0
- package/src/looks.ts +14 -0
- package/src/project/output-roots.ts +73 -0
- package/src/project/tab-census.ts +149 -0
- package/src/project-tool-catalog.ts +96 -0
- package/src/selection.tsx +108 -0
- package/src/services.ts +18 -0
- package/src/session/build-report.ts +19 -0
- package/src/session/collaboration-types.ts +262 -0
- package/src/session/command-table.ts +333 -0
- package/src/session/discovery.ts +90 -0
- package/src/session/editor-brand.ts +73 -0
- package/src/session/editor-compatibility.ts +248 -0
- package/src/session/editor-control-lifecycle.ts +68 -0
- package/src/session/editor-control-protocol.ts +5 -0
- package/src/session/entrypoint-selection-readers.ts +66 -0
- package/src/session/entrypoint-selection-source.ts +120 -0
- package/src/session/game-css-scope.ts +30 -0
- package/src/session/product-create.ts +24 -0
- package/src/session/product-locator.ts +389 -0
- package/src/session/project-module-url.ts +245 -0
- package/src/session/registry-format.ts +203 -0
- package/src/session/relative-path-guard.ts +56 -0
- package/src/session/source-glob.ts +15 -0
- package/src/session/tool-contribution-convention.ts +116 -0
- package/src/session/workbench-locator.ts +650 -0
- package/src/session.ts +41 -0
- package/src/share.ts +160 -0
- package/src/tools/errors.ts +91 -0
- package/src/tools/provider-execution.ts +70 -0
- package/src/tools/registry.ts +341 -0
- package/src/tools/types.ts +159 -0
- package/src/transport.ts +97 -0
- package/src/types.ts +1581 -0
- package/src/views.ts +164 -0
- package/src/widgets/design-system.ts +93 -0
- package/src/widgets/editor-appearance.ts +149 -0
- package/src/widgets/editor-material.ts +83 -0
- package/src/widgets/icon-set-registry.ts +105 -0
- package/src/widgets/index.ts +71 -0
- package/src/widgets/inspector-widgets/AlignmentGrid.tsx +182 -0
- package/src/widgets/inspector-widgets/AssetSlotPicker.tsx +123 -0
- package/src/widgets/inspector-widgets/BorderEditor.tsx +309 -0
- package/src/widgets/inspector-widgets/ColorPicker.tsx +549 -0
- package/src/widgets/inspector-widgets/CurveEditor.tsx +359 -0
- package/src/widgets/inspector-widgets/FilterEditor.tsx +108 -0
- package/src/widgets/inspector-widgets/FontPicker.tsx +191 -0
- package/src/widgets/inspector-widgets/GradientEditor.tsx +623 -0
- package/src/widgets/inspector-widgets/ScrubbableInput.tsx +180 -0
- package/src/widgets/inspector-widgets/ShadowEditor.tsx +319 -0
- package/src/widgets/inspector-widgets/color-utils.ts +201 -0
- package/src/widgets/inspector-widgets/curve-utils.ts +212 -0
- package/src/widgets/inspector-widgets/index.ts +24 -0
- package/src/widgets/inspector-widgets/shared.tsx +140 -0
- package/src/widgets/interactive-edit-scope.ts +33 -0
- package/src/widgets/patterns/Dialog.tsx +129 -0
- package/src/widgets/patterns/Fields.tsx +44 -0
- package/src/widgets/patterns/List.tsx +25 -0
- package/src/widgets/patterns/StateSurface.tsx +40 -0
- package/src/widgets/patterns/Surfaces.tsx +122 -0
- package/src/widgets/patterns/Tabs.tsx +80 -0
- package/src/widgets/patterns/Toolbar.tsx +72 -0
- package/src/widgets/patterns/Tree.tsx +72 -0
- package/src/widgets/primitives/AnchoredMenu.tsx +260 -0
- package/src/widgets/primitives/Button.tsx +62 -0
- package/src/widgets/primitives/ColorInput.tsx +78 -0
- package/src/widgets/primitives/DraftTextInput.tsx +63 -0
- package/src/widgets/primitives/EditorIcon.tsx +157 -0
- package/src/widgets/primitives/FormControls.tsx +88 -0
- package/src/widgets/primitives/HoverPreview.tsx +96 -0
- package/src/widgets/primitives/JsonInput.tsx +113 -0
- package/src/widgets/primitives/Layout.tsx +100 -0
- package/src/widgets/primitives/Menu.tsx +140 -0
- package/src/widgets/primitives/NumberInput.tsx +169 -0
- package/src/widgets/primitives/Panel.tsx +80 -0
- package/src/widgets/primitives/SectionHeader.tsx +77 -0
- package/src/widgets/primitives/Text.tsx +54 -0
- package/src/widgets/primitives/ThemeRootPortal.tsx +52 -0
- package/src/widgets/primitives/Tooltip.tsx +204 -0
- package/src/widgets/primitives/Vec3Input.tsx +70 -0
- package/src/widgets/primitives/banner-tones.ts +32 -0
- package/src/widgets/primitives/clamp-to-viewport.ts +44 -0
- package/src/widgets/primitives/editor-icons.ts +245 -0
- package/src/widgets/primitives/panel-header-styles.ts +42 -0
- package/src/widgets/theme.ts +2633 -0
- package/src/widgets/z-index.ts +25 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ButtonHTMLAttributes,
|
|
3
|
+
forwardRef,
|
|
4
|
+
type HTMLAttributes,
|
|
5
|
+
type RefObject,
|
|
6
|
+
useEffect,
|
|
7
|
+
useImperativeHandle,
|
|
8
|
+
useRef,
|
|
9
|
+
} from 'react';
|
|
10
|
+
|
|
11
|
+
function classes(base: string, className?: string): string {
|
|
12
|
+
return className ? `${base} ${className}` : base;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface MenuProps extends HTMLAttributes<HTMLDivElement> {
|
|
16
|
+
autoFocusFirst?: boolean;
|
|
17
|
+
/** Optional trigger/anchor that counts as part of the menu's dismissal boundary.
|
|
18
|
+
* Portaled menus need this so pressing their trigger closes them once instead
|
|
19
|
+
* of the document-level outside handler closing on pointerdown and the
|
|
20
|
+
* trigger's click immediately reopening them. */
|
|
21
|
+
dismissBoundaryRef?: RefObject<HTMLElement | null>;
|
|
22
|
+
onDismiss?: (reason: MenuDismissReason) => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type MenuDismissReason = 'escape' | 'outside';
|
|
26
|
+
|
|
27
|
+
function enabledItems(menu: HTMLDivElement): HTMLElement[] {
|
|
28
|
+
return Array.from(
|
|
29
|
+
menu.querySelectorAll<HTMLElement>(
|
|
30
|
+
'[role="menuitem"]:not(:disabled), [role="menuitemradio"]:not(:disabled), [role="menuitemcheckbox"]:not(:disabled)',
|
|
31
|
+
),
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Focus a menu's first enabled item. Exported so a portaled owner (AnchoredMenu)
|
|
36
|
+
* can drive autofocus AFTER it unhides: browsers refuse `focus()` inside a
|
|
37
|
+
* `visibility:hidden` subtree and never restore it on unhide, so the passive
|
|
38
|
+
* mount-time effect below fires too early for a measure-then-show menu. */
|
|
39
|
+
export function focusFirstMenuItem(menu: HTMLDivElement): void {
|
|
40
|
+
enabledItems(menu)[0]?.focus();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const Menu = forwardRef<HTMLDivElement, MenuProps>(function Menu(
|
|
44
|
+
{
|
|
45
|
+
className,
|
|
46
|
+
role = 'menu',
|
|
47
|
+
autoFocusFirst = false,
|
|
48
|
+
dismissBoundaryRef,
|
|
49
|
+
onDismiss,
|
|
50
|
+
onKeyDown,
|
|
51
|
+
...props
|
|
52
|
+
},
|
|
53
|
+
ref,
|
|
54
|
+
) {
|
|
55
|
+
const localRef = useRef<HTMLDivElement>(null);
|
|
56
|
+
useImperativeHandle(ref, () => localRef.current as HTMLDivElement);
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
if (autoFocusFirst) enabledItems(localRef.current as HTMLDivElement)[0]?.focus();
|
|
59
|
+
}, [autoFocusFirst]);
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
if (!onDismiss) return;
|
|
62
|
+
const dismissOutside = (event: PointerEvent) => {
|
|
63
|
+
const target = event.target as Node;
|
|
64
|
+
if (!localRef.current?.contains(target) && !dismissBoundaryRef?.current?.contains(target)) {
|
|
65
|
+
onDismiss('outside');
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
document.addEventListener('pointerdown', dismissOutside);
|
|
69
|
+
return () => document.removeEventListener('pointerdown', dismissOutside);
|
|
70
|
+
}, [dismissBoundaryRef, onDismiss]);
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<div
|
|
74
|
+
{...props}
|
|
75
|
+
ref={localRef}
|
|
76
|
+
role={role}
|
|
77
|
+
className={classes('vgai-menu', className)}
|
|
78
|
+
onKeyDown={(event) => {
|
|
79
|
+
onKeyDown?.(event);
|
|
80
|
+
if (event.defaultPrevented) return;
|
|
81
|
+
if (event.key === 'Escape') {
|
|
82
|
+
event.preventDefault();
|
|
83
|
+
onDismiss?.('escape');
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return;
|
|
87
|
+
event.preventDefault();
|
|
88
|
+
const items = enabledItems(event.currentTarget);
|
|
89
|
+
if (items.length === 0) return;
|
|
90
|
+
const current = items.indexOf(document.activeElement as HTMLElement);
|
|
91
|
+
if (event.key === 'Home') items[0]?.focus();
|
|
92
|
+
else if (event.key === 'End') items.at(-1)?.focus();
|
|
93
|
+
else if (event.key === 'ArrowDown')
|
|
94
|
+
items[(current + 1 + items.length) % items.length]?.focus();
|
|
95
|
+
else items[(current - 1 + items.length) % items.length]?.focus();
|
|
96
|
+
}}
|
|
97
|
+
/>
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
export type MenuItemProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
|
102
|
+
onSelect?: (() => void) | undefined;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
export const MenuItem = forwardRef<HTMLButtonElement, MenuItemProps>(function MenuItem(
|
|
106
|
+
{ className, type = 'button', role = 'menuitem', onClick, onSelect, ...props },
|
|
107
|
+
ref,
|
|
108
|
+
) {
|
|
109
|
+
return (
|
|
110
|
+
<button
|
|
111
|
+
{...props}
|
|
112
|
+
ref={ref}
|
|
113
|
+
type={type}
|
|
114
|
+
role={role}
|
|
115
|
+
className={classes('vgai-menu-item', className)}
|
|
116
|
+
onClick={(event) => {
|
|
117
|
+
onClick?.(event);
|
|
118
|
+
if (!event.defaultPrevented) onSelect?.();
|
|
119
|
+
}}
|
|
120
|
+
/>
|
|
121
|
+
);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
export function MenuSeparator(props: HTMLAttributes<HTMLHRElement>) {
|
|
125
|
+
return <hr {...props} className={classes('vgai-menu-separator', props.className)} />;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export const MenuTrigger = forwardRef<HTMLButtonElement, ButtonHTMLAttributes<HTMLButtonElement>>(
|
|
129
|
+
function MenuTrigger({ className, type = 'button', ...props }, ref) {
|
|
130
|
+
return (
|
|
131
|
+
<button
|
|
132
|
+
{...props}
|
|
133
|
+
ref={ref}
|
|
134
|
+
type={type}
|
|
135
|
+
aria-haspopup={props['aria-haspopup'] ?? 'menu'}
|
|
136
|
+
className={classes('vgai-menu-trigger', className)}
|
|
137
|
+
/>
|
|
138
|
+
);
|
|
139
|
+
},
|
|
140
|
+
);
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { useInteractiveEditScope } from '../interactive-edit-scope';
|
|
3
|
+
import { TextInput } from './FormControls';
|
|
4
|
+
|
|
5
|
+
interface NumberInputProps {
|
|
6
|
+
value: number | null; // null = mixed (multi-edit)
|
|
7
|
+
onChange: (value: number) => void;
|
|
8
|
+
/**
|
|
9
|
+
* Fires once per discrete edit gesture — a drag's pointerup (with the
|
|
10
|
+
* drag's FINAL value) or a typed edit's blur/Enter — instead of on every
|
|
11
|
+
* intermediate `onChange` call (which still fires on every pointermove
|
|
12
|
+
* during a drag, for callers that want live feedback as the value moves).
|
|
13
|
+
* Optional: existing callers that only wired `onChange` are unaffected. A
|
|
14
|
+
* caller whose `onChange` triggers something expensive per call (a file
|
|
15
|
+
* write, a network POST — see DataPanel's DATA-tab number cells, #3)
|
|
16
|
+
* should commit through THIS instead, so a 2-second scrub fires exactly
|
|
17
|
+
* one commit, not dozens.
|
|
18
|
+
*/
|
|
19
|
+
onChangeEnd?: (value: number) => void;
|
|
20
|
+
step?: number;
|
|
21
|
+
/**
|
|
22
|
+
* Decimal places in the resting display. Defaults to 3 — the right read for
|
|
23
|
+
* a transform axis, and a nonsense one for a whole-number tuning field
|
|
24
|
+
* (`ROUNDS 3.000`). A caller that knows its field's granularity passes it;
|
|
25
|
+
* `TuningPanel` derives it from the schema's step.
|
|
26
|
+
*/
|
|
27
|
+
precision?: number;
|
|
28
|
+
/** Read-only: no scrub, no typed edit. Optional — existing callers unchanged. */
|
|
29
|
+
disabled?: boolean;
|
|
30
|
+
label?: string;
|
|
31
|
+
labelColor?: string | undefined;
|
|
32
|
+
style?: React.CSSProperties;
|
|
33
|
+
/** Stable test hook (set on the wrapper; the edit <input> is its descendant). */
|
|
34
|
+
testId?: string | undefined;
|
|
35
|
+
/** Hover text for the whole field (a channel's read-only reason, say). */
|
|
36
|
+
title?: string | undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function NumberInput({
|
|
40
|
+
value,
|
|
41
|
+
onChange,
|
|
42
|
+
onChangeEnd,
|
|
43
|
+
step = 0.1,
|
|
44
|
+
precision = 3,
|
|
45
|
+
disabled = false,
|
|
46
|
+
label,
|
|
47
|
+
labelColor,
|
|
48
|
+
style,
|
|
49
|
+
testId,
|
|
50
|
+
title,
|
|
51
|
+
}: NumberInputProps) {
|
|
52
|
+
const [editing, setEditing] = useState(false);
|
|
53
|
+
const [text, setText] = useState('');
|
|
54
|
+
const dragRef = useRef({ startX: 0, startVal: 0, dragging: false, gesture: false, lastVal: 0 });
|
|
55
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
56
|
+
const editScope = useInteractiveEditScope();
|
|
57
|
+
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
if (!editing) return;
|
|
60
|
+
const input = inputRef.current;
|
|
61
|
+
if (!input) return;
|
|
62
|
+
input.focus();
|
|
63
|
+
// THE WHOLE VALUE IS SELECTED WHEN THE EDITOR OPENS, so typing REPLACES
|
|
64
|
+
// it — every number field in every DCC tool. Focused without a
|
|
65
|
+
// selection, a typed number was spliced into the old text and committed
|
|
66
|
+
// as garbage: click the field showing -5, type -7, Enter → -47;
|
|
67
|
+
// double-click, type -9 → -459 (measured on the GPU instrument,
|
|
68
|
+
// 2026-09-03). That is the "everything's there, just not where I wanted
|
|
69
|
+
// it" and "it jumped" of runhuman passes 122 and 127 — the author typed
|
|
70
|
+
// one number and the field committed another.
|
|
71
|
+
input.select();
|
|
72
|
+
}, [editing]);
|
|
73
|
+
|
|
74
|
+
const isMixed = value === null;
|
|
75
|
+
const numValue = value ?? 0;
|
|
76
|
+
const display = isMixed
|
|
77
|
+
? '—'
|
|
78
|
+
: Number.isFinite(numValue)
|
|
79
|
+
? numValue.toFixed(precision)
|
|
80
|
+
: (0).toFixed(precision);
|
|
81
|
+
|
|
82
|
+
const handlePointerDown = (e: React.PointerEvent) => {
|
|
83
|
+
if (disabled) return;
|
|
84
|
+
dragRef.current = {
|
|
85
|
+
startX: e.clientX,
|
|
86
|
+
startVal: numValue,
|
|
87
|
+
dragging: false,
|
|
88
|
+
gesture: false,
|
|
89
|
+
lastVal: numValue,
|
|
90
|
+
};
|
|
91
|
+
const onMove = (me: PointerEvent) => {
|
|
92
|
+
const dx = me.clientX - dragRef.current.startX;
|
|
93
|
+
if (Math.abs(dx) > 3) dragRef.current.dragging = true;
|
|
94
|
+
if (dragRef.current.dragging) {
|
|
95
|
+
// Coalesce the whole scrub into a single undo step: open the gesture
|
|
96
|
+
// just before the first value change (so the snapshot is pre-drag).
|
|
97
|
+
if (!dragRef.current.gesture) {
|
|
98
|
+
dragRef.current.gesture = true;
|
|
99
|
+
editScope?.begin();
|
|
100
|
+
}
|
|
101
|
+
const newVal = dragRef.current.startVal + dx * step;
|
|
102
|
+
const rounded = Math.round(newVal / step) * step;
|
|
103
|
+
dragRef.current.lastVal = rounded;
|
|
104
|
+
onChange(rounded);
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
const onUp = () => {
|
|
108
|
+
window.removeEventListener('pointermove', onMove);
|
|
109
|
+
window.removeEventListener('pointerup', onUp);
|
|
110
|
+
if (dragRef.current.gesture) editScope?.end();
|
|
111
|
+
if (dragRef.current.dragging) {
|
|
112
|
+
// One commit for the whole gesture (#3) — `onChange` above already
|
|
113
|
+
// fired per pointermove for callers that want live feedback; this is
|
|
114
|
+
// the single "the user is done" signal, with the drag's final value.
|
|
115
|
+
onChangeEnd?.(dragRef.current.lastVal);
|
|
116
|
+
} else {
|
|
117
|
+
setEditing(true);
|
|
118
|
+
setText(isMixed ? '' : display);
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
window.addEventListener('pointermove', onMove);
|
|
122
|
+
window.addEventListener('pointerup', onUp);
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const commitEdit = () => {
|
|
126
|
+
setEditing(false);
|
|
127
|
+
const n = Number.parseFloat(text);
|
|
128
|
+
if (Number.isFinite(n)) {
|
|
129
|
+
onChange(n);
|
|
130
|
+
onChangeEnd?.(n);
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
return (
|
|
135
|
+
<div className="vgai-number-input" data-testid={testId} title={title} style={style}>
|
|
136
|
+
{label && (
|
|
137
|
+
<span
|
|
138
|
+
className="vgai-number-input-label"
|
|
139
|
+
style={labelColor ? { color: labelColor } : undefined}
|
|
140
|
+
>
|
|
141
|
+
{label}
|
|
142
|
+
</span>
|
|
143
|
+
)}
|
|
144
|
+
{editing ? (
|
|
145
|
+
<TextInput
|
|
146
|
+
ref={inputRef}
|
|
147
|
+
value={text}
|
|
148
|
+
onChange={(e) => setText(e.target.value)}
|
|
149
|
+
onBlur={commitEdit}
|
|
150
|
+
onKeyDown={(e) => {
|
|
151
|
+
if (e.key === 'Enter') commitEdit();
|
|
152
|
+
if (e.key === 'Escape') setEditing(false);
|
|
153
|
+
}}
|
|
154
|
+
placeholder={isMixed ? '—' : undefined}
|
|
155
|
+
className="vgai-number-input-editor"
|
|
156
|
+
/>
|
|
157
|
+
) : (
|
|
158
|
+
<div
|
|
159
|
+
onPointerDown={handlePointerDown}
|
|
160
|
+
className="vgai-number-input-scrub"
|
|
161
|
+
data-mixed={isMixed || undefined}
|
|
162
|
+
data-disabled={disabled || undefined}
|
|
163
|
+
>
|
|
164
|
+
{display}
|
|
165
|
+
</div>
|
|
166
|
+
)}
|
|
167
|
+
</div>
|
|
168
|
+
);
|
|
169
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type React from 'react';
|
|
2
|
+
import { space } from '../theme';
|
|
3
|
+
import { panelHeaderStyle } from './panel-header-styles';
|
|
4
|
+
|
|
5
|
+
interface PanelOwnProps {
|
|
6
|
+
name: React.ReactNode;
|
|
7
|
+
actions?: React.ReactNode;
|
|
8
|
+
onHeaderClick?: () => void;
|
|
9
|
+
/** The dock host already names permanent panels in its tab strip. Hide the
|
|
10
|
+
* legacy embedded header there to avoid two consecutive titles for one
|
|
11
|
+
* surface while retaining Panel's content/layout behavior. */
|
|
12
|
+
hideHeader?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type PanelProps = PanelOwnProps &
|
|
16
|
+
Omit<React.HTMLAttributes<HTMLDivElement>, keyof PanelOwnProps> & {
|
|
17
|
+
/** React 19 ref-as-prop, forwarded onto the panel's own scroll/root div. */
|
|
18
|
+
ref?: React.Ref<HTMLDivElement>;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function Panel({
|
|
22
|
+
name,
|
|
23
|
+
actions,
|
|
24
|
+
onHeaderClick,
|
|
25
|
+
hideHeader = false,
|
|
26
|
+
style,
|
|
27
|
+
children,
|
|
28
|
+
...rest
|
|
29
|
+
}: PanelProps) {
|
|
30
|
+
return (
|
|
31
|
+
<div
|
|
32
|
+
{...rest}
|
|
33
|
+
style={{
|
|
34
|
+
// No surface fill of its own (§2.31 transparent-interiors wave): the
|
|
35
|
+
// hosting dock group / floating card layer carries the themed panel
|
|
36
|
+
// surface. A Panel that floats free of any painted surface opts into
|
|
37
|
+
// one locally via `style` (see the design-system stories).
|
|
38
|
+
display: 'flex',
|
|
39
|
+
flexDirection: 'column',
|
|
40
|
+
pointerEvents: 'auto',
|
|
41
|
+
...style,
|
|
42
|
+
}}
|
|
43
|
+
>
|
|
44
|
+
{!hideHeader && (
|
|
45
|
+
<div
|
|
46
|
+
role={onHeaderClick ? 'button' : undefined}
|
|
47
|
+
tabIndex={onHeaderClick ? 0 : undefined}
|
|
48
|
+
onClick={onHeaderClick}
|
|
49
|
+
onKeyDown={
|
|
50
|
+
onHeaderClick
|
|
51
|
+
? (e) => {
|
|
52
|
+
if (e.key === 'Enter') onHeaderClick();
|
|
53
|
+
}
|
|
54
|
+
: undefined
|
|
55
|
+
}
|
|
56
|
+
style={{
|
|
57
|
+
...panelHeaderStyle,
|
|
58
|
+
cursor: onHeaderClick ? 'pointer' : undefined,
|
|
59
|
+
pointerEvents: 'auto',
|
|
60
|
+
}}
|
|
61
|
+
>
|
|
62
|
+
<span style={{ flexShrink: 0 }}>{name}</span>
|
|
63
|
+
{actions && (
|
|
64
|
+
<div
|
|
65
|
+
style={{
|
|
66
|
+
marginLeft: 'auto',
|
|
67
|
+
display: 'flex',
|
|
68
|
+
alignItems: 'center',
|
|
69
|
+
gap: space[3],
|
|
70
|
+
}}
|
|
71
|
+
>
|
|
72
|
+
{actions}
|
|
73
|
+
</div>
|
|
74
|
+
)}
|
|
75
|
+
</div>
|
|
76
|
+
)}
|
|
77
|
+
{children}
|
|
78
|
+
</div>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { IconDefinition } from '@fortawesome/free-solid-svg-icons';
|
|
2
|
+
import { type ReactNode, useState } from 'react';
|
|
3
|
+
import { DisclosureIcon, EditorIcon } from './EditorIcon';
|
|
4
|
+
|
|
5
|
+
interface SectionHeaderProps {
|
|
6
|
+
label: string;
|
|
7
|
+
/** The section's glyph, beside the disclosure arrow. Every inspection
|
|
8
|
+
* section carries one (`inspection/model.ts` makes `icon` required), so a
|
|
9
|
+
* section reads the same in the stacked column as it does on the compact
|
|
10
|
+
* card's icon strip. */
|
|
11
|
+
icon?: IconDefinition;
|
|
12
|
+
description?: ReactNode;
|
|
13
|
+
actions?: ReactNode;
|
|
14
|
+
/**
|
|
15
|
+
* `false` heads the section without a disclosure control and keeps it open.
|
|
16
|
+
* Collapsing is what a STACK is for — hiding one block to reach the next.
|
|
17
|
+
* Where a single section is showing because you pressed its tab, a triangle
|
|
18
|
+
* offers to hide the only thing in the box (owner, 2026-08-07: "in small
|
|
19
|
+
* mode there's no reason for a collapsible triangle on the section name").
|
|
20
|
+
*/
|
|
21
|
+
collapsible?: boolean;
|
|
22
|
+
defaultOpen?: boolean;
|
|
23
|
+
/* The disclosure's size is `--vgai-section-disclosure-size` where a surface
|
|
24
|
+
* sets one, and `--vgai-icon-xs` — `DisclosureIcon`'s own default rung —
|
|
25
|
+
* where none does, so a host that declares nothing renders exactly what it
|
|
26
|
+
* rendered before. The one declarer is the PROPERTIES presentation
|
|
27
|
+
* (`compact-inspector.css`), where Blender's band mark measures 5.5x9.5 CSS
|
|
28
|
+
* against the 4x6 this rung draws. */
|
|
29
|
+
children: ReactNode;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function SectionHeader({
|
|
33
|
+
label,
|
|
34
|
+
icon,
|
|
35
|
+
description,
|
|
36
|
+
actions,
|
|
37
|
+
collapsible = true,
|
|
38
|
+
defaultOpen = true,
|
|
39
|
+
children,
|
|
40
|
+
}: SectionHeaderProps) {
|
|
41
|
+
const [open, setOpen] = useState(defaultOpen);
|
|
42
|
+
const title = (
|
|
43
|
+
<span className="vgai-section-title">
|
|
44
|
+
<span>{label}</span>
|
|
45
|
+
{description ? <small>{description}</small> : null}
|
|
46
|
+
</span>
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<section className="vgai-section">
|
|
51
|
+
<div className="vgai-section-header">
|
|
52
|
+
{collapsible ? (
|
|
53
|
+
<button
|
|
54
|
+
type="button"
|
|
55
|
+
className="vgai-section-trigger"
|
|
56
|
+
aria-expanded={open}
|
|
57
|
+
onClick={() => setOpen(!open)}
|
|
58
|
+
>
|
|
59
|
+
<DisclosureIcon
|
|
60
|
+
direction={open ? 'down' : 'right'}
|
|
61
|
+
style={{ fontSize: 'var(--vgai-section-disclosure-size, var(--vgai-icon-xs))' }}
|
|
62
|
+
/>
|
|
63
|
+
{icon && <EditorIcon icon={icon} tone="dim" className="vgai-section-glyph" />}
|
|
64
|
+
{title}
|
|
65
|
+
</button>
|
|
66
|
+
) : (
|
|
67
|
+
<div className="vgai-section-trigger" data-vgai-static>
|
|
68
|
+
{icon && <EditorIcon icon={icon} tone="dim" className="vgai-section-glyph" />}
|
|
69
|
+
{title}
|
|
70
|
+
</div>
|
|
71
|
+
)}
|
|
72
|
+
{actions ? <div className="vgai-section-actions">{actions}</div> : null}
|
|
73
|
+
</div>
|
|
74
|
+
{(open || !collapsible) && <div className="vgai-section-body">{children}</div>}
|
|
75
|
+
</section>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { type ComponentType, type ElementType, forwardRef, type HTMLAttributes } from 'react';
|
|
2
|
+
|
|
3
|
+
export type TextVariant = 'caption' | 'body' | 'label' | 'title' | 'heading' | 'display' | 'code';
|
|
4
|
+
export type TextTone = 'primary' | 'muted' | 'dim' | 'accent' | 'danger' | 'warning' | 'success';
|
|
5
|
+
|
|
6
|
+
export interface TextProps extends HTMLAttributes<HTMLElement> {
|
|
7
|
+
as?: ElementType;
|
|
8
|
+
variant?: TextVariant;
|
|
9
|
+
tone?: TextTone;
|
|
10
|
+
truncate?: boolean;
|
|
11
|
+
selectable?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function classes(base: string, className?: string): string {
|
|
15
|
+
return className ? `${base} ${className}` : base;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Semantic editor typography. Product surfaces choose a role, never a px size. */
|
|
19
|
+
export const Text = forwardRef<HTMLElement, TextProps>(function Text(
|
|
20
|
+
{
|
|
21
|
+
as: Component = 'span',
|
|
22
|
+
variant = 'body',
|
|
23
|
+
tone = 'primary',
|
|
24
|
+
truncate = false,
|
|
25
|
+
selectable = false,
|
|
26
|
+
className,
|
|
27
|
+
...props
|
|
28
|
+
},
|
|
29
|
+
ref,
|
|
30
|
+
) {
|
|
31
|
+
// `as` is polymorphic, so TS intersects props across EVERY member of
|
|
32
|
+
// `ElementType`. That intersection collapses to `never` the moment the JSX
|
|
33
|
+
// namespace grows — which it now does: the R3F design session pulls
|
|
34
|
+
// `@react-three/fiber`'s `IntrinsicElements` augmentation into the editor's
|
|
35
|
+
// program, and `ref`/`className` stopped being assignable here with no
|
|
36
|
+
// change to this file. Narrow the callee to the prop shape this component
|
|
37
|
+
// actually documents in `TextProps`.
|
|
38
|
+
const Element = Component as ComponentType<Record<string, unknown>>;
|
|
39
|
+
return (
|
|
40
|
+
<Element
|
|
41
|
+
{...props}
|
|
42
|
+
ref={ref}
|
|
43
|
+
data-variant={variant}
|
|
44
|
+
data-tone={tone}
|
|
45
|
+
data-truncate={truncate || undefined}
|
|
46
|
+
data-selectable={selectable || undefined}
|
|
47
|
+
className={classes('vgai-text', className)}
|
|
48
|
+
/>
|
|
49
|
+
);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
export function Keycap({ className, ...props }: HTMLAttributes<HTMLElement>) {
|
|
53
|
+
return <kbd {...props} className={classes('vgai-keycap', className)} />;
|
|
54
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { type ReactNode, useId, useLayoutEffect, useRef, useState } from 'react';
|
|
2
|
+
import { createPortal } from 'react-dom';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Portal children to the nearest `.vgai-editor-theme` root — the same target
|
|
6
|
+
* resolution `Tooltip` uses (`Tooltip.tsx`), extracted for `position: fixed`
|
|
7
|
+
* overlays that open from inside a panel (context menus).
|
|
8
|
+
*
|
|
9
|
+
* Why this exists (Glass-UI spike, W6 — work item 6): `backdrop-filter` (like
|
|
10
|
+
* `filter`/`transform`) makes an element the containing block for ALL
|
|
11
|
+
* positioned descendants, including `position: fixed`. An inline
|
|
12
|
+
* fixed-position menu inside a treated floating card would position relative
|
|
13
|
+
* to the card — at the wrong place — and be clipped by the card's `overflow:
|
|
14
|
+
* hidden`. The theme root carries no filter, so a menu portaled here keeps
|
|
15
|
+
* viewport-correct client coordinates under every workspace layout and
|
|
16
|
+
* theme treatment.
|
|
17
|
+
*
|
|
18
|
+
* THE PORTAL RECORDS WHERE IT CAME FROM, and that is what makes a portaled
|
|
19
|
+
* overlay reachable through the surface that opened it. The anchor span below
|
|
20
|
+
* already sits IN PLACE — it is how the theme root is found — so the two ends
|
|
21
|
+
* carry the same generated id: `data-vgai-portal` where the portal was
|
|
22
|
+
* written, `data-vgai-portal-content` on what it wrote. `editor-document-probe`
|
|
23
|
+
* walks that pair, so `editor.document.click` can drive a menu a DOCUMENT
|
|
24
|
+
* opened without being able to reach one the editor's own chrome opened. Its
|
|
25
|
+
* own header used to say the DOM records no ownership and that any heuristic
|
|
26
|
+
* wide enough would over-reach; this is the ownership, stated rather than
|
|
27
|
+
* guessed. A `display: contents` wrapper generates no box, so nothing about
|
|
28
|
+
* where a `position: fixed` child lands changes.
|
|
29
|
+
*/
|
|
30
|
+
export function ThemeRootPortal({ children }: { children: ReactNode }) {
|
|
31
|
+
const anchorRef = useRef<HTMLSpanElement>(null);
|
|
32
|
+
const [portalRoot, setPortalRoot] = useState<Element | null>(null);
|
|
33
|
+
const id = useId();
|
|
34
|
+
|
|
35
|
+
useLayoutEffect(() => {
|
|
36
|
+
setPortalRoot(anchorRef.current?.closest('.vgai-editor-theme') ?? null);
|
|
37
|
+
}, []);
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<>
|
|
41
|
+
<span ref={anchorRef} data-vgai-portal={id} style={{ display: 'none' }} />
|
|
42
|
+
{portalRoot
|
|
43
|
+
? createPortal(
|
|
44
|
+
<div data-vgai-portal-content={id} style={{ display: 'contents' }}>
|
|
45
|
+
{children}
|
|
46
|
+
</div>,
|
|
47
|
+
portalRoot,
|
|
48
|
+
)
|
|
49
|
+
: null}
|
|
50
|
+
</>
|
|
51
|
+
);
|
|
52
|
+
}
|