@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,122 @@
|
|
|
1
|
+
import { forwardRef, type HTMLAttributes, type ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
function classes(base: string, className?: string): string {
|
|
4
|
+
return className ? `${base} ${className}` : base;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export type EditorTone = 'info' | 'warning' | 'error' | 'success';
|
|
8
|
+
export type EditorSurfaceVariant = 'shell' | 'panel' | 'chrome' | 'raised' | 'inset' | 'overlay';
|
|
9
|
+
|
|
10
|
+
export interface EditorSurfaceProps extends HTMLAttributes<HTMLDivElement> {
|
|
11
|
+
variant?: EditorSurfaceVariant;
|
|
12
|
+
border?: boolean;
|
|
13
|
+
scroll?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function EditorSurface({
|
|
17
|
+
variant = 'panel',
|
|
18
|
+
border = false,
|
|
19
|
+
scroll = false,
|
|
20
|
+
className,
|
|
21
|
+
...props
|
|
22
|
+
}: EditorSurfaceProps) {
|
|
23
|
+
return (
|
|
24
|
+
<div
|
|
25
|
+
{...props}
|
|
26
|
+
data-variant={variant}
|
|
27
|
+
data-border={border || undefined}
|
|
28
|
+
data-scroll={scroll || undefined}
|
|
29
|
+
className={classes('vgai-surface', className)}
|
|
30
|
+
/>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface EditorBannerProps extends HTMLAttributes<HTMLDivElement> {
|
|
35
|
+
tone?: EditorTone;
|
|
36
|
+
icon?: ReactNode;
|
|
37
|
+
actions?: ReactNode;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function EditorBanner({
|
|
41
|
+
tone = 'info',
|
|
42
|
+
icon,
|
|
43
|
+
actions,
|
|
44
|
+
className,
|
|
45
|
+
children,
|
|
46
|
+
...props
|
|
47
|
+
}: EditorBannerProps) {
|
|
48
|
+
return (
|
|
49
|
+
<div
|
|
50
|
+
{...props}
|
|
51
|
+
role={tone === 'error' ? 'alert' : 'status'}
|
|
52
|
+
data-tone={tone}
|
|
53
|
+
className={classes('vgai-banner', className)}
|
|
54
|
+
>
|
|
55
|
+
{icon && <span className="vgai-banner-icon">{icon}</span>}
|
|
56
|
+
<div className="vgai-banner-content">{children}</div>
|
|
57
|
+
{actions && <div className="vgai-banner-actions">{actions}</div>}
|
|
58
|
+
</div>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const EditorPopover = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
|
|
63
|
+
function EditorPopover({ className, ...props }, ref) {
|
|
64
|
+
return <div ref={ref} {...props} className={classes('vgai-popover', className)} />;
|
|
65
|
+
},
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
export interface EditorBadgeProps extends HTMLAttributes<HTMLSpanElement> {
|
|
69
|
+
/** Labels are the safe default. Use count only for compact numeric counters. */
|
|
70
|
+
shape?: 'label' | 'count';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function EditorBadge({ shape = 'label', className, ...props }: EditorBadgeProps) {
|
|
74
|
+
return <span {...props} data-shape={shape} className={classes('vgai-badge', className)} />;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface DropIndicatorProps extends HTMLAttributes<HTMLDivElement> {
|
|
78
|
+
position?: 'before' | 'inside' | 'after';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function DropIndicator({ position = 'inside', className, ...props }: DropIndicatorProps) {
|
|
82
|
+
return (
|
|
83
|
+
<div
|
|
84
|
+
{...props}
|
|
85
|
+
aria-hidden="true"
|
|
86
|
+
data-position={position}
|
|
87
|
+
className={classes('vgai-drop-indicator', className)}
|
|
88
|
+
/>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface ResizeHandleProps extends HTMLAttributes<HTMLDivElement> {
|
|
93
|
+
orientation: 'horizontal' | 'vertical';
|
|
94
|
+
label: string;
|
|
95
|
+
valueNow?: number;
|
|
96
|
+
valueMin?: number;
|
|
97
|
+
valueMax?: number;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function ResizeHandle({
|
|
101
|
+
orientation,
|
|
102
|
+
label,
|
|
103
|
+
valueNow,
|
|
104
|
+
valueMin = 0,
|
|
105
|
+
valueMax = 100,
|
|
106
|
+
className,
|
|
107
|
+
...props
|
|
108
|
+
}: ResizeHandleProps) {
|
|
109
|
+
return (
|
|
110
|
+
<div
|
|
111
|
+
{...props}
|
|
112
|
+
role="separator"
|
|
113
|
+
aria-label={label}
|
|
114
|
+
aria-orientation={orientation}
|
|
115
|
+
aria-valuenow={valueNow}
|
|
116
|
+
aria-valuemin={valueNow === undefined ? undefined : valueMin}
|
|
117
|
+
aria-valuemax={valueNow === undefined ? undefined : valueMax}
|
|
118
|
+
data-orientation={orientation}
|
|
119
|
+
className={classes('vgai-resize-handle', className)}
|
|
120
|
+
/>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { type ButtonHTMLAttributes, forwardRef, type HTMLAttributes, type ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
function classes(base: string, className?: string): string {
|
|
4
|
+
return className ? `${base} ${className}` : base;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export const EditorTabList = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
|
|
8
|
+
function EditorTabList({ className, onKeyDown, ...props }, ref) {
|
|
9
|
+
return (
|
|
10
|
+
<div
|
|
11
|
+
{...props}
|
|
12
|
+
ref={ref}
|
|
13
|
+
role="tablist"
|
|
14
|
+
className={classes('vgai-tabs', className)}
|
|
15
|
+
onKeyDown={(event) => {
|
|
16
|
+
onKeyDown?.(event);
|
|
17
|
+
if (event.defaultPrevented) return;
|
|
18
|
+
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
|
|
19
|
+
const tabs = Array.from(
|
|
20
|
+
event.currentTarget.querySelectorAll<HTMLButtonElement>('[role="tab"]:not(:disabled)'),
|
|
21
|
+
);
|
|
22
|
+
if (tabs.length === 0) return;
|
|
23
|
+
event.preventDefault();
|
|
24
|
+
const current = tabs.indexOf(document.activeElement as HTMLButtonElement);
|
|
25
|
+
const next =
|
|
26
|
+
event.key === 'Home'
|
|
27
|
+
? tabs[0]
|
|
28
|
+
: event.key === 'End'
|
|
29
|
+
? tabs.at(-1)
|
|
30
|
+
: event.key === 'ArrowRight'
|
|
31
|
+
? tabs[(current + 1 + tabs.length) % tabs.length]
|
|
32
|
+
: tabs[(current - 1 + tabs.length) % tabs.length];
|
|
33
|
+
next?.focus();
|
|
34
|
+
}}
|
|
35
|
+
/>
|
|
36
|
+
);
|
|
37
|
+
},
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
export interface EditorTabProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
41
|
+
selected?: boolean;
|
|
42
|
+
dirty?: boolean;
|
|
43
|
+
badge?: ReactNode;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const EditorTab = forwardRef<HTMLButtonElement, EditorTabProps>(function EditorTab(
|
|
47
|
+
{
|
|
48
|
+
selected = false,
|
|
49
|
+
dirty = false,
|
|
50
|
+
badge,
|
|
51
|
+
className,
|
|
52
|
+
type = 'button',
|
|
53
|
+
tabIndex,
|
|
54
|
+
children,
|
|
55
|
+
...props
|
|
56
|
+
},
|
|
57
|
+
ref,
|
|
58
|
+
) {
|
|
59
|
+
return (
|
|
60
|
+
<button
|
|
61
|
+
{...props}
|
|
62
|
+
ref={ref}
|
|
63
|
+
type={type}
|
|
64
|
+
role="tab"
|
|
65
|
+
tabIndex={tabIndex ?? (selected ? 0 : -1)}
|
|
66
|
+
aria-selected={selected}
|
|
67
|
+
data-selected={selected || undefined}
|
|
68
|
+
className={classes('vgai-tab', className)}
|
|
69
|
+
>
|
|
70
|
+
{dirty && (
|
|
71
|
+
<>
|
|
72
|
+
<span className="vgai-tab-dirty" title="Unsaved" aria-hidden="true" />
|
|
73
|
+
<span className="vgai-sr-only">Unsaved</span>
|
|
74
|
+
</>
|
|
75
|
+
)}
|
|
76
|
+
<span className="vgai-tab-label">{children}</span>
|
|
77
|
+
{badge !== undefined && <span className="vgai-tab-badge">{badge}</span>}
|
|
78
|
+
</button>
|
|
79
|
+
);
|
|
80
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { forwardRef, type HTMLAttributes } from 'react';
|
|
2
|
+
|
|
3
|
+
function classes(base: string, className?: string): string {
|
|
4
|
+
return className ? `${base} ${className}` : base;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface EditorToolbarProps extends HTMLAttributes<HTMLDivElement> {
|
|
8
|
+
label: string;
|
|
9
|
+
compact?: boolean;
|
|
10
|
+
/** Independent controls may be active together; single-selection toolbars emphasize one choice. */
|
|
11
|
+
selectionMode?: 'independent' | 'single';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const EditorToolbar = forwardRef<HTMLDivElement, EditorToolbarProps>(function EditorToolbar(
|
|
15
|
+
{ label, compact = false, selectionMode = 'independent', className, ...props },
|
|
16
|
+
ref,
|
|
17
|
+
) {
|
|
18
|
+
return (
|
|
19
|
+
<div
|
|
20
|
+
{...props}
|
|
21
|
+
ref={ref}
|
|
22
|
+
role="toolbar"
|
|
23
|
+
aria-label={label}
|
|
24
|
+
data-compact={compact || undefined}
|
|
25
|
+
data-selection-mode={selectionMode}
|
|
26
|
+
className={classes('vgai-toolbar', className)}
|
|
27
|
+
/>
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
export function ToolbarGroup({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
|
32
|
+
return <div {...props} role="group" className={classes('vgai-toolbar-group', className)} />;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function ToolbarDivider(props: HTMLAttributes<HTMLSpanElement>) {
|
|
36
|
+
return (
|
|
37
|
+
<span
|
|
38
|
+
{...props}
|
|
39
|
+
aria-hidden="true"
|
|
40
|
+
className={classes('vgai-toolbar-divider', props.className)}
|
|
41
|
+
/>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Viewport-local tool cluster. Placement remains the viewport's
|
|
46
|
+
* responsibility. `vgai-glass-island` opts the cluster into the refraction
|
|
47
|
+
* engine's opt-in island surface set (P6 — glass-native chrome): under a
|
|
48
|
+
* glass theme on a capable GPU it carries its own url() displacement
|
|
49
|
+
* chain; everywhere else the class is inert and the var-driven
|
|
50
|
+
* `.vgai-floating-toolbar` paint decides (including inside floating cards,
|
|
51
|
+
* where the engine refuses islands — one filter per card). */
|
|
52
|
+
export function FloatingToolbar({ className, onPointerDownCapture, ...props }: EditorToolbarProps) {
|
|
53
|
+
return (
|
|
54
|
+
<EditorToolbar
|
|
55
|
+
{...props}
|
|
56
|
+
compact
|
|
57
|
+
className={classes('vgai-floating-toolbar vgai-glass-island', className)}
|
|
58
|
+
onPointerDownCapture={(event) => {
|
|
59
|
+
onPointerDownCapture?.(event);
|
|
60
|
+
// Viewport floating toolbars live inside the element owned by
|
|
61
|
+
// OrbitControls. Without this capture boundary, OrbitControls sees a
|
|
62
|
+
// button pointerdown, captures the pointer to the viewport container,
|
|
63
|
+
// and the button never receives the matching pointerup/click.
|
|
64
|
+
event.stopPropagation();
|
|
65
|
+
}}
|
|
66
|
+
/>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function SplitButtonGroup({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
|
71
|
+
return <div {...props} role="group" className={classes('vgai-split-button', className)} />;
|
|
72
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { forwardRef, type HTMLAttributes, type ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
function classes(base: string, className?: string): string {
|
|
4
|
+
return className ? `${base} ${className}` : base;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface EditorTreeProps extends HTMLAttributes<HTMLDivElement> {
|
|
8
|
+
label: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const EditorTree = forwardRef<HTMLDivElement, EditorTreeProps>(function EditorTree(
|
|
12
|
+
{ label, className, ...props },
|
|
13
|
+
ref,
|
|
14
|
+
) {
|
|
15
|
+
return (
|
|
16
|
+
<div
|
|
17
|
+
{...props}
|
|
18
|
+
ref={ref}
|
|
19
|
+
role="tree"
|
|
20
|
+
aria-label={label}
|
|
21
|
+
className={classes('vgai-tree', className)}
|
|
22
|
+
/>
|
|
23
|
+
);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
export interface EditorTreeRowProps extends HTMLAttributes<HTMLDivElement> {
|
|
27
|
+
level?: number;
|
|
28
|
+
selected?: boolean;
|
|
29
|
+
expanded?: boolean | undefined;
|
|
30
|
+
muted?: boolean;
|
|
31
|
+
dragging?: boolean;
|
|
32
|
+
leading?: ReactNode;
|
|
33
|
+
actions?: ReactNode;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const EditorTreeRow = forwardRef<HTMLDivElement, EditorTreeRowProps>(function EditorTreeRow(
|
|
37
|
+
{
|
|
38
|
+
level = 1,
|
|
39
|
+
selected = false,
|
|
40
|
+
expanded,
|
|
41
|
+
muted = false,
|
|
42
|
+
dragging = false,
|
|
43
|
+
leading,
|
|
44
|
+
actions,
|
|
45
|
+
className,
|
|
46
|
+
children,
|
|
47
|
+
style,
|
|
48
|
+
...props
|
|
49
|
+
},
|
|
50
|
+
ref,
|
|
51
|
+
) {
|
|
52
|
+
return (
|
|
53
|
+
<div
|
|
54
|
+
{...props}
|
|
55
|
+
ref={ref}
|
|
56
|
+
role="treeitem"
|
|
57
|
+
tabIndex={props.tabIndex ?? -1}
|
|
58
|
+
aria-level={level}
|
|
59
|
+
aria-selected={selected}
|
|
60
|
+
{...(expanded === undefined ? {} : { 'aria-expanded': expanded })}
|
|
61
|
+
data-selected={selected || undefined}
|
|
62
|
+
data-muted={muted || undefined}
|
|
63
|
+
data-dragging={dragging || undefined}
|
|
64
|
+
className={classes('vgai-tree-row', className)}
|
|
65
|
+
style={{ paddingLeft: 6 + Math.max(0, level - 1) * 14, ...style }}
|
|
66
|
+
>
|
|
67
|
+
{leading && <span className="vgai-tree-row-leading">{leading}</span>}
|
|
68
|
+
<span className="vgai-tree-row-label">{children}</span>
|
|
69
|
+
{actions && <span className="vgai-tree-row-actions">{actions}</span>}
|
|
70
|
+
</div>
|
|
71
|
+
);
|
|
72
|
+
});
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ButtonHTMLAttributes,
|
|
3
|
+
type CSSProperties,
|
|
4
|
+
type ReactNode,
|
|
5
|
+
type RefObject,
|
|
6
|
+
useCallback,
|
|
7
|
+
useId,
|
|
8
|
+
useLayoutEffect,
|
|
9
|
+
useRef,
|
|
10
|
+
useState,
|
|
11
|
+
} from 'react';
|
|
12
|
+
import { clampRectToViewport } from './clamp-to-viewport';
|
|
13
|
+
import { focusFirstMenuItem, Menu, type MenuDismissReason, MenuItem, type MenuProps } from './Menu';
|
|
14
|
+
import { ThemeRootPortal } from './ThemeRootPortal';
|
|
15
|
+
|
|
16
|
+
export interface AnchoredMenuProps extends Omit<MenuProps, 'children' | 'onDismiss' | 'style'> {
|
|
17
|
+
anchorRef: RefObject<HTMLElement | null>;
|
|
18
|
+
children: ReactNode;
|
|
19
|
+
/** Horizontal edge shared with the trigger. Game-toolbar menus are trailing-aligned. */
|
|
20
|
+
align?: 'start' | 'end';
|
|
21
|
+
/** Which side of the trigger the menu opens toward. A trigger at the bottom
|
|
22
|
+
* of the viewport (a composer control) opens 'top' — upward — so the menu
|
|
23
|
+
* stays on screen. `right` and `left` open BESIDE it with their top edge on
|
|
24
|
+
* the trigger's, which is what a SUBMENU does: its trigger is one row of the
|
|
25
|
+
* menu above it, so opening downward would cover the rows below. `align` is
|
|
26
|
+
* not read for those two — a side placement's cross-axis is the trigger's
|
|
27
|
+
* own top. */
|
|
28
|
+
side?: 'bottom' | 'top' | 'right' | 'left';
|
|
29
|
+
gap?: number;
|
|
30
|
+
/** Slide the placed rect back inside the window when the trigger sits near
|
|
31
|
+
* an edge (`clampRectToViewport`). Opt-in: a trigger that cannot reach an
|
|
32
|
+
* edge keeps the exact shared-edge alignment, which is why this is not the
|
|
33
|
+
* default. */
|
|
34
|
+
clamp?: boolean;
|
|
35
|
+
onDismiss: () => void;
|
|
36
|
+
style?: CSSProperties;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A menu positioned against a trigger while portaled to the owning theme root.
|
|
41
|
+
*
|
|
42
|
+
* Glass surfaces create containing/stacking contexts through backdrop-filter.
|
|
43
|
+
* A dropdown left inside one cannot out-stack a neighbouring glass card no
|
|
44
|
+
* matter how large its local z-index is. This primitive measures the existing
|
|
45
|
+
* trigger/menu geometry, expresses the same placement in viewport coordinates,
|
|
46
|
+
* and portals the menu to the filter-free theme root. It deliberately does not
|
|
47
|
+
* clamp: callers that can reach a viewport edge own that policy separately via
|
|
48
|
+
* `clampRectToViewport`.
|
|
49
|
+
*/
|
|
50
|
+
export function AnchoredMenu({
|
|
51
|
+
anchorRef,
|
|
52
|
+
align = 'end',
|
|
53
|
+
side = 'bottom',
|
|
54
|
+
autoFocusFirst = true,
|
|
55
|
+
gap = 4,
|
|
56
|
+
clamp = false,
|
|
57
|
+
onDismiss,
|
|
58
|
+
style,
|
|
59
|
+
children,
|
|
60
|
+
...props
|
|
61
|
+
}: AnchoredMenuProps) {
|
|
62
|
+
const [menuElement, setMenuElement] = useState<HTMLDivElement | null>(null);
|
|
63
|
+
const [position, setPosition] = useState<{ left: number; top: number } | null>(null);
|
|
64
|
+
const positionRef = useRef<{ left: number; top: number } | null>(null);
|
|
65
|
+
const restoreFocusOnUnmount = useRef(true);
|
|
66
|
+
const didAutoFocus = useRef(false);
|
|
67
|
+
|
|
68
|
+
const updatePosition = useCallback(() => {
|
|
69
|
+
const anchor = anchorRef.current;
|
|
70
|
+
if (!anchor || !menuElement) return;
|
|
71
|
+
const anchorRect = anchor.getBoundingClientRect();
|
|
72
|
+
const menuRect = menuElement.getBoundingClientRect();
|
|
73
|
+
const beside = side === 'right' || side === 'left';
|
|
74
|
+
const placed = beside
|
|
75
|
+
? {
|
|
76
|
+
left: side === 'right' ? anchorRect.right + gap : anchorRect.left - gap - menuRect.width,
|
|
77
|
+
top: anchorRect.top,
|
|
78
|
+
}
|
|
79
|
+
: {
|
|
80
|
+
left: align === 'end' ? anchorRect.right - menuRect.width : anchorRect.left,
|
|
81
|
+
top: side === 'top' ? anchorRect.top - gap - menuRect.height : anchorRect.bottom + gap,
|
|
82
|
+
};
|
|
83
|
+
const next = clamp
|
|
84
|
+
? clampRectToViewport({ ...placed, width: menuRect.width, height: menuRect.height })
|
|
85
|
+
: placed;
|
|
86
|
+
const current = positionRef.current;
|
|
87
|
+
if (current?.left === next.left && current.top === next.top) return;
|
|
88
|
+
positionRef.current = next;
|
|
89
|
+
setPosition(next);
|
|
90
|
+
}, [align, anchorRef, clamp, gap, menuElement, side]);
|
|
91
|
+
|
|
92
|
+
useLayoutEffect(() => {
|
|
93
|
+
const anchor = anchorRef.current;
|
|
94
|
+
if (!anchor || !menuElement) return;
|
|
95
|
+
updatePosition();
|
|
96
|
+
|
|
97
|
+
// Portaling changes the menu from anchor-relative CSS geometry to fixed
|
|
98
|
+
// viewport coordinates. Preserve the old continuous attachment contract.
|
|
99
|
+
// ResizeObserver covers size changes but intentionally does not report a
|
|
100
|
+
// position-only reflow, and transforms/layout can move an anchor without
|
|
101
|
+
// emitting window resize or scroll. While this transient menu is open,
|
|
102
|
+
// one animation-frame geometry read is the only complete signal. The
|
|
103
|
+
// ref equality guard above keeps settled frames free of React updates.
|
|
104
|
+
let frame = 0;
|
|
105
|
+
const trackPosition = () => {
|
|
106
|
+
updatePosition();
|
|
107
|
+
frame = requestAnimationFrame(trackPosition);
|
|
108
|
+
};
|
|
109
|
+
frame = requestAnimationFrame(trackPosition);
|
|
110
|
+
window.addEventListener('resize', updatePosition);
|
|
111
|
+
document.addEventListener('scroll', updatePosition, true);
|
|
112
|
+
const resizeObserver =
|
|
113
|
+
typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(updatePosition);
|
|
114
|
+
resizeObserver?.observe(anchor);
|
|
115
|
+
resizeObserver?.observe(menuElement);
|
|
116
|
+
return () => {
|
|
117
|
+
const restoreFocus =
|
|
118
|
+
restoreFocusOnUnmount.current && menuElement.contains(document.activeElement);
|
|
119
|
+
window.removeEventListener('resize', updatePosition);
|
|
120
|
+
document.removeEventListener('scroll', updatePosition, true);
|
|
121
|
+
cancelAnimationFrame(frame);
|
|
122
|
+
resizeObserver?.disconnect();
|
|
123
|
+
// A selected item unmounts its menu through the caller rather than the
|
|
124
|
+
// Menu dismissal callback. Restore after that commit, while leaving an
|
|
125
|
+
// outside-pointer target in control of focus.
|
|
126
|
+
if (restoreFocus) queueMicrotask(() => anchorRef.current?.focus());
|
|
127
|
+
};
|
|
128
|
+
}, [anchorRef, menuElement, updatePosition]);
|
|
129
|
+
|
|
130
|
+
// Drive autofocus HERE, not through Menu's mount-time effect. The menu is
|
|
131
|
+
// `visibility:hidden` until the measure pass sets `position`; a `focus()`
|
|
132
|
+
// into a hidden subtree is refused by the browser and not restored on
|
|
133
|
+
// unhide, so Menu's passive focus fires uselessly on the first commit. Focus
|
|
134
|
+
// once, right after we unhide, guarding against the rAF reposition loop
|
|
135
|
+
// stealing focus back to the first item mid-navigation.
|
|
136
|
+
useLayoutEffect(() => {
|
|
137
|
+
if (!autoFocusFirst || didAutoFocus.current) return;
|
|
138
|
+
if (!menuElement || !position) return;
|
|
139
|
+
didAutoFocus.current = true;
|
|
140
|
+
focusFirstMenuItem(menuElement);
|
|
141
|
+
}, [autoFocusFirst, menuElement, position]);
|
|
142
|
+
|
|
143
|
+
const handleDismiss = (reason: MenuDismissReason) => {
|
|
144
|
+
restoreFocusOnUnmount.current = reason !== 'outside';
|
|
145
|
+
onDismiss();
|
|
146
|
+
if (reason === 'escape') anchorRef.current?.focus();
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
return (
|
|
150
|
+
<ThemeRootPortal>
|
|
151
|
+
<Menu
|
|
152
|
+
{...props}
|
|
153
|
+
ref={setMenuElement}
|
|
154
|
+
dismissBoundaryRef={anchorRef}
|
|
155
|
+
onDismiss={handleDismiss}
|
|
156
|
+
style={{
|
|
157
|
+
...style,
|
|
158
|
+
position: 'fixed',
|
|
159
|
+
left: position?.left ?? 0,
|
|
160
|
+
top: position?.top ?? 0,
|
|
161
|
+
visibility: position ? style?.visibility : 'hidden',
|
|
162
|
+
}}
|
|
163
|
+
>
|
|
164
|
+
{children}
|
|
165
|
+
</Menu>
|
|
166
|
+
</ThemeRootPortal>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* A MENU ROW THAT OPENS A MENU OF ITS OWN — the nested half of `Menu`, and the
|
|
172
|
+
* shape every DCC menu bar has (Blender draws `Mesh ▸`, `Light ▸`, `Apply ▸`,
|
|
173
|
+
* `Clear ▸` and `Set Origin ▸` this way; so does every desktop application
|
|
174
|
+
* menu). It lives HERE rather than beside `MenuItem` because it opens an
|
|
175
|
+
* {@link AnchoredMenu}, and `AnchoredMenu` is what imports `Menu` — the other
|
|
176
|
+
* direction would be a cycle.
|
|
177
|
+
*
|
|
178
|
+
* IT OWNS ITS OWN OPEN STATE, which is what makes it a primitive rather than a
|
|
179
|
+
* pattern every caller re-coordinates. Opening is a hover or a click, both of
|
|
180
|
+
* which a person reaches for. CLOSING is the part that is easy to get wrong:
|
|
181
|
+
* the child menu is PORTALED (menus escape glass stacking contexts), so moving
|
|
182
|
+
* the pointer from this row into the menu it just opened fires `pointerleave`
|
|
183
|
+
* on the row. `relatedTarget` is the element being ENTERED, so a leave into
|
|
184
|
+
* this submenu's own subtree is not a leave at all — and a sibling row's hover
|
|
185
|
+
* closes this one by being somewhere else. The subtree is found by a `data`
|
|
186
|
+
* attribute rather than by a ref, because `AnchoredMenu` portals its menu and
|
|
187
|
+
* forwards no handle to it.
|
|
188
|
+
*
|
|
189
|
+
* Choosing a row in the child unmounts the whole tree through the caller's own
|
|
190
|
+
* close, which is why there is no `onSelect` here: this row has no action.
|
|
191
|
+
*/
|
|
192
|
+
export function MenuSubmenu({
|
|
193
|
+
label,
|
|
194
|
+
children,
|
|
195
|
+
gap = 0,
|
|
196
|
+
...props
|
|
197
|
+
}: Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'children' | 'onSelect'> & {
|
|
198
|
+
label: ReactNode;
|
|
199
|
+
children: ReactNode;
|
|
200
|
+
/** Passed through to the child menu's placement. */
|
|
201
|
+
gap?: number;
|
|
202
|
+
}) {
|
|
203
|
+
const [open, setOpen] = useState(false);
|
|
204
|
+
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
205
|
+
const id = useId();
|
|
206
|
+
const staysOpen = (node: EventTarget | null): boolean =>
|
|
207
|
+
node instanceof Element &&
|
|
208
|
+
(triggerRef.current?.contains(node) === true ||
|
|
209
|
+
node.closest(`[data-vgai-submenu="${id}"]`) !== null);
|
|
210
|
+
const leave = (event: { relatedTarget: EventTarget | null }): void => {
|
|
211
|
+
if (!staysOpen(event.relatedTarget)) setOpen(false);
|
|
212
|
+
};
|
|
213
|
+
return (
|
|
214
|
+
<>
|
|
215
|
+
<MenuItem
|
|
216
|
+
{...props}
|
|
217
|
+
ref={triggerRef}
|
|
218
|
+
aria-haspopup="menu"
|
|
219
|
+
aria-expanded={open}
|
|
220
|
+
onPointerEnter={() => setOpen(true)}
|
|
221
|
+
onPointerLeave={leave}
|
|
222
|
+
onClick={(event) => {
|
|
223
|
+
// A click on this row OPENS and never selects or closes: the row has
|
|
224
|
+
// no action of its own, so letting the click through would close the
|
|
225
|
+
// whole menu having done nothing, and toggling would close a submenu
|
|
226
|
+
// a person had just pointed at. Blender's submenus behave the same —
|
|
227
|
+
// they close by leaving them or by Escape.
|
|
228
|
+
event.preventDefault();
|
|
229
|
+
setOpen(true);
|
|
230
|
+
}}
|
|
231
|
+
onKeyDown={(event) => {
|
|
232
|
+
if (event.key === 'ArrowRight' || event.key === 'Enter' || event.key === ' ') {
|
|
233
|
+
event.preventDefault();
|
|
234
|
+
setOpen(true);
|
|
235
|
+
}
|
|
236
|
+
if (event.key === 'ArrowLeft') setOpen(false);
|
|
237
|
+
}}
|
|
238
|
+
>
|
|
239
|
+
<span style={{ flex: 1 }}>{label}</span>
|
|
240
|
+
<span aria-hidden="true" style={{ paddingInlineStart: 'var(--vgai-space-4)' }}>
|
|
241
|
+
▸
|
|
242
|
+
</span>
|
|
243
|
+
</MenuItem>
|
|
244
|
+
{open && (
|
|
245
|
+
<AnchoredMenu
|
|
246
|
+
anchorRef={triggerRef}
|
|
247
|
+
data-vgai-submenu={id}
|
|
248
|
+
side="right"
|
|
249
|
+
gap={gap}
|
|
250
|
+
clamp
|
|
251
|
+
autoFocusFirst={false}
|
|
252
|
+
onDismiss={() => setOpen(false)}
|
|
253
|
+
onPointerLeave={leave}
|
|
254
|
+
>
|
|
255
|
+
{children}
|
|
256
|
+
</AnchoredMenu>
|
|
257
|
+
)}
|
|
258
|
+
</>
|
|
259
|
+
);
|
|
260
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { type ButtonHTMLAttributes, forwardRef, type ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
export type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger' | 'success';
|
|
4
|
+
export type ButtonSize = 'compact' | 'default' | 'comfortable';
|
|
5
|
+
export type ButtonShape = 'default' | 'segment';
|
|
6
|
+
|
|
7
|
+
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
8
|
+
variant?: ButtonVariant;
|
|
9
|
+
size?: ButtonSize;
|
|
10
|
+
/** Segment reserves a stable capsule footprint for mode/tool selections. */
|
|
11
|
+
shape?: ButtonShape;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type IconButtonProps = Omit<ButtonProps, 'aria-label'> & {
|
|
15
|
+
'aria-label': string;
|
|
16
|
+
/** Optional visible caption rendered below the icon for labelled toolbars. */
|
|
17
|
+
visibleLabel?: ReactNode;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** Canonical editor button. Visual variants and dimensions live in theme.css. */
|
|
21
|
+
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
|
|
22
|
+
{
|
|
23
|
+
variant = 'secondary',
|
|
24
|
+
size = 'default',
|
|
25
|
+
shape = 'default',
|
|
26
|
+
className,
|
|
27
|
+
type = 'button',
|
|
28
|
+
...props
|
|
29
|
+
},
|
|
30
|
+
ref,
|
|
31
|
+
) {
|
|
32
|
+
return (
|
|
33
|
+
<button
|
|
34
|
+
{...props}
|
|
35
|
+
ref={ref}
|
|
36
|
+
type={type}
|
|
37
|
+
className={className ? `vgai-btn ${className}` : 'vgai-btn'}
|
|
38
|
+
data-variant={variant}
|
|
39
|
+
data-size={size}
|
|
40
|
+
data-shape={shape}
|
|
41
|
+
/>
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
/** Icon-only button. The accessible name is required by its public type. */
|
|
46
|
+
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(function IconButton(
|
|
47
|
+
{ variant = 'ghost', visibleLabel, children, ...props },
|
|
48
|
+
ref,
|
|
49
|
+
) {
|
|
50
|
+
return (
|
|
51
|
+
<Button
|
|
52
|
+
{...props}
|
|
53
|
+
ref={ref}
|
|
54
|
+
variant={variant}
|
|
55
|
+
data-icon="true"
|
|
56
|
+
data-label-placement={visibleLabel === undefined ? undefined : 'below'}
|
|
57
|
+
>
|
|
58
|
+
{children}
|
|
59
|
+
{visibleLabel !== undefined && <span className="vgai-icon-button-label">{visibleLabel}</span>}
|
|
60
|
+
</Button>
|
|
61
|
+
);
|
|
62
|
+
});
|