@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.
Files changed (104) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +8 -0
  3. package/README.md +19 -0
  4. package/package.json +90 -0
  5. package/src/account.ts +210 -0
  6. package/src/chrome.ts +83 -0
  7. package/src/client.ts +1547 -0
  8. package/src/commands.ts +66 -0
  9. package/src/contributions.ts +985 -0
  10. package/src/document-probe.ts +237 -0
  11. package/src/editor-view.ts +220 -0
  12. package/src/extension.ts +40 -0
  13. package/src/generations.ts +178 -0
  14. package/src/host.ts +1167 -0
  15. package/src/http-transport.browser.ts +14 -0
  16. package/src/http-transport.node.ts +19 -0
  17. package/src/index.ts +128 -0
  18. package/src/layout-arrangements.ts +5 -0
  19. package/src/layouts.tsx +108 -0
  20. package/src/looks.ts +14 -0
  21. package/src/project/output-roots.ts +73 -0
  22. package/src/project/tab-census.ts +149 -0
  23. package/src/project-tool-catalog.ts +96 -0
  24. package/src/selection.tsx +108 -0
  25. package/src/services.ts +18 -0
  26. package/src/session/build-report.ts +19 -0
  27. package/src/session/collaboration-types.ts +262 -0
  28. package/src/session/command-table.ts +333 -0
  29. package/src/session/discovery.ts +90 -0
  30. package/src/session/editor-brand.ts +73 -0
  31. package/src/session/editor-compatibility.ts +248 -0
  32. package/src/session/editor-control-lifecycle.ts +68 -0
  33. package/src/session/editor-control-protocol.ts +5 -0
  34. package/src/session/entrypoint-selection-readers.ts +66 -0
  35. package/src/session/entrypoint-selection-source.ts +120 -0
  36. package/src/session/game-css-scope.ts +30 -0
  37. package/src/session/product-create.ts +24 -0
  38. package/src/session/product-locator.ts +389 -0
  39. package/src/session/project-module-url.ts +245 -0
  40. package/src/session/registry-format.ts +203 -0
  41. package/src/session/relative-path-guard.ts +56 -0
  42. package/src/session/source-glob.ts +15 -0
  43. package/src/session/tool-contribution-convention.ts +116 -0
  44. package/src/session/workbench-locator.ts +650 -0
  45. package/src/session.ts +41 -0
  46. package/src/share.ts +160 -0
  47. package/src/tools/errors.ts +91 -0
  48. package/src/tools/provider-execution.ts +70 -0
  49. package/src/tools/registry.ts +341 -0
  50. package/src/tools/types.ts +159 -0
  51. package/src/transport.ts +97 -0
  52. package/src/types.ts +1581 -0
  53. package/src/views.ts +164 -0
  54. package/src/widgets/design-system.ts +93 -0
  55. package/src/widgets/editor-appearance.ts +149 -0
  56. package/src/widgets/editor-material.ts +83 -0
  57. package/src/widgets/icon-set-registry.ts +105 -0
  58. package/src/widgets/index.ts +71 -0
  59. package/src/widgets/inspector-widgets/AlignmentGrid.tsx +182 -0
  60. package/src/widgets/inspector-widgets/AssetSlotPicker.tsx +123 -0
  61. package/src/widgets/inspector-widgets/BorderEditor.tsx +309 -0
  62. package/src/widgets/inspector-widgets/ColorPicker.tsx +549 -0
  63. package/src/widgets/inspector-widgets/CurveEditor.tsx +359 -0
  64. package/src/widgets/inspector-widgets/FilterEditor.tsx +108 -0
  65. package/src/widgets/inspector-widgets/FontPicker.tsx +191 -0
  66. package/src/widgets/inspector-widgets/GradientEditor.tsx +623 -0
  67. package/src/widgets/inspector-widgets/ScrubbableInput.tsx +180 -0
  68. package/src/widgets/inspector-widgets/ShadowEditor.tsx +319 -0
  69. package/src/widgets/inspector-widgets/color-utils.ts +201 -0
  70. package/src/widgets/inspector-widgets/curve-utils.ts +212 -0
  71. package/src/widgets/inspector-widgets/index.ts +24 -0
  72. package/src/widgets/inspector-widgets/shared.tsx +140 -0
  73. package/src/widgets/interactive-edit-scope.ts +33 -0
  74. package/src/widgets/patterns/Dialog.tsx +129 -0
  75. package/src/widgets/patterns/Fields.tsx +44 -0
  76. package/src/widgets/patterns/List.tsx +25 -0
  77. package/src/widgets/patterns/StateSurface.tsx +40 -0
  78. package/src/widgets/patterns/Surfaces.tsx +122 -0
  79. package/src/widgets/patterns/Tabs.tsx +80 -0
  80. package/src/widgets/patterns/Toolbar.tsx +72 -0
  81. package/src/widgets/patterns/Tree.tsx +72 -0
  82. package/src/widgets/primitives/AnchoredMenu.tsx +260 -0
  83. package/src/widgets/primitives/Button.tsx +62 -0
  84. package/src/widgets/primitives/ColorInput.tsx +78 -0
  85. package/src/widgets/primitives/DraftTextInput.tsx +63 -0
  86. package/src/widgets/primitives/EditorIcon.tsx +157 -0
  87. package/src/widgets/primitives/FormControls.tsx +88 -0
  88. package/src/widgets/primitives/HoverPreview.tsx +96 -0
  89. package/src/widgets/primitives/JsonInput.tsx +113 -0
  90. package/src/widgets/primitives/Layout.tsx +100 -0
  91. package/src/widgets/primitives/Menu.tsx +140 -0
  92. package/src/widgets/primitives/NumberInput.tsx +169 -0
  93. package/src/widgets/primitives/Panel.tsx +80 -0
  94. package/src/widgets/primitives/SectionHeader.tsx +77 -0
  95. package/src/widgets/primitives/Text.tsx +54 -0
  96. package/src/widgets/primitives/ThemeRootPortal.tsx +52 -0
  97. package/src/widgets/primitives/Tooltip.tsx +204 -0
  98. package/src/widgets/primitives/Vec3Input.tsx +70 -0
  99. package/src/widgets/primitives/banner-tones.ts +32 -0
  100. package/src/widgets/primitives/clamp-to-viewport.ts +44 -0
  101. package/src/widgets/primitives/editor-icons.ts +245 -0
  102. package/src/widgets/primitives/panel-header-styles.ts +42 -0
  103. package/src/widgets/theme.ts +2633 -0
  104. 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
+ });