@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,180 @@
|
|
|
1
|
+
import { TextInput } from '../design-system';
|
|
2
|
+
/**
|
|
3
|
+
* ScrubbableInput — a numeric input whose LABEL is a drag-to-scrub handle
|
|
4
|
+
* (spec 27 §5 C1). Ported from `visual-edit/inspector.tsx`'s `ScrubbableInput`
|
|
5
|
+
* (:107-219): drag the label left/right to change the number, Shift = ×10,
|
|
6
|
+
* Alt = ×0.1, Escape cancels back to the drag-start value. Unlike the
|
|
7
|
+
* reference (which threaded a `prop` string + string value through a
|
|
8
|
+
* shared style-bag callback), this widget's contract is the kit-wide pure
|
|
9
|
+
* `value: number` / `onChange` / `onChangeEnd` (see `shared.tsx`'s doc
|
|
10
|
+
* comment) — `unit` is display-only (appended to the rendered text, stripped
|
|
11
|
+
* on parse), keeping the value itself a plain number for callers to compose
|
|
12
|
+
* into whatever CSS string they write.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { type ReactNode, useMemo, useRef, useState } from 'react';
|
|
16
|
+
import { type ChangeHandlers, fireChange, fireEnd, rowStyle, THEME } from './shared';
|
|
17
|
+
|
|
18
|
+
export interface ScrubbableInputProps extends ChangeHandlers<number> {
|
|
19
|
+
label: ReactNode;
|
|
20
|
+
unit?: string | undefined;
|
|
21
|
+
step?: number | undefined;
|
|
22
|
+
min?: number | undefined;
|
|
23
|
+
max?: number | undefined;
|
|
24
|
+
precision?: number | undefined;
|
|
25
|
+
testId?: string | undefined;
|
|
26
|
+
style?: React.CSSProperties | undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function ScrubbableInput({
|
|
30
|
+
label,
|
|
31
|
+
value,
|
|
32
|
+
unit = '',
|
|
33
|
+
step = 1,
|
|
34
|
+
min,
|
|
35
|
+
max,
|
|
36
|
+
precision,
|
|
37
|
+
disabled,
|
|
38
|
+
onChange,
|
|
39
|
+
onChangeEnd,
|
|
40
|
+
testId,
|
|
41
|
+
style: extraStyle,
|
|
42
|
+
}: ScrubbableInputProps): React.ReactElement {
|
|
43
|
+
const dragRef = useRef<{ startX: number; startValue: number; last: number } | null>(null);
|
|
44
|
+
|
|
45
|
+
const effectivePrecision = precision ?? (step < 1 ? 2 : step >= 100 ? -2 : 0);
|
|
46
|
+
|
|
47
|
+
const round = useMemo(
|
|
48
|
+
() =>
|
|
49
|
+
(n: number): number => {
|
|
50
|
+
if (effectivePrecision < 0) {
|
|
51
|
+
const factor = 10 ** -effectivePrecision;
|
|
52
|
+
return Math.round(n / factor) * factor;
|
|
53
|
+
}
|
|
54
|
+
const factor = 10 ** effectivePrecision;
|
|
55
|
+
return Math.round(n * factor) / factor;
|
|
56
|
+
},
|
|
57
|
+
[effectivePrecision],
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const clamp = (n: number): number => {
|
|
61
|
+
let v = n;
|
|
62
|
+
if (min !== undefined) v = Math.max(min, v);
|
|
63
|
+
if (max !== undefined) v = Math.min(max, v);
|
|
64
|
+
return round(v);
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const handleScrubStart = (e: React.MouseEvent): void => {
|
|
68
|
+
if (disabled) return;
|
|
69
|
+
e.preventDefault();
|
|
70
|
+
dragRef.current = { startX: e.clientX, startValue: value, last: value };
|
|
71
|
+
document.body.style.cursor = 'ew-resize';
|
|
72
|
+
|
|
73
|
+
const handleMove = (me: MouseEvent): void => {
|
|
74
|
+
if (!dragRef.current) return;
|
|
75
|
+
me.preventDefault();
|
|
76
|
+
const delta = me.clientX - dragRef.current.startX;
|
|
77
|
+
const multiplier = me.shiftKey ? 10 : me.altKey ? 0.1 : 1;
|
|
78
|
+
const next = clamp(dragRef.current.startValue + delta * step * multiplier);
|
|
79
|
+
dragRef.current.last = next;
|
|
80
|
+
fireChange({ onChange }, next);
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const cleanup = (): void => {
|
|
84
|
+
document.body.style.cursor = '';
|
|
85
|
+
window.removeEventListener('mousemove', handleMove);
|
|
86
|
+
window.removeEventListener('mouseup', handleUp);
|
|
87
|
+
window.removeEventListener('keydown', handleKeyDuringScrub);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const handleUp = (): void => {
|
|
91
|
+
if (dragRef.current) {
|
|
92
|
+
const final = dragRef.current.last;
|
|
93
|
+
dragRef.current = null;
|
|
94
|
+
fireEnd({ onChange, onChangeEnd }, final);
|
|
95
|
+
}
|
|
96
|
+
cleanup();
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const handleKeyDuringScrub = (ke: KeyboardEvent): void => {
|
|
100
|
+
if (ke.key === 'Escape' && dragRef.current) {
|
|
101
|
+
const original = clamp(dragRef.current.startValue);
|
|
102
|
+
dragRef.current = null;
|
|
103
|
+
fireChange({ onChange }, original);
|
|
104
|
+
fireEnd({ onChange, onChangeEnd }, original);
|
|
105
|
+
cleanup();
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
window.addEventListener('mousemove', handleMove);
|
|
110
|
+
window.addEventListener('mouseup', handleUp);
|
|
111
|
+
window.addEventListener('keydown', handleKeyDuringScrub);
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
// TYPING IS A DRAFT, COMMITTED ONCE. The text field used to be controlled
|
|
115
|
+
// by the live value and fire `onChange` per keystroke, so the first digit
|
|
116
|
+
// was WRITTEN, the write re-projected the board, the field re-rendered
|
|
117
|
+
// from the new value (and lost focus with it) and every later
|
|
118
|
+
// digit went nowhere — typing 257 into a width left it at 2 (measured on
|
|
119
|
+
// production build 59; runhuman pass 135: "it changed to 237.3"). While
|
|
120
|
+
// focused the field shows its own draft, the whole value selected on
|
|
121
|
+
// entry; Enter or blur commits, Escape cancels. Scrubbing the label is
|
|
122
|
+
// unchanged — that path IS live by design.
|
|
123
|
+
const [draft, setDraft] = useState<string | null>(null);
|
|
124
|
+
const cancelled = useRef(false);
|
|
125
|
+
const commitText = (raw: string): void => {
|
|
126
|
+
const n = Number.parseFloat(raw);
|
|
127
|
+
if (Number.isNaN(n)) return;
|
|
128
|
+
const next = clamp(n);
|
|
129
|
+
if (next === value) return;
|
|
130
|
+
fireEnd({ onChange, onChangeEnd }, next);
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
return (
|
|
134
|
+
<div style={{ ...rowStyle, ...extraStyle }}>
|
|
135
|
+
<span
|
|
136
|
+
className="vgai-scrub-label"
|
|
137
|
+
data-testid={testId ? `${testId}-label` : undefined}
|
|
138
|
+
onMouseDown={handleScrubStart}
|
|
139
|
+
data-disabled={disabled || undefined}
|
|
140
|
+
style={{
|
|
141
|
+
fontSize: 11,
|
|
142
|
+
// Resting/hover color comes from `.vgai-scrub-label` in theme.css —
|
|
143
|
+
// an inline color here forced the hover rule into `!important`.
|
|
144
|
+
...(disabled ? { color: THEME.dynamic } : {}),
|
|
145
|
+
flexShrink: 0,
|
|
146
|
+
cursor: disabled ? 'not-allowed' : 'ew-resize',
|
|
147
|
+
userSelect: 'none',
|
|
148
|
+
fontWeight: 500,
|
|
149
|
+
}}
|
|
150
|
+
>
|
|
151
|
+
{label}
|
|
152
|
+
</span>
|
|
153
|
+
<TextInput
|
|
154
|
+
data-testid={testId}
|
|
155
|
+
type="text"
|
|
156
|
+
value={draft ?? `${value}${unit}`}
|
|
157
|
+
readOnly={disabled}
|
|
158
|
+
data-dynamic={disabled || undefined}
|
|
159
|
+
onFocus={(e) => {
|
|
160
|
+
setDraft(`${value}${unit}`);
|
|
161
|
+
e.target.select();
|
|
162
|
+
}}
|
|
163
|
+
onChange={(e) => setDraft(e.target.value)}
|
|
164
|
+
onBlur={(e) => {
|
|
165
|
+
if (!cancelled.current) commitText(e.target.value);
|
|
166
|
+
cancelled.current = false;
|
|
167
|
+
setDraft(null);
|
|
168
|
+
}}
|
|
169
|
+
onKeyDown={(e) => {
|
|
170
|
+
if (e.key === 'Enter') (e.target as HTMLInputElement).blur();
|
|
171
|
+
if (e.key === 'Escape') {
|
|
172
|
+
cancelled.current = true;
|
|
173
|
+
(e.target as HTMLInputElement).blur();
|
|
174
|
+
}
|
|
175
|
+
}}
|
|
176
|
+
style={{ flex: 1, width: 0 }}
|
|
177
|
+
/>
|
|
178
|
+
</div>
|
|
179
|
+
);
|
|
180
|
+
}
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import { Button, Checkbox } from '../design-system';
|
|
2
|
+
/**
|
|
3
|
+
* ShadowEditor / TextShadowEditor — a list of box/text shadows (add/remove,
|
|
4
|
+
* x/y/blur/(spread)/color, inset for box-shadow) (spec 27 §5 C1). Ported from
|
|
5
|
+
* `visual-edit/inspector.tsx`'s `ShadowEditor`/`ShadowRow` (:632-769) and
|
|
6
|
+
* `TextShadowEditor`/`TextShadowRow` (:1622-1788) — folded into one generic
|
|
7
|
+
* `ShadowListEditor` since the two are the same list-of-rows shape with
|
|
8
|
+
* `spread`/`inset` present only for box-shadow.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { ColorSwatch } from './ColorPicker';
|
|
12
|
+
import { rgbStringToHex } from './color-utils';
|
|
13
|
+
import { ScrubbableInput } from './ScrubbableInput';
|
|
14
|
+
import { type ChangeHandlers, fireChange, fireEnd, THEME } from './shared';
|
|
15
|
+
|
|
16
|
+
export interface ShadowValue {
|
|
17
|
+
x: number;
|
|
18
|
+
y: number;
|
|
19
|
+
blur: number;
|
|
20
|
+
spread: number;
|
|
21
|
+
color: string;
|
|
22
|
+
inset: boolean;
|
|
23
|
+
enabled: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const DEFAULT_SHADOW: ShadowValue = {
|
|
27
|
+
x: 0,
|
|
28
|
+
y: 4,
|
|
29
|
+
blur: 8,
|
|
30
|
+
spread: 0,
|
|
31
|
+
color: 'rgba(0,0,0,0.25)',
|
|
32
|
+
inset: false,
|
|
33
|
+
enabled: true,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export const DEFAULT_TEXT_SHADOW: ShadowValue = {
|
|
37
|
+
x: 0,
|
|
38
|
+
y: 2,
|
|
39
|
+
blur: 4,
|
|
40
|
+
spread: 0,
|
|
41
|
+
color: 'rgba(0,0,0,0.3)',
|
|
42
|
+
inset: false,
|
|
43
|
+
enabled: true,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
function composeOne(s: ShadowValue, withSpreadInset: boolean): string {
|
|
47
|
+
const parts = withSpreadInset
|
|
48
|
+
? [s.inset ? 'inset' : '', `${s.x}px`, `${s.y}px`, `${s.blur}px`, `${s.spread}px`, s.color]
|
|
49
|
+
: [`${s.x}px`, `${s.y}px`, `${s.blur}px`, s.color];
|
|
50
|
+
return parts.filter(Boolean).join(' ');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** `boxShadow`/`textShadow` CSS string <-> the widget's `ShadowValue[]`. */
|
|
54
|
+
export function composeShadows(shadows: ShadowValue[], withSpreadInset = true): string {
|
|
55
|
+
const enabled = shadows.filter((s) => s.enabled);
|
|
56
|
+
if (enabled.length === 0) return 'none';
|
|
57
|
+
return enabled.map((s) => composeOne(s, withSpreadInset)).join(', ');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Pulls the color token (`rgba(...)`/hex) out of a shadow's numeric-part
|
|
61
|
+
* tail -> `{color, nums}` (nums = whatever's left to parse as x/y/blur/
|
|
62
|
+
* spread). Split out of `parseOne` to keep it under biome's
|
|
63
|
+
* cognitive-complexity ceiling. */
|
|
64
|
+
function extractShadowColor(rest: string): { color: string; nums: string } {
|
|
65
|
+
const rgbMatch = rest.match(/(rgba?\([^)]+\))/);
|
|
66
|
+
if (rgbMatch?.[1]) return { color: rgbMatch[1], nums: rest.replace(rgbMatch[1], '').trim() };
|
|
67
|
+
const hexMatch = rest.match(/(#[0-9a-fA-F]{3,8})/);
|
|
68
|
+
if (hexMatch?.[1]) return { color: hexMatch[1], nums: rest.replace(hexMatch[1], '').trim() };
|
|
69
|
+
return { color: '#000000', nums: rest };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function parseOne(value: string, withSpreadInset: boolean): Omit<ShadowValue, 'enabled'> | null {
|
|
73
|
+
if (!value || value === 'none') return null;
|
|
74
|
+
const v = value.trim();
|
|
75
|
+
const inset = withSpreadInset && v.startsWith('inset');
|
|
76
|
+
const rest = inset ? v.slice(5).trim() : v;
|
|
77
|
+
const { color, nums } = extractShadowColor(rest);
|
|
78
|
+
const parts = nums
|
|
79
|
+
.split(/\s+/)
|
|
80
|
+
.map((s) => Number.parseFloat(s))
|
|
81
|
+
.filter((n) => !Number.isNaN(n));
|
|
82
|
+
if (withSpreadInset) {
|
|
83
|
+
return {
|
|
84
|
+
inset,
|
|
85
|
+
x: parts[0] ?? 0,
|
|
86
|
+
y: parts[1] ?? 0,
|
|
87
|
+
blur: parts[2] ?? 0,
|
|
88
|
+
spread: parts[3] ?? 0,
|
|
89
|
+
color,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
if (parts.length < 2) return null;
|
|
93
|
+
return {
|
|
94
|
+
inset: false,
|
|
95
|
+
x: parts[0] ?? 0,
|
|
96
|
+
y: parts[1] ?? 0,
|
|
97
|
+
blur: parts[2] ?? 0,
|
|
98
|
+
spread: 0,
|
|
99
|
+
color,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function parseShadows(value: string, withSpreadInset = true): ShadowValue[] {
|
|
104
|
+
if (!value || value === 'none') return [];
|
|
105
|
+
const parts = value.split(/,\s*(?![^(]*\))/);
|
|
106
|
+
const result: ShadowValue[] = [];
|
|
107
|
+
for (const part of parts) {
|
|
108
|
+
const p = parseOne(part.trim(), withSpreadInset);
|
|
109
|
+
if (p) result.push({ ...p, enabled: true });
|
|
110
|
+
}
|
|
111
|
+
return result;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface ShadowListEditorProps extends ChangeHandlers<ShadowValue[]> {
|
|
115
|
+
/** `false` for text-shadow: hides the spread/inset controls. */
|
|
116
|
+
withSpreadInset?: boolean | undefined;
|
|
117
|
+
label?: string | undefined;
|
|
118
|
+
testId?: string | undefined;
|
|
119
|
+
recentColors?: string[] | undefined;
|
|
120
|
+
onAddRecentColor?: ((color: string) => void) | undefined;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function ShadowListEditor({
|
|
124
|
+
value,
|
|
125
|
+
onChange,
|
|
126
|
+
onChangeEnd,
|
|
127
|
+
disabled,
|
|
128
|
+
withSpreadInset = true,
|
|
129
|
+
label = 'Shadow',
|
|
130
|
+
testId = 'shadow-editor',
|
|
131
|
+
recentColors,
|
|
132
|
+
onAddRecentColor,
|
|
133
|
+
}: ShadowListEditorProps): React.ReactElement {
|
|
134
|
+
const apply = (next: ShadowValue[], commit: boolean): void => {
|
|
135
|
+
if (commit) fireEnd({ onChange, onChangeEnd }, next);
|
|
136
|
+
else fireChange({ onChange }, next);
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const update = (index: number, partial: Partial<ShadowValue>, commit = false): void => {
|
|
140
|
+
apply(
|
|
141
|
+
value.map((s, i) => (i === index ? { ...s, ...partial } : s)),
|
|
142
|
+
commit,
|
|
143
|
+
);
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const remove = (index: number): void => {
|
|
147
|
+
apply(
|
|
148
|
+
value.filter((_, i) => i !== index),
|
|
149
|
+
true,
|
|
150
|
+
);
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const add = (): void => {
|
|
154
|
+
apply([...value, { ...(withSpreadInset ? DEFAULT_SHADOW : DEFAULT_TEXT_SHADOW) }], true);
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
return (
|
|
158
|
+
<div data-testid={testId}>
|
|
159
|
+
<div
|
|
160
|
+
style={{
|
|
161
|
+
display: 'flex',
|
|
162
|
+
alignItems: 'center',
|
|
163
|
+
gap: 6,
|
|
164
|
+
marginBottom: value.length > 0 ? 4 : 0,
|
|
165
|
+
}}
|
|
166
|
+
>
|
|
167
|
+
<span style={{ fontSize: 9, color: THEME.textMuted }}>{label}</span>
|
|
168
|
+
<div style={{ flex: 1 }} />
|
|
169
|
+
{!disabled && (
|
|
170
|
+
<Button
|
|
171
|
+
type="button"
|
|
172
|
+
variant="ghost"
|
|
173
|
+
size="compact"
|
|
174
|
+
data-testid={`${testId}-add`}
|
|
175
|
+
onClick={add}
|
|
176
|
+
>
|
|
177
|
+
+
|
|
178
|
+
</Button>
|
|
179
|
+
)}
|
|
180
|
+
</div>
|
|
181
|
+
{value.map((shadow, i) => {
|
|
182
|
+
const hexColor = shadow.color.startsWith('#') ? shadow.color : rgbStringToHex(shadow.color);
|
|
183
|
+
return (
|
|
184
|
+
<div
|
|
185
|
+
key={i}
|
|
186
|
+
data-testid={`${testId}-row-${i}`}
|
|
187
|
+
style={{ marginBottom: 6, padding: '4px 0', borderBottom: `1px solid ${THEME.border}` }}
|
|
188
|
+
>
|
|
189
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 4 }}>
|
|
190
|
+
<Button
|
|
191
|
+
type="button"
|
|
192
|
+
variant="ghost"
|
|
193
|
+
size="compact"
|
|
194
|
+
aria-pressed={shadow.enabled}
|
|
195
|
+
data-testid={`${testId}-toggle-${i}`}
|
|
196
|
+
onClick={() => {
|
|
197
|
+
if (!disabled) update(i, { enabled: !shadow.enabled }, true);
|
|
198
|
+
}}
|
|
199
|
+
>
|
|
200
|
+
{shadow.enabled ? '●' : '○'}
|
|
201
|
+
</Button>
|
|
202
|
+
<ColorSwatch
|
|
203
|
+
testId={`${testId}-color-${i}`}
|
|
204
|
+
value={hexColor}
|
|
205
|
+
disabled={disabled}
|
|
206
|
+
onChange={(c) => update(i, { color: c })}
|
|
207
|
+
onChangeEnd={(c) => update(i, { color: c }, true)}
|
|
208
|
+
recentColors={recentColors}
|
|
209
|
+
onAddRecentColor={onAddRecentColor}
|
|
210
|
+
/>
|
|
211
|
+
{withSpreadInset && (
|
|
212
|
+
<label
|
|
213
|
+
style={{
|
|
214
|
+
display: 'flex',
|
|
215
|
+
alignItems: 'center',
|
|
216
|
+
gap: 2,
|
|
217
|
+
fontSize: 8,
|
|
218
|
+
color: THEME.textMuted,
|
|
219
|
+
cursor: 'pointer',
|
|
220
|
+
}}
|
|
221
|
+
>
|
|
222
|
+
<Checkbox
|
|
223
|
+
// Standard checkbox size via the shared class (I-30: this
|
|
224
|
+
// was the one checkbox in the codebase with its own 10px
|
|
225
|
+
// explicit dimensions).
|
|
226
|
+
className="vgai-checkbox"
|
|
227
|
+
data-testid={`${testId}-inset-${i}`}
|
|
228
|
+
checked={shadow.inset}
|
|
229
|
+
disabled={disabled}
|
|
230
|
+
onChange={(e) => update(i, { inset: e.target.checked }, true)}
|
|
231
|
+
/>
|
|
232
|
+
in
|
|
233
|
+
</label>
|
|
234
|
+
)}
|
|
235
|
+
<div style={{ flex: 1 }} />
|
|
236
|
+
{!disabled && (
|
|
237
|
+
<Button
|
|
238
|
+
type="button"
|
|
239
|
+
variant="ghost"
|
|
240
|
+
size="compact"
|
|
241
|
+
aria-label={`Remove ${label.toLowerCase()} ${i + 1}`}
|
|
242
|
+
data-testid={`${testId}-remove-${i}`}
|
|
243
|
+
onClick={() => remove(i)}
|
|
244
|
+
>
|
|
245
|
+
×
|
|
246
|
+
</Button>
|
|
247
|
+
)}
|
|
248
|
+
</div>
|
|
249
|
+
{shadow.enabled && (
|
|
250
|
+
<div style={{ display: 'flex', gap: 4 }}>
|
|
251
|
+
<ScrubbableInput
|
|
252
|
+
label="X"
|
|
253
|
+
value={shadow.x}
|
|
254
|
+
disabled={disabled}
|
|
255
|
+
onChange={(v) => update(i, { x: v })}
|
|
256
|
+
onChangeEnd={(v) => update(i, { x: v }, true)}
|
|
257
|
+
style={{ flex: 1 }}
|
|
258
|
+
/>
|
|
259
|
+
<ScrubbableInput
|
|
260
|
+
label="Y"
|
|
261
|
+
value={shadow.y}
|
|
262
|
+
disabled={disabled}
|
|
263
|
+
onChange={(v) => update(i, { y: v })}
|
|
264
|
+
onChangeEnd={(v) => update(i, { y: v }, true)}
|
|
265
|
+
style={{ flex: 1 }}
|
|
266
|
+
/>
|
|
267
|
+
<ScrubbableInput
|
|
268
|
+
label="B"
|
|
269
|
+
value={shadow.blur}
|
|
270
|
+
min={0}
|
|
271
|
+
disabled={disabled}
|
|
272
|
+
onChange={(v) => update(i, { blur: Math.max(0, v) })}
|
|
273
|
+
onChangeEnd={(v) => update(i, { blur: Math.max(0, v) }, true)}
|
|
274
|
+
style={{ flex: 1 }}
|
|
275
|
+
/>
|
|
276
|
+
{withSpreadInset && (
|
|
277
|
+
<ScrubbableInput
|
|
278
|
+
label="S"
|
|
279
|
+
value={shadow.spread}
|
|
280
|
+
disabled={disabled}
|
|
281
|
+
onChange={(v) => update(i, { spread: v })}
|
|
282
|
+
onChangeEnd={(v) => update(i, { spread: v }, true)}
|
|
283
|
+
style={{ flex: 1 }}
|
|
284
|
+
/>
|
|
285
|
+
)}
|
|
286
|
+
</div>
|
|
287
|
+
)}
|
|
288
|
+
</div>
|
|
289
|
+
);
|
|
290
|
+
})}
|
|
291
|
+
</div>
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export function ShadowEditor(
|
|
296
|
+
props: Omit<ShadowListEditorProps, 'withSpreadInset' | 'testId'>,
|
|
297
|
+
): React.ReactElement {
|
|
298
|
+
return (
|
|
299
|
+
<ShadowListEditor
|
|
300
|
+
{...props}
|
|
301
|
+
withSpreadInset
|
|
302
|
+
testId="box-shadow-editor"
|
|
303
|
+
label={props.label ?? 'Shadow'}
|
|
304
|
+
/>
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export function TextShadowEditor(
|
|
309
|
+
props: Omit<ShadowListEditorProps, 'withSpreadInset' | 'testId'>,
|
|
310
|
+
): React.ReactElement {
|
|
311
|
+
return (
|
|
312
|
+
<ShadowListEditor
|
|
313
|
+
{...props}
|
|
314
|
+
withSpreadInset={false}
|
|
315
|
+
testId="text-shadow-editor"
|
|
316
|
+
label={props.label ?? 'Text Shadow'}
|
|
317
|
+
/>
|
|
318
|
+
);
|
|
319
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Color math for the inspector widget kit (spec 27 §5 C1). Ported from
|
|
3
|
+
* `visual-edit/inspector.tsx`'s free-floating hex/rgb/hsv/hsl helpers
|
|
4
|
+
* (993-1035:2781-2855) — pure functions, zero deps, zero DOM. Colour-CHAIN
|
|
5
|
+
* resolution (the eyedropper's "what's the effective background here" logic)
|
|
6
|
+
* is NOT re-implemented here — that's `effectiveColorFromChain` in
|
|
7
|
+
* `ui-source/inspect.ts`, reused as-is by `ColorPicker.tsx`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export type ColorFormat = 'hex' | 'rgb' | 'hsl';
|
|
11
|
+
|
|
12
|
+
export function hexToRgb(hex: string): [number, number, number] {
|
|
13
|
+
let h = hex.replace('#', '');
|
|
14
|
+
if (h.length === 3)
|
|
15
|
+
h = `${h[0] ?? '0'}${h[0] ?? '0'}${h[1] ?? '0'}${h[1] ?? '0'}${h[2] ?? '0'}${h[2] ?? '0'}`;
|
|
16
|
+
const n = Number.parseInt(h, 16);
|
|
17
|
+
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function rgbToHex(r: number, g: number, b: number): string {
|
|
21
|
+
return `#${[r, g, b]
|
|
22
|
+
.map((c) =>
|
|
23
|
+
Math.round(Math.max(0, Math.min(255, c)))
|
|
24
|
+
.toString(16)
|
|
25
|
+
.padStart(2, '0'),
|
|
26
|
+
)
|
|
27
|
+
.join('')}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function rgbToHsv(r: number, g: number, b: number): [number, number, number] {
|
|
31
|
+
const rn = r / 255;
|
|
32
|
+
const gn = g / 255;
|
|
33
|
+
const bn = b / 255;
|
|
34
|
+
const max = Math.max(rn, gn, bn);
|
|
35
|
+
const min = Math.min(rn, gn, bn);
|
|
36
|
+
const d = max - min;
|
|
37
|
+
let h = 0;
|
|
38
|
+
if (d !== 0) {
|
|
39
|
+
if (max === rn) h = ((((gn - bn) / d) % 6) + 6) % 6;
|
|
40
|
+
else if (max === gn) h = (bn - rn) / d + 2;
|
|
41
|
+
else h = (rn - gn) / d + 4;
|
|
42
|
+
h /= 6;
|
|
43
|
+
}
|
|
44
|
+
const s = max === 0 ? 0 : d / max;
|
|
45
|
+
return [h, s, max];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function hsvToRgb(h: number, s: number, v: number): [number, number, number] {
|
|
49
|
+
const i = Math.floor(h * 6);
|
|
50
|
+
const f = h * 6 - i;
|
|
51
|
+
const p = v * (1 - s);
|
|
52
|
+
const q = v * (1 - f * s);
|
|
53
|
+
const t = v * (1 - (1 - f) * s);
|
|
54
|
+
let r = 0;
|
|
55
|
+
let g = 0;
|
|
56
|
+
let b = 0;
|
|
57
|
+
switch (i % 6) {
|
|
58
|
+
case 0:
|
|
59
|
+
r = v;
|
|
60
|
+
g = t;
|
|
61
|
+
b = p;
|
|
62
|
+
break;
|
|
63
|
+
case 1:
|
|
64
|
+
r = q;
|
|
65
|
+
g = v;
|
|
66
|
+
b = p;
|
|
67
|
+
break;
|
|
68
|
+
case 2:
|
|
69
|
+
r = p;
|
|
70
|
+
g = v;
|
|
71
|
+
b = t;
|
|
72
|
+
break;
|
|
73
|
+
case 3:
|
|
74
|
+
r = p;
|
|
75
|
+
g = q;
|
|
76
|
+
b = v;
|
|
77
|
+
break;
|
|
78
|
+
case 4:
|
|
79
|
+
r = t;
|
|
80
|
+
g = p;
|
|
81
|
+
b = v;
|
|
82
|
+
break;
|
|
83
|
+
case 5:
|
|
84
|
+
r = v;
|
|
85
|
+
g = p;
|
|
86
|
+
b = q;
|
|
87
|
+
break;
|
|
88
|
+
default:
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function hsvToHex(h: number, s: number, v: number): string {
|
|
95
|
+
const [r, g, b] = hsvToRgb(h, s, v);
|
|
96
|
+
return rgbToHex(r, g, b);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function hslToRgb(h: number, s: number, l: number): [number, number, number] {
|
|
100
|
+
let r: number;
|
|
101
|
+
let g: number;
|
|
102
|
+
let b: number;
|
|
103
|
+
if (s === 0) {
|
|
104
|
+
r = g = b = l;
|
|
105
|
+
} else {
|
|
106
|
+
const hue2rgb = (p: number, q: number, t0: number): number => {
|
|
107
|
+
let t = t0;
|
|
108
|
+
if (t < 0) t += 1;
|
|
109
|
+
if (t > 1) t -= 1;
|
|
110
|
+
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
|
111
|
+
if (t < 1 / 2) return q;
|
|
112
|
+
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
|
113
|
+
return p;
|
|
114
|
+
};
|
|
115
|
+
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
|
116
|
+
const p = 2 * l - q;
|
|
117
|
+
r = hue2rgb(p, q, h + 1 / 3);
|
|
118
|
+
g = hue2rgb(p, q, h);
|
|
119
|
+
b = hue2rgb(p, q, h - 1 / 3);
|
|
120
|
+
}
|
|
121
|
+
return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function rgbToHsl(r: number, g: number, b: number): [number, number, number] {
|
|
125
|
+
const rn = r / 255;
|
|
126
|
+
const gn = g / 255;
|
|
127
|
+
const bn = b / 255;
|
|
128
|
+
const max = Math.max(rn, gn, bn);
|
|
129
|
+
const min = Math.min(rn, gn, bn);
|
|
130
|
+
let h = 0;
|
|
131
|
+
let s = 0;
|
|
132
|
+
const l = (max + min) / 2;
|
|
133
|
+
if (max !== min) {
|
|
134
|
+
const d = max - min;
|
|
135
|
+
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
|
136
|
+
if (max === rn) h = ((gn - bn) / d + (gn < bn ? 6 : 0)) / 6;
|
|
137
|
+
else if (max === gn) h = ((bn - rn) / d + 2) / 6;
|
|
138
|
+
else h = ((rn - gn) / d + 4) / 6;
|
|
139
|
+
}
|
|
140
|
+
return [h, s, l];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Normalize any hex/rgb(a)/hsl(a) string to a 6-digit hex (alpha dropped). */
|
|
144
|
+
export function toHex(color: string): string {
|
|
145
|
+
if (color.startsWith('#') && (color.length === 4 || color.length === 7)) return color;
|
|
146
|
+
if (color.startsWith('#') && color.length === 9) return color.slice(0, 7); // strip alpha from #RRGGBBAA
|
|
147
|
+
const m = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
|
|
148
|
+
if (m) {
|
|
149
|
+
const [, r, g, b] = m;
|
|
150
|
+
return `#${[r ?? '0', g ?? '0', b ?? '0'].map((c) => Number.parseInt(c, 10).toString(16).padStart(2, '0')).join('')}`;
|
|
151
|
+
}
|
|
152
|
+
const hsl = color.match(/hsla?\((\d+),\s*(\d+)%?,\s*(\d+)%?/);
|
|
153
|
+
if (hsl) {
|
|
154
|
+
const [r, g, b] = hslToRgb(
|
|
155
|
+
Number.parseInt(hsl[1] ?? '0', 10) / 360,
|
|
156
|
+
Number.parseInt(hsl[2] ?? '0', 10) / 100,
|
|
157
|
+
Number.parseInt(hsl[3] ?? '0', 10) / 100,
|
|
158
|
+
);
|
|
159
|
+
return rgbToHex(r, g, b);
|
|
160
|
+
}
|
|
161
|
+
return '#000000';
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function parseAlpha(color: string): number {
|
|
165
|
+
const rgbaMatch = color.match(/rgba\([^,]+,[^,]+,[^,]+,\s*([\d.]+)\)/);
|
|
166
|
+
if (rgbaMatch?.[1]) return Number.parseFloat(rgbaMatch[1]);
|
|
167
|
+
const hslaMatch = color.match(/hsla\([^,]+,[^,]+,[^,]+,\s*([\d.]+)\)/);
|
|
168
|
+
if (hslaMatch?.[1]) return Number.parseFloat(hslaMatch[1]);
|
|
169
|
+
if (color.startsWith('#') && color.length === 9)
|
|
170
|
+
return Number.parseInt(color.slice(7, 9), 16) / 255;
|
|
171
|
+
return 1;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function colorToString(
|
|
175
|
+
r: number,
|
|
176
|
+
g: number,
|
|
177
|
+
b: number,
|
|
178
|
+
a: number,
|
|
179
|
+
format: ColorFormat,
|
|
180
|
+
): string {
|
|
181
|
+
if (format === 'hex') {
|
|
182
|
+
const hex = rgbToHex(r, g, b);
|
|
183
|
+
return a < 1 ? `rgba(${r}, ${g}, ${b}, ${a.toFixed(2)})` : hex;
|
|
184
|
+
}
|
|
185
|
+
if (format === 'rgb') {
|
|
186
|
+
return a < 1 ? `rgba(${r}, ${g}, ${b}, ${a.toFixed(2)})` : `rgb(${r}, ${g}, ${b})`;
|
|
187
|
+
}
|
|
188
|
+
const [h, s, l] = rgbToHsl(r, g, b);
|
|
189
|
+
const hDeg = Math.round(h * 360);
|
|
190
|
+
const sPct = Math.round(s * 100);
|
|
191
|
+
const lPct = Math.round(l * 100);
|
|
192
|
+
return a < 1
|
|
193
|
+
? `hsla(${hDeg}, ${sPct}%, ${lPct}%, ${a.toFixed(2)})`
|
|
194
|
+
: `hsl(${hDeg}, ${sPct}%, ${lPct}%)`;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function rgbStringToHex(rgb: string): string {
|
|
198
|
+
const m = rgb.match(/\d+/g);
|
|
199
|
+
if (!m || m.length < 3) return '#000000';
|
|
200
|
+
return `#${[m[0] ?? '0', m[1] ?? '0', m[2] ?? '0'].map((n) => Number.parseInt(n, 10).toString(16).padStart(2, '0')).join('')}`;
|
|
201
|
+
}
|