@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,78 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { useInteractiveEditScope } from '../interactive-edit-scope';
|
|
3
|
+
import { ColorSwatchInput, TextInput } from './FormControls';
|
|
4
|
+
|
|
5
|
+
interface ColorInputProps {
|
|
6
|
+
value: string | null;
|
|
7
|
+
onChange: (value: string) => void;
|
|
8
|
+
/** Read-only: swatch and hex field both refuse input. Optional — existing callers unchanged. */
|
|
9
|
+
disabled?: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function ColorInput({ value, onChange, disabled = false }: ColorInputProps) {
|
|
13
|
+
// Local draft state for the text input — only commits on blur or Enter.
|
|
14
|
+
const [draft, setDraft] = useState(value ?? '');
|
|
15
|
+
const editScope = useInteractiveEditScope();
|
|
16
|
+
|
|
17
|
+
// Sync draft when the external value changes (e.g. undo, selection change).
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
setDraft(value ?? '');
|
|
20
|
+
}, [value]);
|
|
21
|
+
|
|
22
|
+
const commit = () => {
|
|
23
|
+
const trimmed = draft.trim();
|
|
24
|
+
if (trimmed && trimmed !== value) {
|
|
25
|
+
onChange(trimmed);
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
if (value === null) {
|
|
30
|
+
return (
|
|
31
|
+
<div className="vgai-color-input">
|
|
32
|
+
<div className="vgai-color-input-mixed" />
|
|
33
|
+
<TextInput
|
|
34
|
+
// Mixed-value language unified on the em-dash (I-15), matching
|
|
35
|
+
// NumberInput's mixed display.
|
|
36
|
+
placeholder="—"
|
|
37
|
+
disabled={disabled}
|
|
38
|
+
onChange={(e) => {
|
|
39
|
+
if (e.target.value) onChange(e.target.value);
|
|
40
|
+
}}
|
|
41
|
+
data-mixed="true"
|
|
42
|
+
/>
|
|
43
|
+
</div>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<div className="vgai-color-input">
|
|
49
|
+
<ColorSwatchInput
|
|
50
|
+
value={value}
|
|
51
|
+
disabled={disabled}
|
|
52
|
+
// The native picker fires onChange continuously while dragging. Coalesce
|
|
53
|
+
// the whole drag into one undo step (beginInteractiveEdit is idempotent);
|
|
54
|
+
// the gesture closes on blur when the picker is dismissed.
|
|
55
|
+
onChange={(e) => {
|
|
56
|
+
editScope?.begin();
|
|
57
|
+
onChange(e.target.value);
|
|
58
|
+
}}
|
|
59
|
+
onBlur={() => editScope?.end()}
|
|
60
|
+
/>
|
|
61
|
+
<TextInput
|
|
62
|
+
value={draft}
|
|
63
|
+
disabled={disabled}
|
|
64
|
+
onChange={(e) => setDraft(e.target.value)}
|
|
65
|
+
onBlur={commit}
|
|
66
|
+
onKeyDown={(e) => {
|
|
67
|
+
if (e.key === 'Enter') {
|
|
68
|
+
commit();
|
|
69
|
+
(e.target as HTMLInputElement).blur();
|
|
70
|
+
} else if (e.key === 'Escape') {
|
|
71
|
+
setDraft(value);
|
|
72
|
+
(e.target as HTMLInputElement).blur();
|
|
73
|
+
}
|
|
74
|
+
}}
|
|
75
|
+
/>
|
|
76
|
+
</div>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A text field whose typing is LOCAL until Enter or blur — the discipline
|
|
3
|
+
* `NumberInput` uses for its typed edits, lifted out for every Inspector row
|
|
4
|
+
* whose `onChange` is a SOURCE WRITE.
|
|
5
|
+
*
|
|
6
|
+
* WHY. A source write remounts the world, and a controlled input that
|
|
7
|
+
* commits per keystroke re-renders from the in-flight value under the
|
|
8
|
+
* typist: the second digit of "50" lands on a field that already reverted to
|
|
9
|
+
* "5" (runhuman pass 49, Grid Size "takes half the value"), a deletion in the
|
|
10
|
+
* name field "would not take" (pass 45). Commit once, on Enter/blur; Escape
|
|
11
|
+
* cancels. While not focused the field tracks the live value, so an external
|
|
12
|
+
* change still shows.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { type InputHTMLAttributes, useRef, useState } from 'react';
|
|
16
|
+
import { TextInput } from './FormControls';
|
|
17
|
+
|
|
18
|
+
export interface DraftTextInputProps
|
|
19
|
+
extends Omit<InputHTMLAttributes<HTMLInputElement>, 'value' | 'onChange' | 'onBlur'> {
|
|
20
|
+
/** The live value, shown whenever the field is not being edited. */
|
|
21
|
+
readonly value: string;
|
|
22
|
+
/** Fires ONCE per committed edit (Enter or blur) with the final text. Not
|
|
23
|
+
* called when the text is unchanged. */
|
|
24
|
+
readonly onCommit: (next: string) => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function DraftTextInput({
|
|
28
|
+
value,
|
|
29
|
+
onCommit,
|
|
30
|
+
onKeyDown,
|
|
31
|
+
onFocus,
|
|
32
|
+
...rest
|
|
33
|
+
}: DraftTextInputProps) {
|
|
34
|
+
const [draft, setDraft] = useState<string | null>(null);
|
|
35
|
+
// Escape blurs, and blur commits — the flag lets the cancel win over the
|
|
36
|
+
// stale draft the blur handler still closes over.
|
|
37
|
+
const cancelled = useRef(false);
|
|
38
|
+
const commit = (): void => {
|
|
39
|
+
if (!cancelled.current && draft !== null && draft !== value) onCommit(draft);
|
|
40
|
+
cancelled.current = false;
|
|
41
|
+
setDraft(null);
|
|
42
|
+
};
|
|
43
|
+
return (
|
|
44
|
+
<TextInput
|
|
45
|
+
{...rest}
|
|
46
|
+
value={draft ?? value}
|
|
47
|
+
onFocus={(event) => {
|
|
48
|
+
setDraft(value);
|
|
49
|
+
onFocus?.(event);
|
|
50
|
+
}}
|
|
51
|
+
onChange={(event) => setDraft(event.target.value)}
|
|
52
|
+
onBlur={commit}
|
|
53
|
+
onKeyDown={(event) => {
|
|
54
|
+
if (event.key === 'Enter') (event.target as HTMLInputElement).blur();
|
|
55
|
+
if (event.key === 'Escape') {
|
|
56
|
+
cancelled.current = true;
|
|
57
|
+
(event.target as HTMLInputElement).blur();
|
|
58
|
+
}
|
|
59
|
+
onKeyDown?.(event);
|
|
60
|
+
}}
|
|
61
|
+
/>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import type { IconDefinition } from '@fortawesome/fontawesome-svg-core';
|
|
2
|
+
import {
|
|
3
|
+
faCaretDown,
|
|
4
|
+
faCaretRight,
|
|
5
|
+
faChevronDown,
|
|
6
|
+
faChevronRight,
|
|
7
|
+
} from '@fortawesome/free-solid-svg-icons';
|
|
8
|
+
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|
9
|
+
import type React from 'react';
|
|
10
|
+
import { useSyncExternalStore } from 'react';
|
|
11
|
+
import { activeIconGlyph, activeIconSetSnapshot, subscribeIconSets } from '../icon-set-registry';
|
|
12
|
+
import { themeVars } from '../theme';
|
|
13
|
+
|
|
14
|
+
export type EditorIconSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl';
|
|
15
|
+
export type EditorIconTone = 'current' | 'primary' | 'muted' | 'dim' | 'accent' | 'danger';
|
|
16
|
+
|
|
17
|
+
// A glyph's size is the ICON scale (`theme.ts`'s `iconSize`), not the type
|
|
18
|
+
// scale: these sizes used to be the font's rungs, so a skin that tightened its
|
|
19
|
+
// text shrank every glyph with it — under Blender's density the chrome drew
|
|
20
|
+
// 10–11 px glyphs where Blender draws 14.
|
|
21
|
+
const SIZE: Record<EditorIconSize, string> = {
|
|
22
|
+
xs: 'var(--vgai-icon-xs)',
|
|
23
|
+
sm: 'var(--vgai-icon-sm)',
|
|
24
|
+
md: 'var(--vgai-icon-md)',
|
|
25
|
+
lg: 'var(--vgai-icon-lg)',
|
|
26
|
+
xl: 'var(--vgai-icon-xl)',
|
|
27
|
+
'2xl': 'var(--vgai-icon-2xl)',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const TONE: Record<EditorIconTone, string | undefined> = {
|
|
31
|
+
current: undefined,
|
|
32
|
+
primary: themeVars.content.primary,
|
|
33
|
+
muted: themeVars.content.muted,
|
|
34
|
+
dim: themeVars.content.dim,
|
|
35
|
+
accent: themeVars.accent.default,
|
|
36
|
+
danger: themeVars.semantic.danger,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export interface EditorIconProps extends Omit<React.HTMLAttributes<SVGSVGElement>, 'color'> {
|
|
40
|
+
icon: IconDefinition;
|
|
41
|
+
size?: EditorIconSize;
|
|
42
|
+
tone?: EditorIconTone;
|
|
43
|
+
/** Icons inside already-labelled controls stay decorative by default. */
|
|
44
|
+
label?: string;
|
|
45
|
+
spin?: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Canonical editor-chrome icon metrics and accessibility behavior. */
|
|
49
|
+
export function EditorIcon({
|
|
50
|
+
icon,
|
|
51
|
+
size = 'sm',
|
|
52
|
+
tone = 'current',
|
|
53
|
+
label,
|
|
54
|
+
className,
|
|
55
|
+
style,
|
|
56
|
+
spin = false,
|
|
57
|
+
...svgProps
|
|
58
|
+
}: EditorIconProps) {
|
|
59
|
+
// The active icon set (`icon-set-registry.ts`): a glyph it carries for this
|
|
60
|
+
// name paints in Font Awesome's box (1.25em fixed width, 1em tall, the
|
|
61
|
+
// -0.125em baseline) so no site notices which set is on.
|
|
62
|
+
useSyncExternalStore(subscribeIconSets, activeIconSetSnapshot, activeIconSetSnapshot);
|
|
63
|
+
const glyph = activeIconGlyph(icon.iconName);
|
|
64
|
+
if (glyph) {
|
|
65
|
+
// THE GLYPH'S OWN COLOUR, and who wins. A site that names a tone has
|
|
66
|
+
// said something about THIS instance — de-emphasis (`dim`/`muted` on a
|
|
67
|
+
// quiet rail), status (`danger`) — and that always wins; `current` is the
|
|
68
|
+
// default, i.e. the site said nothing, so the glyph's category speaks.
|
|
69
|
+
const siteToned = tone !== 'current';
|
|
70
|
+
const categoryInk = glyph.tone ? `var(--vgai-category-${glyph.tone}, currentColor)` : undefined;
|
|
71
|
+
// With a `tonedPath` the category tints only that path and the body stays
|
|
72
|
+
// `currentColor` — Blender's operator marks tint the OPERATED element and
|
|
73
|
+
// leave the cube neutral. Without one, the category is the whole glyph's
|
|
74
|
+
// ink, so it rides `color` and every path inherits it.
|
|
75
|
+
const svgColor = siteToned ? TONE[tone] : glyph.tonedPath ? undefined : categoryInk;
|
|
76
|
+
const tonedFill = siteToned ? 'currentColor' : (categoryInk ?? 'currentColor');
|
|
77
|
+
return (
|
|
78
|
+
<svg
|
|
79
|
+
{...svgProps}
|
|
80
|
+
viewBox={glyph.viewBox ?? '0 0 16 16'}
|
|
81
|
+
// THE SITE'S HALF OF THE INK. A category token is the glyph's colour
|
|
82
|
+
// at FULL opacity (`theme.ts`'s category docblock) and Blender's own
|
|
83
|
+
// alpha belongs to the SITE — 0.80 in the Outliner and on an inactive
|
|
84
|
+
// Properties tab, 1.00 on the open tab and in the tool shelf. A site
|
|
85
|
+
// can only apply that to the glyphs it actually tints, so the tone
|
|
86
|
+
// rides out as a DOM marker: a set with no tone for this name (Font
|
|
87
|
+
// Awesome's, every Classic surface) carries no attribute and no rule
|
|
88
|
+
// keyed on it can reach it.
|
|
89
|
+
data-vgai-tone={siteToned ? undefined : glyph.tone}
|
|
90
|
+
className={className}
|
|
91
|
+
aria-hidden={label ? undefined : (svgProps['aria-hidden'] ?? 'true')}
|
|
92
|
+
aria-label={label}
|
|
93
|
+
role={label ? 'img' : undefined}
|
|
94
|
+
style={{
|
|
95
|
+
width: '1.25em',
|
|
96
|
+
height: '1em',
|
|
97
|
+
overflow: 'visible',
|
|
98
|
+
verticalAlign: '-0.125em',
|
|
99
|
+
fontSize: SIZE[size],
|
|
100
|
+
color: svgColor,
|
|
101
|
+
...style,
|
|
102
|
+
}}
|
|
103
|
+
>
|
|
104
|
+
<path d={glyph.path} fill="currentColor" />
|
|
105
|
+
{glyph.tonedPath ? <path d={glyph.tonedPath} fill={tonedFill} /> : null}
|
|
106
|
+
</svg>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
return (
|
|
110
|
+
<FontAwesomeIcon
|
|
111
|
+
{...svgProps}
|
|
112
|
+
icon={icon}
|
|
113
|
+
fixedWidth
|
|
114
|
+
spin={spin}
|
|
115
|
+
className={className}
|
|
116
|
+
aria-hidden={label ? undefined : (svgProps['aria-hidden'] ?? 'true')}
|
|
117
|
+
aria-label={label}
|
|
118
|
+
role={label ? 'img' : undefined}
|
|
119
|
+
style={{ fontSize: SIZE[size], color: TONE[tone], ...style }}
|
|
120
|
+
/>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function DisclosureIcon({
|
|
125
|
+
direction,
|
|
126
|
+
size = 'xs',
|
|
127
|
+
tone = 'current',
|
|
128
|
+
style,
|
|
129
|
+
}: {
|
|
130
|
+
direction: 'right' | 'down';
|
|
131
|
+
size?: EditorIconSize;
|
|
132
|
+
tone?: EditorIconTone;
|
|
133
|
+
style?: React.CSSProperties;
|
|
134
|
+
}) {
|
|
135
|
+
return (
|
|
136
|
+
<EditorIcon
|
|
137
|
+
icon={direction === 'right' ? faChevronRight : faChevronDown}
|
|
138
|
+
size={size}
|
|
139
|
+
tone={tone}
|
|
140
|
+
{...(style ? { style } : {})}
|
|
141
|
+
/>
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function TreeDisclosureIcon({
|
|
146
|
+
direction,
|
|
147
|
+
size = 'xs',
|
|
148
|
+
tone = 'current',
|
|
149
|
+
}: {
|
|
150
|
+
direction: 'right' | 'down';
|
|
151
|
+
size?: EditorIconSize;
|
|
152
|
+
tone?: EditorIconTone;
|
|
153
|
+
}) {
|
|
154
|
+
return (
|
|
155
|
+
<EditorIcon icon={direction === 'right' ? faCaretRight : faCaretDown} size={size} tone={tone} />
|
|
156
|
+
);
|
|
157
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import {
|
|
2
|
+
forwardRef,
|
|
3
|
+
type InputHTMLAttributes,
|
|
4
|
+
type SelectHTMLAttributes,
|
|
5
|
+
type TextareaHTMLAttributes,
|
|
6
|
+
} from 'react';
|
|
7
|
+
|
|
8
|
+
function classes(base: string, className?: string): string {
|
|
9
|
+
return className ? `${base} ${className}` : base;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type TextInputProps = InputHTMLAttributes<HTMLInputElement>;
|
|
13
|
+
|
|
14
|
+
export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(function TextInput(
|
|
15
|
+
{ className, type = 'text', ...props },
|
|
16
|
+
ref,
|
|
17
|
+
) {
|
|
18
|
+
return <input {...props} ref={ref} type={type} className={classes('vgai-input', className)} />;
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export type SelectProps = SelectHTMLAttributes<HTMLSelectElement>;
|
|
22
|
+
|
|
23
|
+
export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select(
|
|
24
|
+
{ className, ...props },
|
|
25
|
+
ref,
|
|
26
|
+
) {
|
|
27
|
+
return <select {...props} ref={ref} className={classes('vgai-select', className)} />;
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
export type TextAreaProps = TextareaHTMLAttributes<HTMLTextAreaElement>;
|
|
31
|
+
|
|
32
|
+
/** Canonical multiline editor field. Paint and interaction states live in theme.css. */
|
|
33
|
+
export const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(function TextArea(
|
|
34
|
+
{ className, ...props },
|
|
35
|
+
ref,
|
|
36
|
+
) {
|
|
37
|
+
return (
|
|
38
|
+
<textarea {...props} ref={ref} className={classes('vgai-input vgai-textarea', className)} />
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
export type CheckboxProps = Omit<InputHTMLAttributes<HTMLInputElement>, 'type'>;
|
|
43
|
+
|
|
44
|
+
export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(function Checkbox(
|
|
45
|
+
{ className, ...props },
|
|
46
|
+
ref,
|
|
47
|
+
) {
|
|
48
|
+
return (
|
|
49
|
+
<input {...props} ref={ref} type="checkbox" className={classes('vgai-checkbox', className)} />
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
export type ColorSwatchInputProps = Omit<InputHTMLAttributes<HTMLInputElement>, 'type'>;
|
|
54
|
+
|
|
55
|
+
/** Native color picker with editor-owned dimensions, border, and focus treatment. */
|
|
56
|
+
export const ColorSwatchInput = forwardRef<HTMLInputElement, ColorSwatchInputProps>(
|
|
57
|
+
function ColorSwatchInput({ className, ...props }, ref) {
|
|
58
|
+
return (
|
|
59
|
+
<input
|
|
60
|
+
{...props}
|
|
61
|
+
ref={ref}
|
|
62
|
+
type="color"
|
|
63
|
+
className={classes('vgai-color-swatch', className)}
|
|
64
|
+
/>
|
|
65
|
+
);
|
|
66
|
+
},
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
export type FileInputProps = Omit<InputHTMLAttributes<HTMLInputElement>, 'type'>;
|
|
70
|
+
|
|
71
|
+
/** File input kept native for platform behavior while sharing editor focus/disabled states. */
|
|
72
|
+
export const FileInput = forwardRef<HTMLInputElement, FileInputProps>(function FileInput(
|
|
73
|
+
{ className, ...props },
|
|
74
|
+
ref,
|
|
75
|
+
) {
|
|
76
|
+
return (
|
|
77
|
+
<input {...props} ref={ref} type="file" className={classes('vgai-file-input', className)} />
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
export type RangeInputProps = Omit<InputHTMLAttributes<HTMLInputElement>, 'type'>;
|
|
82
|
+
|
|
83
|
+
export const RangeInput = forwardRef<HTMLInputElement, RangeInputProps>(function RangeInput(
|
|
84
|
+
{ className, ...props },
|
|
85
|
+
ref,
|
|
86
|
+
) {
|
|
87
|
+
return <input {...props} ref={ref} type="range" className={classes('vgai-range', className)} />;
|
|
88
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HoverPreview — a rich, anchored preview that opens on hover or keyboard
|
|
3
|
+
* focus of its single trigger child, through {@link AnchoredMenu} (portaled,
|
|
4
|
+
* no focus trap, dismissed on leave/blur/Escape/outside press).
|
|
5
|
+
*
|
|
6
|
+
* This is the design-system's OWNED home for hover-triggered preview
|
|
7
|
+
* behavior, the same way {@link Tooltip} owns hover-triggered text tips:
|
|
8
|
+
* product surfaces declare WHAT previews (`content`), never wire
|
|
9
|
+
* `onMouseEnter`/`onMouseLeave` themselves — the migration guard forbids
|
|
10
|
+
* exactly those handlers outside `primitives/` because hand-rolled hover
|
|
11
|
+
* wiring is how focus parity (a keyboard user must get the same preview) and
|
|
12
|
+
* Escape semantics quietly fork per surface.
|
|
13
|
+
*
|
|
14
|
+
* Where {@link Tooltip} carries a string and a hotkey, HoverPreview carries
|
|
15
|
+
* an arbitrary body (a thumbnail card, a monospace excerpt). Escape closes
|
|
16
|
+
* the preview and STOPS there — the surface around it never also reads that
|
|
17
|
+
* Escape (the first consumer is the chat composer, where Escape would
|
|
18
|
+
* otherwise close the composer itself).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
cloneElement,
|
|
23
|
+
isValidElement,
|
|
24
|
+
type KeyboardEvent,
|
|
25
|
+
type ReactElement,
|
|
26
|
+
type ReactNode,
|
|
27
|
+
useId,
|
|
28
|
+
useRef,
|
|
29
|
+
useState,
|
|
30
|
+
} from 'react';
|
|
31
|
+
import { AnchoredMenu, type AnchoredMenuProps } from './AnchoredMenu';
|
|
32
|
+
|
|
33
|
+
export interface HoverPreviewProps
|
|
34
|
+
extends Omit<
|
|
35
|
+
AnchoredMenuProps,
|
|
36
|
+
'anchorRef' | 'children' | 'onDismiss' | 'id' | 'role' | 'content'
|
|
37
|
+
> {
|
|
38
|
+
/** The rich preview body, rendered inside the anchored surface. */
|
|
39
|
+
readonly preview: ReactNode;
|
|
40
|
+
/** The single trigger element the preview anchors to. */
|
|
41
|
+
readonly children: ReactElement<{ 'aria-describedby'?: string }>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function HoverPreview({ preview, children, ...menuProps }: HoverPreviewProps) {
|
|
45
|
+
const [open, setOpen] = useState(false);
|
|
46
|
+
const anchorRef = useRef<HTMLElement | null>(null);
|
|
47
|
+
const id = useId();
|
|
48
|
+
|
|
49
|
+
const close = () => setOpen(false);
|
|
50
|
+
const openAt = (anchor: HTMLElement) => {
|
|
51
|
+
anchorRef.current = anchor;
|
|
52
|
+
setOpen(true);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
let child = children;
|
|
56
|
+
if (isValidElement<{ 'aria-describedby'?: string }>(children)) {
|
|
57
|
+
const describedBy = [children.props['aria-describedby'], open ? id : null]
|
|
58
|
+
.filter(Boolean)
|
|
59
|
+
.join(' ');
|
|
60
|
+
child = cloneElement(children, describedBy ? { 'aria-describedby': describedBy } : {});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<span
|
|
65
|
+
className="vgai-tooltip-anchor"
|
|
66
|
+
onMouseEnter={(event) => openAt(event.currentTarget)}
|
|
67
|
+
onMouseLeave={close}
|
|
68
|
+
onFocusCapture={(event) => openAt(event.currentTarget)}
|
|
69
|
+
onBlurCapture={(event) => {
|
|
70
|
+
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) close();
|
|
71
|
+
}}
|
|
72
|
+
onKeyDown={(event: KeyboardEvent) => {
|
|
73
|
+
if (event.key !== 'Escape' || !open) return;
|
|
74
|
+
// The preview consumed the key; the surface around it must not also
|
|
75
|
+
// read this Escape (e.g. as "close the composer").
|
|
76
|
+
event.preventDefault();
|
|
77
|
+
event.stopPropagation();
|
|
78
|
+
close();
|
|
79
|
+
}}
|
|
80
|
+
>
|
|
81
|
+
{child}
|
|
82
|
+
{open && anchorRef.current && (
|
|
83
|
+
<AnchoredMenu
|
|
84
|
+
{...menuProps}
|
|
85
|
+
anchorRef={anchorRef}
|
|
86
|
+
id={id}
|
|
87
|
+
role="tooltip"
|
|
88
|
+
autoFocusFirst={false}
|
|
89
|
+
onDismiss={close}
|
|
90
|
+
>
|
|
91
|
+
{preview}
|
|
92
|
+
</AnchoredMenu>
|
|
93
|
+
)}
|
|
94
|
+
</span>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { themeVars } from '../theme';
|
|
3
|
+
import { TextInput } from './FormControls';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Type-preserving JSON input for the component inspector's raw path.
|
|
7
|
+
*
|
|
8
|
+
* Same invariant as DataPanel's `JsonCell` (W2 hardening, fd9f450c), kept as
|
|
9
|
+
* a separate primitive on purpose — the inspector writes the in-memory scene
|
|
10
|
+
* store, DataPanel writes files; the two systems stay uncoupled:
|
|
11
|
+
* **a commit must never widen a typed value into a JSON string.**
|
|
12
|
+
* `commit()` parses the draft and hands `onCommit` the PARSED value; on parse
|
|
13
|
+
* failure it REFUSES — draft kept, error shown, no write. Unlike the sibling
|
|
14
|
+
* keystroke-live inputs, this commits on blur/Enter only (mid-edit JSON is
|
|
15
|
+
* almost always invalid).
|
|
16
|
+
*/
|
|
17
|
+
export function JsonInput({
|
|
18
|
+
value,
|
|
19
|
+
onCommit,
|
|
20
|
+
style,
|
|
21
|
+
disabled = false,
|
|
22
|
+
}: {
|
|
23
|
+
value: unknown;
|
|
24
|
+
onCommit: (value: unknown) => void;
|
|
25
|
+
style?: React.CSSProperties;
|
|
26
|
+
/** Read-only: the field refuses input. Optional — existing callers unchanged. */
|
|
27
|
+
disabled?: boolean;
|
|
28
|
+
}) {
|
|
29
|
+
const display = value === undefined ? '' : JSON.stringify(value);
|
|
30
|
+
const [draft, setDraft] = useState(display);
|
|
31
|
+
const [invalid, setInvalid] = useState(false);
|
|
32
|
+
// Escape below calls `.blur()` imperatively to drop focus, which fires the
|
|
33
|
+
// native blur/focusout event SYNCHRONOUSLY, inside the same handler/render —
|
|
34
|
+
// so the resulting `onBlur={commit}` call closes over the PRE-revert
|
|
35
|
+
// `draft`/`display` (React hasn't re-rendered yet) and would re-derive
|
|
36
|
+
// `invalid: true` right after Escape just cleared it. This ref lets Escape
|
|
37
|
+
// tell the nested commit to no-op instead of racing it.
|
|
38
|
+
const revertingRef = useRef(false);
|
|
39
|
+
|
|
40
|
+
// Re-sync when the underlying value changes (undo, another widget, reload).
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
setDraft(display);
|
|
43
|
+
setInvalid(false);
|
|
44
|
+
}, [display]);
|
|
45
|
+
|
|
46
|
+
const commit = () => {
|
|
47
|
+
if (revertingRef.current) {
|
|
48
|
+
revertingRef.current = false;
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (draft === display) {
|
|
52
|
+
setInvalid(false);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
let parsed: unknown;
|
|
56
|
+
try {
|
|
57
|
+
parsed = JSON.parse(draft);
|
|
58
|
+
} catch {
|
|
59
|
+
setInvalid(true); // refuse: keep the draft, no onCommit, no write
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
setInvalid(false);
|
|
63
|
+
onCommit(parsed);
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<div>
|
|
68
|
+
<TextInput
|
|
69
|
+
data-testid="json-input"
|
|
70
|
+
// Self-styled baseline (I-28): the shared field class means this
|
|
71
|
+
// primitive is never silently unstyled when a caller passes no
|
|
72
|
+
// `style` — callers now only supply layout (width/flex).
|
|
73
|
+
aria-invalid={invalid || undefined}
|
|
74
|
+
data-tone={invalid ? 'danger' : undefined}
|
|
75
|
+
disabled={disabled}
|
|
76
|
+
value={draft}
|
|
77
|
+
onChange={(e) => {
|
|
78
|
+
setDraft(e.target.value);
|
|
79
|
+
if (invalid) setInvalid(false);
|
|
80
|
+
}}
|
|
81
|
+
onBlur={commit}
|
|
82
|
+
onKeyDown={(e) => {
|
|
83
|
+
if (e.key === 'Enter') {
|
|
84
|
+
commit();
|
|
85
|
+
} else if (e.key === 'Escape') {
|
|
86
|
+
revertingRef.current = true;
|
|
87
|
+
setDraft(display);
|
|
88
|
+
setInvalid(false);
|
|
89
|
+
(e.target as HTMLInputElement).blur();
|
|
90
|
+
}
|
|
91
|
+
}}
|
|
92
|
+
style={style}
|
|
93
|
+
title={
|
|
94
|
+
invalid
|
|
95
|
+
? 'Not valid JSON — nothing written. Fix the JSON or press Escape to revert.'
|
|
96
|
+
: undefined
|
|
97
|
+
}
|
|
98
|
+
/>
|
|
99
|
+
{invalid && (
|
|
100
|
+
<div
|
|
101
|
+
data-testid="json-input-error"
|
|
102
|
+
style={{
|
|
103
|
+
fontSize: 'var(--vgai-font-sm)',
|
|
104
|
+
color: themeVars.semantic.danger,
|
|
105
|
+
marginTop: 2,
|
|
106
|
+
}}
|
|
107
|
+
>
|
|
108
|
+
Not valid JSON — nothing written. Fix the JSON or press Escape to revert.
|
|
109
|
+
</div>
|
|
110
|
+
)}
|
|
111
|
+
</div>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { CSSProperties, HTMLAttributes, ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
export type LayoutGap = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 8 | 10 | 12;
|
|
4
|
+
export type LayoutAlignment = 'start' | 'center' | 'end' | 'stretch' | 'baseline';
|
|
5
|
+
export type LayoutJustification = 'start' | 'center' | 'end' | 'between' | 'around' | 'evenly';
|
|
6
|
+
|
|
7
|
+
interface LayoutProps extends HTMLAttributes<HTMLDivElement> {
|
|
8
|
+
gap?: LayoutGap;
|
|
9
|
+
align?: LayoutAlignment;
|
|
10
|
+
justify?: LayoutJustification;
|
|
11
|
+
padding?: LayoutGap;
|
|
12
|
+
paddingX?: LayoutGap;
|
|
13
|
+
paddingY?: LayoutGap;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const alignValue: Record<LayoutAlignment, CSSProperties['alignItems']> = {
|
|
17
|
+
start: 'flex-start',
|
|
18
|
+
center: 'center',
|
|
19
|
+
end: 'flex-end',
|
|
20
|
+
stretch: 'stretch',
|
|
21
|
+
baseline: 'baseline',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const justifyValue: Record<LayoutJustification, CSSProperties['justifyContent']> = {
|
|
25
|
+
start: 'flex-start',
|
|
26
|
+
center: 'center',
|
|
27
|
+
end: 'flex-end',
|
|
28
|
+
between: 'space-between',
|
|
29
|
+
around: 'space-around',
|
|
30
|
+
evenly: 'space-evenly',
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function token(value: LayoutGap | undefined): string | undefined {
|
|
34
|
+
return value === 0 ? '0px' : value === undefined ? undefined : `var(--vgai-space-${value})`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function layoutStyle({
|
|
38
|
+
gap,
|
|
39
|
+
align,
|
|
40
|
+
justify,
|
|
41
|
+
padding,
|
|
42
|
+
paddingX,
|
|
43
|
+
paddingY,
|
|
44
|
+
style,
|
|
45
|
+
}: LayoutProps): CSSProperties {
|
|
46
|
+
return {
|
|
47
|
+
gap: token(gap),
|
|
48
|
+
alignItems: align ? alignValue[align] : undefined,
|
|
49
|
+
justifyContent: justify ? justifyValue[justify] : undefined,
|
|
50
|
+
padding: token(padding),
|
|
51
|
+
paddingInline: token(paddingX),
|
|
52
|
+
paddingBlock: token(paddingY),
|
|
53
|
+
...style,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function classes(base: string, className?: string): string {
|
|
58
|
+
return className ? `${base} ${className}` : base;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function Stack({ className, ...props }: LayoutProps) {
|
|
62
|
+
return <div {...props} className={classes('vgai-stack', className)} style={layoutStyle(props)} />;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function Inline({ className, ...props }: LayoutProps) {
|
|
66
|
+
return (
|
|
67
|
+
<div {...props} className={classes('vgai-inline', className)} style={layoutStyle(props)} />
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function Spacer({ className, ...props }: HTMLAttributes<HTMLSpanElement>) {
|
|
72
|
+
return <span {...props} aria-hidden="true" className={classes('vgai-spacer', className)} />;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function Divider({
|
|
76
|
+
orientation = 'horizontal',
|
|
77
|
+
className,
|
|
78
|
+
...props
|
|
79
|
+
}: HTMLAttributes<HTMLDivElement> & { orientation?: 'horizontal' | 'vertical' }) {
|
|
80
|
+
return (
|
|
81
|
+
<div
|
|
82
|
+
{...props}
|
|
83
|
+
aria-hidden="true"
|
|
84
|
+
data-orientation={orientation}
|
|
85
|
+
className={classes('vgai-divider', className)}
|
|
86
|
+
/>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function Actions({
|
|
91
|
+
children,
|
|
92
|
+
className,
|
|
93
|
+
...props
|
|
94
|
+
}: HTMLAttributes<HTMLDivElement> & { children: ReactNode }) {
|
|
95
|
+
return (
|
|
96
|
+
<Inline {...props} className={classes('vgai-actions', className)} gap={2} align="center">
|
|
97
|
+
{children}
|
|
98
|
+
</Inline>
|
|
99
|
+
);
|
|
100
|
+
}
|