@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,182 @@
|
|
|
1
|
+
import { Button } from '../design-system';
|
|
2
|
+
/**
|
|
3
|
+
* AlignmentGrid — a 3×3 flex `justify-content`×`align-items` picker (spec 27
|
|
4
|
+
* §5 C1). Ported from `visual-edit/inspector.tsx`'s `AlignmentGrid`
|
|
5
|
+
* (:304-351). `AnchorPresets` below REUSES the existing `ui-editor/
|
|
6
|
+
* inspector.tsx` `ANCHOR_PRESETS` table's VALUES (now exported there for
|
|
7
|
+
* this reuse) as a data-only copy — not a live import — because
|
|
8
|
+
* `ui-editor/inspector.tsx` pulls in `UIEditStore`/`react-store`/
|
|
9
|
+
* `node-ops`/the inspector-section-registry (a whole editing-mode runtime
|
|
10
|
+
* graph this pure, adapter-free widget kit must not drag in, Rule zero's
|
|
11
|
+
* "talk ONLY to the AuthoringAdapter contract" spirit extended to "don't
|
|
12
|
+
* import a store-coupled module just for one constant"). Keep this table in
|
|
13
|
+
* sync with `ui-editor/inspector.tsx`'s `ANCHOR_PRESETS` if either changes —
|
|
14
|
+
* C2's wiring step is the natural point to collapse them into one shared,
|
|
15
|
+
* dependency-free `anchor-presets.ts` module both sides import.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { type ChangeHandlers, THEME } from './shared';
|
|
19
|
+
|
|
20
|
+
const ANCHOR_PRESETS: Record<
|
|
21
|
+
string,
|
|
22
|
+
{ anchor: AnchorRect['anchor']; offset: AnchorRect['offset'] }
|
|
23
|
+
> = {
|
|
24
|
+
full: {
|
|
25
|
+
anchor: { left: 0, top: 0, right: 1, bottom: 1 },
|
|
26
|
+
offset: { left: 0, top: 0, right: 0, bottom: 0 },
|
|
27
|
+
},
|
|
28
|
+
top: {
|
|
29
|
+
anchor: { left: 0, top: 0, right: 1, bottom: 0 },
|
|
30
|
+
offset: { left: 0, top: 0, right: 0, bottom: 0 },
|
|
31
|
+
},
|
|
32
|
+
bottom: {
|
|
33
|
+
anchor: { left: 0, top: 1, right: 1, bottom: 1 },
|
|
34
|
+
offset: { left: 0, top: 0, right: 0, bottom: 0 },
|
|
35
|
+
},
|
|
36
|
+
left: {
|
|
37
|
+
anchor: { left: 0, top: 0, right: 0, bottom: 1 },
|
|
38
|
+
offset: { left: 0, top: 0, right: 0, bottom: 0 },
|
|
39
|
+
},
|
|
40
|
+
right: {
|
|
41
|
+
anchor: { left: 1, top: 0, right: 1, bottom: 1 },
|
|
42
|
+
offset: { left: 0, top: 0, right: 0, bottom: 0 },
|
|
43
|
+
},
|
|
44
|
+
center: {
|
|
45
|
+
anchor: { left: 0.5, top: 0.5, right: 0.5, bottom: 0.5 },
|
|
46
|
+
offset: { left: -60, top: -20, right: 60, bottom: 20 },
|
|
47
|
+
},
|
|
48
|
+
'top-left': {
|
|
49
|
+
anchor: { left: 0, top: 0, right: 0, bottom: 0 },
|
|
50
|
+
offset: { left: 0, top: 0, right: 120, bottom: 40 },
|
|
51
|
+
},
|
|
52
|
+
'top-right': {
|
|
53
|
+
anchor: { left: 1, top: 0, right: 1, bottom: 0 },
|
|
54
|
+
offset: { left: -120, top: 0, right: 0, bottom: 40 },
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const JUSTIFY = ['flex-start', 'center', 'flex-end'] as const;
|
|
59
|
+
const ALIGN = ['flex-start', 'center', 'flex-end'] as const;
|
|
60
|
+
|
|
61
|
+
export interface AlignmentValue {
|
|
62
|
+
justify: (typeof JUSTIFY)[number];
|
|
63
|
+
align: (typeof ALIGN)[number];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** One 3×3 dot — split out of `AlignmentGrid`'s double `.map` purely to keep
|
|
67
|
+
* each function under biome's cognitive-complexity ceiling; same markup. */
|
|
68
|
+
function AlignmentCell({
|
|
69
|
+
row,
|
|
70
|
+
col,
|
|
71
|
+
justify,
|
|
72
|
+
align,
|
|
73
|
+
isActive,
|
|
74
|
+
disabled,
|
|
75
|
+
onSelect,
|
|
76
|
+
}: {
|
|
77
|
+
row: number;
|
|
78
|
+
col: number;
|
|
79
|
+
justify: AlignmentValue['justify'];
|
|
80
|
+
align: AlignmentValue['align'];
|
|
81
|
+
isActive: boolean;
|
|
82
|
+
disabled?: boolean | undefined;
|
|
83
|
+
onSelect: (justify: AlignmentValue['justify'], align: AlignmentValue['align']) => void;
|
|
84
|
+
}): React.ReactElement {
|
|
85
|
+
return (
|
|
86
|
+
<div
|
|
87
|
+
data-testid={`alignment-grid-${row}-${col}`}
|
|
88
|
+
onClick={() => {
|
|
89
|
+
if (!disabled) onSelect(justify, align);
|
|
90
|
+
}}
|
|
91
|
+
style={{
|
|
92
|
+
width: 16,
|
|
93
|
+
height: 16,
|
|
94
|
+
display: 'flex',
|
|
95
|
+
alignItems: 'center',
|
|
96
|
+
justifyContent: 'center',
|
|
97
|
+
cursor: disabled ? 'not-allowed' : 'pointer',
|
|
98
|
+
borderRadius: THEME.radiusSmall,
|
|
99
|
+
background: isActive ? THEME.accent : 'transparent',
|
|
100
|
+
}}
|
|
101
|
+
>
|
|
102
|
+
<div
|
|
103
|
+
style={{
|
|
104
|
+
width: isActive ? 6 : 4,
|
|
105
|
+
height: isActive ? 6 : 4,
|
|
106
|
+
borderRadius: '50%',
|
|
107
|
+
background: isActive ? THEME.onAccent : THEME.textMuted,
|
|
108
|
+
}}
|
|
109
|
+
/>
|
|
110
|
+
</div>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function AlignmentGrid({
|
|
115
|
+
value,
|
|
116
|
+
onChange,
|
|
117
|
+
disabled,
|
|
118
|
+
}: ChangeHandlers<AlignmentValue>): React.ReactElement {
|
|
119
|
+
const jIdx = JUSTIFY.indexOf(value.justify);
|
|
120
|
+
const aIdx = ALIGN.indexOf(value.align);
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<div
|
|
124
|
+
data-testid="alignment-grid"
|
|
125
|
+
style={{
|
|
126
|
+
display: 'inline-grid',
|
|
127
|
+
gridTemplateColumns: 'repeat(3, 16px)',
|
|
128
|
+
gridTemplateRows: 'repeat(3, 16px)',
|
|
129
|
+
gap: 1,
|
|
130
|
+
background: THEME.inputBg,
|
|
131
|
+
borderRadius: THEME.radiusMedium,
|
|
132
|
+
padding: 4,
|
|
133
|
+
opacity: disabled ? 0.35 : 1,
|
|
134
|
+
}}
|
|
135
|
+
>
|
|
136
|
+
{ALIGN.map((a, row) =>
|
|
137
|
+
JUSTIFY.map((j, col) => (
|
|
138
|
+
<AlignmentCell
|
|
139
|
+
key={`${row}-${col}`}
|
|
140
|
+
row={row}
|
|
141
|
+
col={col}
|
|
142
|
+
justify={j}
|
|
143
|
+
align={a}
|
|
144
|
+
isActive={row === aIdx && col === jIdx}
|
|
145
|
+
disabled={disabled}
|
|
146
|
+
onSelect={(justify, align) => onChange({ justify, align })}
|
|
147
|
+
/>
|
|
148
|
+
)),
|
|
149
|
+
)}
|
|
150
|
+
</div>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export interface AnchorRect {
|
|
155
|
+
anchor: { left: number; top: number; right: number; bottom: number };
|
|
156
|
+
offset: { left: number; top: number; right: number; bottom: number };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export interface AnchorPresetsProps {
|
|
160
|
+
onSelect: (name: string, preset: AnchorRect) => void;
|
|
161
|
+
disabled?: boolean | undefined;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function AnchorPresets({ onSelect, disabled }: AnchorPresetsProps): React.ReactElement {
|
|
165
|
+
return (
|
|
166
|
+
<div data-testid="anchor-presets" style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
|
167
|
+
{Object.entries(ANCHOR_PRESETS).map(([name, preset]) => (
|
|
168
|
+
<Button
|
|
169
|
+
key={name}
|
|
170
|
+
type="button"
|
|
171
|
+
variant="secondary"
|
|
172
|
+
size="compact"
|
|
173
|
+
data-testid={`anchor-preset-${name}`}
|
|
174
|
+
disabled={disabled}
|
|
175
|
+
onClick={() => onSelect(name, preset)}
|
|
176
|
+
>
|
|
177
|
+
{name}
|
|
178
|
+
</Button>
|
|
179
|
+
))}
|
|
180
|
+
</div>
|
|
181
|
+
);
|
|
182
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AssetSlotPicker — the widget for a `PropertyDescriptor.type: 'asset'` row:
|
|
3
|
+
* ONE slot on the inspected thing that either holds a project asset or is
|
|
4
|
+
* empty (a material's `map`, `normalMap`, …).
|
|
5
|
+
*
|
|
6
|
+
* Kit-pure like every other widget here (`index.ts`): it takes the candidate
|
|
7
|
+
* paths and a preview-URL function from its caller and imports no editor
|
|
8
|
+
* state, so the same control serves any lane that can name its own assets.
|
|
9
|
+
*
|
|
10
|
+
* Deliberately NOT a second asset browser. The choices are a flat list of
|
|
11
|
+
* project-relative paths in a `<Select>` — the Asset Browser panel remains the
|
|
12
|
+
* place to look through, tag, import and preview assets; this is the one-click
|
|
13
|
+
* assignment at the point of use, which is the gesture a material author is
|
|
14
|
+
* making. The thumbnail is the assigned file itself, so the row shows WHAT is
|
|
15
|
+
* assigned and not only its name.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { faXmark } from '@fortawesome/free-solid-svg-icons';
|
|
19
|
+
import type React from 'react';
|
|
20
|
+
import { EditorIcon, IconButton, Select, themeVars } from '../design-system';
|
|
21
|
+
import { THEME } from './shared';
|
|
22
|
+
|
|
23
|
+
const THUMB = 22;
|
|
24
|
+
|
|
25
|
+
const thumbStyle: React.CSSProperties = {
|
|
26
|
+
width: THUMB,
|
|
27
|
+
height: THUMB,
|
|
28
|
+
flex: `0 0 ${THUMB}px`,
|
|
29
|
+
borderRadius: THEME.radiusSmall,
|
|
30
|
+
border: `1px solid ${THEME.border}`,
|
|
31
|
+
background: THEME.inputBg,
|
|
32
|
+
objectFit: 'cover',
|
|
33
|
+
// A checker under a transparent PNG, so an alpha map does not read as white.
|
|
34
|
+
backgroundImage:
|
|
35
|
+
'linear-gradient(45deg,rgba(128,128,128,.35) 25%,transparent 25%,transparent 75%,rgba(128,128,128,.35) 75%),' +
|
|
36
|
+
'linear-gradient(45deg,rgba(128,128,128,.35) 25%,transparent 25%,transparent 75%,rgba(128,128,128,.35) 75%)',
|
|
37
|
+
backgroundSize: '8px 8px',
|
|
38
|
+
backgroundPosition: '0 0, 4px 4px',
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export interface AssetSlotPickerProps {
|
|
42
|
+
/** The assigned asset's project path, or `''` when the slot is empty. */
|
|
43
|
+
readonly value: string;
|
|
44
|
+
/** Assign a path, or `''` to CLEAR the slot. */
|
|
45
|
+
readonly onChange: (value: string) => void;
|
|
46
|
+
/** The paths this slot may be filled from, already filtered by the caller to
|
|
47
|
+
* the kinds it can honor. */
|
|
48
|
+
readonly options: readonly string[];
|
|
49
|
+
/** Browser URL for a path's preview image. */
|
|
50
|
+
readonly previewUrlFor: (path: string) => string;
|
|
51
|
+
readonly disabled?: boolean | undefined;
|
|
52
|
+
/**
|
|
53
|
+
* What the last assignment ANSWERED — the write ack's own sentence when it
|
|
54
|
+
* did not persist. Rendered under the control because an edit whose file half
|
|
55
|
+
* silently did not happen is exactly the invisible failure the write pipe
|
|
56
|
+
* exists to make loud.
|
|
57
|
+
*/
|
|
58
|
+
readonly note?: string | undefined;
|
|
59
|
+
readonly testId: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function AssetSlotPicker({
|
|
63
|
+
value,
|
|
64
|
+
onChange,
|
|
65
|
+
options,
|
|
66
|
+
previewUrlFor,
|
|
67
|
+
disabled,
|
|
68
|
+
note,
|
|
69
|
+
testId,
|
|
70
|
+
}: AssetSlotPickerProps): React.ReactElement {
|
|
71
|
+
// A value assigned outside this list (a texture the game's own code loaded,
|
|
72
|
+
// or a file since deleted) must still be SHOWN — dropping it from the select
|
|
73
|
+
// would silently redisplay the slot as empty while it is not.
|
|
74
|
+
const choices = value && !options.includes(value) ? [value, ...options] : options;
|
|
75
|
+
return (
|
|
76
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, minWidth: 0, flex: 1 }}>
|
|
77
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 4, minWidth: 0 }}>
|
|
78
|
+
{value ? (
|
|
79
|
+
<img
|
|
80
|
+
src={previewUrlFor(value)}
|
|
81
|
+
alt=""
|
|
82
|
+
style={thumbStyle}
|
|
83
|
+
data-testid={`${testId}-thumb`}
|
|
84
|
+
/>
|
|
85
|
+
) : (
|
|
86
|
+
<span style={thumbStyle} aria-hidden="true" />
|
|
87
|
+
)}
|
|
88
|
+
<Select
|
|
89
|
+
data-testid={testId}
|
|
90
|
+
disabled={disabled}
|
|
91
|
+
value={value}
|
|
92
|
+
title={value || 'No texture assigned'}
|
|
93
|
+
onChange={(e) => onChange(e.target.value)}
|
|
94
|
+
style={{ flex: 1, minWidth: 0 }}
|
|
95
|
+
>
|
|
96
|
+
<option value="">—</option>
|
|
97
|
+
{choices.map((path) => (
|
|
98
|
+
<option key={path} value={path}>
|
|
99
|
+
{path}
|
|
100
|
+
</option>
|
|
101
|
+
))}
|
|
102
|
+
</Select>
|
|
103
|
+
<IconButton
|
|
104
|
+
data-testid={`${testId}-clear`}
|
|
105
|
+
aria-label="Clear this texture slot"
|
|
106
|
+
title="Clear this texture slot"
|
|
107
|
+
disabled={disabled || !value}
|
|
108
|
+
onClick={() => onChange('')}
|
|
109
|
+
>
|
|
110
|
+
<EditorIcon icon={faXmark} />
|
|
111
|
+
</IconButton>
|
|
112
|
+
</div>
|
|
113
|
+
{note && (
|
|
114
|
+
<span
|
|
115
|
+
data-testid={`${testId}-note`}
|
|
116
|
+
style={{ fontSize: 'var(--vgai-font-sm)', color: themeVars.content.muted }}
|
|
117
|
+
>
|
|
118
|
+
{note}
|
|
119
|
+
</span>
|
|
120
|
+
)}
|
|
121
|
+
</div>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import { Button, Select } from '../design-system';
|
|
2
|
+
/**
|
|
3
|
+
* BorderEditor (per-side width/style/color) + BorderRadiusEditor (per-corner)
|
|
4
|
+
* (spec 27 §5 C1). Ported from `visual-edit/inspector.tsx`'s `BorderEditor`
|
|
5
|
+
* (:1853-1951) and `BorderRadiusEditor` (:1794-1847) — both keep the
|
|
6
|
+
* reference's uniform<->per-side/per-corner expand toggle (a single border/
|
|
7
|
+
* radius is the common case; independent sides are one click away).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { faBorderAll, faBorderTopLeft } from '@fortawesome/free-solid-svg-icons';
|
|
11
|
+
import { useState } from 'react';
|
|
12
|
+
import { EditorIcon } from '../design-system';
|
|
13
|
+
import { ColorSwatch } from './ColorPicker';
|
|
14
|
+
import { ScrubbableInput } from './ScrubbableInput';
|
|
15
|
+
import { type ChangeHandlers, fireChange, fireEnd, THEME } from './shared';
|
|
16
|
+
|
|
17
|
+
const BORDER_STYLES = ['none', 'solid', 'dashed', 'dotted', 'double'];
|
|
18
|
+
|
|
19
|
+
export interface BorderSide {
|
|
20
|
+
width: number;
|
|
21
|
+
style: string;
|
|
22
|
+
color: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface BorderValue {
|
|
26
|
+
top: BorderSide;
|
|
27
|
+
right: BorderSide;
|
|
28
|
+
bottom: BorderSide;
|
|
29
|
+
left: BorderSide;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function uniformBorder(side: BorderSide): BorderValue {
|
|
33
|
+
return { top: side, right: side, bottom: side, left: side };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** `true` when all 4 sides share width/style/color (safe to render collapsed). */
|
|
37
|
+
export function isUniformBorder(v: BorderValue): boolean {
|
|
38
|
+
const key = (s: BorderSide): string => `${s.width}|${s.style}|${s.color}`;
|
|
39
|
+
const k = key(v.top);
|
|
40
|
+
return key(v.right) === k && key(v.bottom) === k && key(v.left) === k;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface BorderEditorProps extends ChangeHandlers<BorderValue> {
|
|
44
|
+
recentColors?: string[] | undefined;
|
|
45
|
+
onAddRecentColor?: ((color: string) => void) | undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function BorderEditor({
|
|
49
|
+
value,
|
|
50
|
+
onChange,
|
|
51
|
+
onChangeEnd,
|
|
52
|
+
disabled,
|
|
53
|
+
recentColors,
|
|
54
|
+
onAddRecentColor,
|
|
55
|
+
}: BorderEditorProps): React.ReactElement {
|
|
56
|
+
const [expanded, setExpanded] = useState(!isUniformBorder(value));
|
|
57
|
+
|
|
58
|
+
const applyAll = (partial: Partial<BorderSide>, commit: boolean): void => {
|
|
59
|
+
const side = { ...value.top, ...partial };
|
|
60
|
+
const next = uniformBorder(side);
|
|
61
|
+
if (commit) fireEnd({ onChange, onChangeEnd }, next);
|
|
62
|
+
else fireChange({ onChange }, next);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const applySide = (
|
|
66
|
+
key: keyof BorderValue,
|
|
67
|
+
partial: Partial<BorderSide>,
|
|
68
|
+
commit: boolean,
|
|
69
|
+
): void => {
|
|
70
|
+
const next = { ...value, [key]: { ...value[key], ...partial } };
|
|
71
|
+
if (commit) fireEnd({ onChange, onChangeEnd }, next);
|
|
72
|
+
else fireChange({ onChange }, next);
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
if (!expanded) {
|
|
76
|
+
return (
|
|
77
|
+
<div
|
|
78
|
+
data-testid="border-editor-uniform"
|
|
79
|
+
style={{ display: 'flex', gap: 6, alignItems: 'center' }}
|
|
80
|
+
>
|
|
81
|
+
<ColorSwatch
|
|
82
|
+
testId="border-color"
|
|
83
|
+
value={value.top.color}
|
|
84
|
+
disabled={disabled}
|
|
85
|
+
onChange={(c) => applyAll({ color: c }, false)}
|
|
86
|
+
onChangeEnd={(c) => applyAll({ color: c }, true)}
|
|
87
|
+
recentColors={recentColors}
|
|
88
|
+
onAddRecentColor={onAddRecentColor}
|
|
89
|
+
/>
|
|
90
|
+
<ScrubbableInput
|
|
91
|
+
testId="border-width"
|
|
92
|
+
label="W"
|
|
93
|
+
value={value.top.width}
|
|
94
|
+
min={0}
|
|
95
|
+
disabled={disabled}
|
|
96
|
+
onChange={(v) => applyAll({ width: v }, false)}
|
|
97
|
+
onChangeEnd={(v) => applyAll({ width: v }, true)}
|
|
98
|
+
style={{ width: 64 }}
|
|
99
|
+
/>
|
|
100
|
+
<Select
|
|
101
|
+
className="vgai-select"
|
|
102
|
+
data-testid="border-style"
|
|
103
|
+
value={value.top.style}
|
|
104
|
+
disabled={disabled}
|
|
105
|
+
data-dynamic={disabled || undefined}
|
|
106
|
+
onChange={(e) => applyAll({ style: e.target.value }, true)}
|
|
107
|
+
style={{ flex: 1 }}
|
|
108
|
+
>
|
|
109
|
+
{BORDER_STYLES.map((o) => (
|
|
110
|
+
<option key={o} value={o}>
|
|
111
|
+
{o}
|
|
112
|
+
</option>
|
|
113
|
+
))}
|
|
114
|
+
</Select>
|
|
115
|
+
<Button
|
|
116
|
+
type="button"
|
|
117
|
+
data-testid="border-expand"
|
|
118
|
+
title="Per-side borders"
|
|
119
|
+
aria-label="Edit borders per side"
|
|
120
|
+
onClick={() => setExpanded(true)}
|
|
121
|
+
variant="ghost"
|
|
122
|
+
size="compact"
|
|
123
|
+
>
|
|
124
|
+
<EditorIcon icon={faBorderAll} />
|
|
125
|
+
</Button>
|
|
126
|
+
</div>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const sides: Array<{ key: keyof BorderValue; label: string }> = [
|
|
131
|
+
{ key: 'top', label: 'T' },
|
|
132
|
+
{ key: 'right', label: 'R' },
|
|
133
|
+
{ key: 'bottom', label: 'B' },
|
|
134
|
+
{ key: 'left', label: 'L' },
|
|
135
|
+
];
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<div
|
|
139
|
+
data-testid="border-editor-expanded"
|
|
140
|
+
style={{ display: 'flex', flexDirection: 'column', gap: 4 }}
|
|
141
|
+
>
|
|
142
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
|
143
|
+
<span style={{ fontSize: 10, color: THEME.textMuted }}>Per-side</span>
|
|
144
|
+
<div style={{ flex: 1 }} />
|
|
145
|
+
<Button
|
|
146
|
+
type="button"
|
|
147
|
+
variant="primary"
|
|
148
|
+
size="compact"
|
|
149
|
+
data-testid="border-collapse"
|
|
150
|
+
title="Uniform border"
|
|
151
|
+
aria-label="Use one border for all sides"
|
|
152
|
+
onClick={() => setExpanded(false)}
|
|
153
|
+
>
|
|
154
|
+
<EditorIcon icon={faBorderAll} />
|
|
155
|
+
</Button>
|
|
156
|
+
</div>
|
|
157
|
+
{sides.map(({ key, label }) => (
|
|
158
|
+
<div key={key} style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
|
159
|
+
<span style={{ fontSize: 10, color: THEME.textMuted, width: 10, textAlign: 'center' }}>
|
|
160
|
+
{label}
|
|
161
|
+
</span>
|
|
162
|
+
<ColorSwatch
|
|
163
|
+
testId={`border-${key}-color`}
|
|
164
|
+
value={value[key].color}
|
|
165
|
+
disabled={disabled}
|
|
166
|
+
onChange={(c) => applySide(key, { color: c }, false)}
|
|
167
|
+
onChangeEnd={(c) => applySide(key, { color: c }, true)}
|
|
168
|
+
recentColors={recentColors}
|
|
169
|
+
onAddRecentColor={onAddRecentColor}
|
|
170
|
+
/>
|
|
171
|
+
<ScrubbableInput
|
|
172
|
+
testId={`border-${key}-width`}
|
|
173
|
+
label=""
|
|
174
|
+
value={value[key].width}
|
|
175
|
+
min={0}
|
|
176
|
+
disabled={disabled}
|
|
177
|
+
onChange={(v) => applySide(key, { width: v }, false)}
|
|
178
|
+
onChangeEnd={(v) => applySide(key, { width: v }, true)}
|
|
179
|
+
style={{ width: 48 }}
|
|
180
|
+
/>
|
|
181
|
+
<Select
|
|
182
|
+
className="vgai-select"
|
|
183
|
+
data-testid={`border-${key}-style`}
|
|
184
|
+
value={value[key].style}
|
|
185
|
+
disabled={disabled}
|
|
186
|
+
data-density="compact"
|
|
187
|
+
data-dynamic={disabled || undefined}
|
|
188
|
+
onChange={(e) => applySide(key, { style: e.target.value }, true)}
|
|
189
|
+
style={{ flex: 1 }}
|
|
190
|
+
>
|
|
191
|
+
{BORDER_STYLES.map((o) => (
|
|
192
|
+
<option key={o} value={o}>
|
|
193
|
+
{o}
|
|
194
|
+
</option>
|
|
195
|
+
))}
|
|
196
|
+
</Select>
|
|
197
|
+
</div>
|
|
198
|
+
))}
|
|
199
|
+
</div>
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export interface BorderRadiusValue {
|
|
204
|
+
topLeft: number;
|
|
205
|
+
topRight: number;
|
|
206
|
+
bottomLeft: number;
|
|
207
|
+
bottomRight: number;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function uniformRadius(n: number): BorderRadiusValue {
|
|
211
|
+
return { topLeft: n, topRight: n, bottomLeft: n, bottomRight: n };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function isUniformRadius(v: BorderRadiusValue): boolean {
|
|
215
|
+
return v.topLeft === v.topRight && v.topRight === v.bottomLeft && v.bottomLeft === v.bottomRight;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function BorderRadiusEditor({
|
|
219
|
+
value,
|
|
220
|
+
onChange,
|
|
221
|
+
onChangeEnd,
|
|
222
|
+
disabled,
|
|
223
|
+
}: ChangeHandlers<BorderRadiusValue>): React.ReactElement {
|
|
224
|
+
const [expanded, setExpanded] = useState(!isUniformRadius(value));
|
|
225
|
+
|
|
226
|
+
const applyAll = (n: number, commit: boolean): void => {
|
|
227
|
+
const next = uniformRadius(n);
|
|
228
|
+
if (commit) fireEnd({ onChange, onChangeEnd }, next);
|
|
229
|
+
else fireChange({ onChange }, next);
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const applyCorner = (key: keyof BorderRadiusValue, n: number, commit: boolean): void => {
|
|
233
|
+
const next = { ...value, [key]: n };
|
|
234
|
+
if (commit) fireEnd({ onChange, onChangeEnd }, next);
|
|
235
|
+
else fireChange({ onChange }, next);
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
if (!expanded) {
|
|
239
|
+
return (
|
|
240
|
+
<div
|
|
241
|
+
data-testid="border-radius-editor-uniform"
|
|
242
|
+
style={{ display: 'flex', gap: 4, alignItems: 'center' }}
|
|
243
|
+
>
|
|
244
|
+
<ScrubbableInput
|
|
245
|
+
testId="border-radius"
|
|
246
|
+
label={<EditorIcon icon={faBorderTopLeft} label="Radius" />}
|
|
247
|
+
value={value.topLeft}
|
|
248
|
+
min={0}
|
|
249
|
+
disabled={disabled}
|
|
250
|
+
onChange={(v) => applyAll(v, false)}
|
|
251
|
+
onChangeEnd={(v) => applyAll(v, true)}
|
|
252
|
+
style={{ flex: 1 }}
|
|
253
|
+
/>
|
|
254
|
+
<Button
|
|
255
|
+
type="button"
|
|
256
|
+
data-testid="border-radius-expand"
|
|
257
|
+
title="Independent corners"
|
|
258
|
+
aria-label="Edit radius per corner"
|
|
259
|
+
onClick={() => setExpanded(true)}
|
|
260
|
+
variant="ghost"
|
|
261
|
+
size="compact"
|
|
262
|
+
>
|
|
263
|
+
<EditorIcon icon={faBorderTopLeft} />
|
|
264
|
+
</Button>
|
|
265
|
+
</div>
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const corners: Array<{ key: keyof BorderRadiusValue; label: string }> = [
|
|
270
|
+
{ key: 'topLeft', label: 'TL' },
|
|
271
|
+
{ key: 'topRight', label: 'TR' },
|
|
272
|
+
{ key: 'bottomLeft', label: 'BL' },
|
|
273
|
+
{ key: 'bottomRight', label: 'BR' },
|
|
274
|
+
];
|
|
275
|
+
|
|
276
|
+
return (
|
|
277
|
+
<div data-testid="border-radius-editor-expanded">
|
|
278
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 4 }}>
|
|
279
|
+
<span style={{ fontSize: 10, color: THEME.textMuted }}>Corners</span>
|
|
280
|
+
<div style={{ flex: 1 }} />
|
|
281
|
+
<Button
|
|
282
|
+
type="button"
|
|
283
|
+
variant="primary"
|
|
284
|
+
size="compact"
|
|
285
|
+
data-testid="border-radius-collapse"
|
|
286
|
+
title="Uniform radius"
|
|
287
|
+
aria-label="Use one radius for all corners"
|
|
288
|
+
onClick={() => setExpanded(false)}
|
|
289
|
+
>
|
|
290
|
+
<EditorIcon icon={faBorderTopLeft} />
|
|
291
|
+
</Button>
|
|
292
|
+
</div>
|
|
293
|
+
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 3 }}>
|
|
294
|
+
{corners.map(({ key, label }) => (
|
|
295
|
+
<ScrubbableInput
|
|
296
|
+
key={key}
|
|
297
|
+
testId={`border-radius-${key}`}
|
|
298
|
+
label={label}
|
|
299
|
+
value={value[key]}
|
|
300
|
+
min={0}
|
|
301
|
+
disabled={disabled}
|
|
302
|
+
onChange={(v) => applyCorner(key, v, false)}
|
|
303
|
+
onChangeEnd={(v) => applyCorner(key, v, true)}
|
|
304
|
+
/>
|
|
305
|
+
))}
|
|
306
|
+
</div>
|
|
307
|
+
</div>
|
|
308
|
+
);
|
|
309
|
+
}
|