@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,623 @@
|
|
|
1
|
+
import { Button, NumberInput } from '../design-system';
|
|
2
|
+
/**
|
|
3
|
+
* GradientEditor — solid/gradient toggle, stop list (add/remove/drag),
|
|
4
|
+
* linear/radial toggle, angle (spec 27 §5 C1). Ported from
|
|
5
|
+
* `visual-edit/inspector.tsx`'s `GradientEditor` (:823-989) — kept as a
|
|
6
|
+
* gradient-only value editor here (the reference's solid-vs-gradient mode
|
|
7
|
+
* switch that also drove `backgroundColor` is a Fill-SECTION concern; C2
|
|
8
|
+
* wires the section's solid/gradient toggle around this + `ColorSwatch`).
|
|
9
|
+
*
|
|
10
|
+
* W1b promotion: the draggable-stop TRACK is factored out as
|
|
11
|
+
* `GradientStopsTrack` and shared by TWO value adapters over the one core —
|
|
12
|
+
* this CSS `GradientValue` editor (unchanged contract + test-ids) and
|
|
13
|
+
* `QuarksGradientEditor`, which edits the particle system's
|
|
14
|
+
* three.quarks-shaped `Gradient` JSON (separate color keys `{value:{r,g,b},
|
|
15
|
+
* pos}` and alpha keys `{value:number, pos}` — the engine schema's
|
|
16
|
+
* `GradientSchema`, typed structurally here so the kit stays decoupled).
|
|
17
|
+
* One component, parameterized — not a fork.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { useState } from 'react';
|
|
21
|
+
import { ColorSwatch } from './ColorPicker';
|
|
22
|
+
import { rgbStringToHex } from './color-utils';
|
|
23
|
+
import { ScrubbableInput } from './ScrubbableInput';
|
|
24
|
+
import { type ChangeHandlers, fireChange, fireEnd, THEME, ToggleButton } from './shared';
|
|
25
|
+
|
|
26
|
+
export interface GradientStop {
|
|
27
|
+
color: string;
|
|
28
|
+
position: number; // 0..1
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface GradientValue {
|
|
32
|
+
type: 'linear' | 'radial';
|
|
33
|
+
angle: number; // degrees, linear only
|
|
34
|
+
stops: GradientStop[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function composeGradient(g: GradientValue): string {
|
|
38
|
+
const stopStr = g.stops.map((s) => `${s.color} ${Math.round(s.position * 100)}%`).join(', ');
|
|
39
|
+
return g.type === 'radial'
|
|
40
|
+
? `radial-gradient(circle, ${stopStr})`
|
|
41
|
+
: `linear-gradient(${g.angle}deg, ${stopStr})`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The `linear-gradient(...)`/`radial-gradient(...)` outer match -> `{type, inner}`,
|
|
45
|
+
* or `null` if `value` isn't either. Split out of `parseGradient` to keep
|
|
46
|
+
* each step under biome's cognitive-complexity ceiling. */
|
|
47
|
+
function matchGradientShell(value: string): { type: GradientValue['type']; inner: string } | null {
|
|
48
|
+
const linearMatch = value.match(/^linear-gradient\((.+)\)$/);
|
|
49
|
+
if (linearMatch?.[1] !== undefined) return { type: 'linear', inner: linearMatch[1] };
|
|
50
|
+
const radialMatch = value.match(/^radial-gradient\((.+)\)$/);
|
|
51
|
+
if (radialMatch?.[1] !== undefined) return { type: 'radial', inner: radialMatch[1] };
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Strips a leading `<N>deg,`/`to <dir>,` prefix off a linear gradient's
|
|
56
|
+
* inner string -> `{angle, colorPart}` (angle stays 180 — CSS's own default
|
|
57
|
+
* — when neither prefix is present). */
|
|
58
|
+
function stripLinearAngle(inner: string): { angle: number; colorPart: string } {
|
|
59
|
+
const angleMatch = inner.match(/^(\d+)deg\s*,\s*/);
|
|
60
|
+
if (angleMatch) {
|
|
61
|
+
return {
|
|
62
|
+
angle: Number.parseInt(angleMatch[1] ?? '180', 10),
|
|
63
|
+
colorPart: inner.slice(angleMatch[0].length),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
if (inner.startsWith('to ')) {
|
|
67
|
+
const dirMatch = inner.match(/^to\s+\w+\s*,\s*/);
|
|
68
|
+
if (dirMatch) return { angle: 180, colorPart: inner.slice(dirMatch[0].length) };
|
|
69
|
+
}
|
|
70
|
+
return { angle: 180, colorPart: inner };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** `"#fff 0%, #000 100%"` -> stops, auto-spreading any stop that omitted its
|
|
74
|
+
* `%` evenly across the run (matches the CSS spec's own auto-position rule). */
|
|
75
|
+
function parseGradientStops(colorPart: string): GradientStop[] {
|
|
76
|
+
const stops: GradientStop[] = colorPart.split(/,\s*(?![^(]*\))/).map((part) => {
|
|
77
|
+
const trimmed = part.trim();
|
|
78
|
+
const pctMatch = trimmed.match(/\s+(\d+)%$/);
|
|
79
|
+
const position = pctMatch ? Number.parseInt(pctMatch[1] ?? '0', 10) / 100 : -1;
|
|
80
|
+
const color = pctMatch ? trimmed.slice(0, -pctMatch[0].length).trim() : trimmed;
|
|
81
|
+
return { color, position };
|
|
82
|
+
});
|
|
83
|
+
for (let i = 0; i < stops.length; i++) {
|
|
84
|
+
const stop = stops[i];
|
|
85
|
+
if (stop && stop.position < 0) stop.position = stops.length > 1 ? i / (stops.length - 1) : 0;
|
|
86
|
+
}
|
|
87
|
+
return stops;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function parseGradient(value: string): GradientValue | null {
|
|
91
|
+
if (!value || value === 'none') return null;
|
|
92
|
+
const shell = matchGradientShell(value);
|
|
93
|
+
if (!shell) return null;
|
|
94
|
+
const { angle, colorPart } =
|
|
95
|
+
shell.type === 'linear'
|
|
96
|
+
? stripLinearAngle(shell.inner)
|
|
97
|
+
: { angle: 180, colorPart: shell.inner };
|
|
98
|
+
const stops = parseGradientStops(colorPart);
|
|
99
|
+
if (stops.length < 2) return null;
|
|
100
|
+
return { type: shell.type, angle, stops };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// Shared draggable-stops track (W1b) — the one core under both adapters.
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
export interface TrackStop {
|
|
108
|
+
/** 0..1 position along the track. */
|
|
109
|
+
position: number;
|
|
110
|
+
/** CSS color painted on the stop dot. */
|
|
111
|
+
css: string;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface GradientStopsTrackProps {
|
|
115
|
+
stops: TrackStop[];
|
|
116
|
+
selected: number;
|
|
117
|
+
onSelect: (index: number) => void;
|
|
118
|
+
/** Fires with the dragged stop's index + clamped 0..1 position; `commit`
|
|
119
|
+
* is false on every move, true once on release (the caller's undo step). */
|
|
120
|
+
onMove: (index: number, position: number, commit: boolean) => void;
|
|
121
|
+
disabled?: boolean | undefined;
|
|
122
|
+
/** Per-dot test ids are `${testIdPrefix}-${i}`. */
|
|
123
|
+
testIdPrefix: string;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function GradientStopsTrack({
|
|
127
|
+
stops,
|
|
128
|
+
selected,
|
|
129
|
+
onSelect,
|
|
130
|
+
onMove,
|
|
131
|
+
disabled,
|
|
132
|
+
testIdPrefix,
|
|
133
|
+
}: GradientStopsTrackProps): React.ReactElement {
|
|
134
|
+
return (
|
|
135
|
+
<div style={{ position: 'relative', height: 12, marginTop: 2 }}>
|
|
136
|
+
{stops.map((stop, i) => (
|
|
137
|
+
<div
|
|
138
|
+
key={i}
|
|
139
|
+
data-testid={`${testIdPrefix}-${i}`}
|
|
140
|
+
onClick={() => onSelect(i)}
|
|
141
|
+
onMouseDown={(e) => {
|
|
142
|
+
if (disabled) return;
|
|
143
|
+
e.preventDefault();
|
|
144
|
+
onSelect(i);
|
|
145
|
+
const parent = (e.target as HTMLElement).parentElement;
|
|
146
|
+
const rect = parent?.getBoundingClientRect();
|
|
147
|
+
const posAt = (clientX: number): number =>
|
|
148
|
+
rect?.width
|
|
149
|
+
? Math.max(0, Math.min(1, (clientX - rect.left) / rect.width))
|
|
150
|
+
: stop.position;
|
|
151
|
+
const onMoveEv = (me: MouseEvent): void => onMove(i, posAt(me.clientX), false);
|
|
152
|
+
const onUp = (me: MouseEvent): void => {
|
|
153
|
+
onMove(i, posAt(me.clientX), true);
|
|
154
|
+
window.removeEventListener('mousemove', onMoveEv);
|
|
155
|
+
window.removeEventListener('mouseup', onUp);
|
|
156
|
+
};
|
|
157
|
+
window.addEventListener('mousemove', onMoveEv);
|
|
158
|
+
window.addEventListener('mouseup', onUp);
|
|
159
|
+
}}
|
|
160
|
+
style={{
|
|
161
|
+
position: 'absolute',
|
|
162
|
+
left: `${stop.position * 100}%`,
|
|
163
|
+
top: 0,
|
|
164
|
+
width: 10,
|
|
165
|
+
height: 10,
|
|
166
|
+
borderRadius: '50%',
|
|
167
|
+
background: stop.css,
|
|
168
|
+
border: `2px solid ${i === selected ? THEME.accent : '#fff'}`,
|
|
169
|
+
transform: 'translateX(-50%)',
|
|
170
|
+
cursor: disabled ? 'not-allowed' : 'pointer',
|
|
171
|
+
}}
|
|
172
|
+
/>
|
|
173
|
+
))}
|
|
174
|
+
</div>
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export interface GradientEditorProps extends ChangeHandlers<GradientValue> {
|
|
179
|
+
recentColors?: string[] | undefined;
|
|
180
|
+
onAddRecentColor?: ((color: string) => void) | undefined;
|
|
181
|
+
/** Root-element test id. Defaults to the stable `gradient-editor` (the gallery
|
|
182
|
+
* / C1 contract); a caller that renders MORE THAN ONE gradient editor on a
|
|
183
|
+
* node (the inspector's `prop.background` + `style.backgroundImage` rows)
|
|
184
|
+
* passes a per-row id so the two roots don't collide (spec 27 §5 C2, S1). */
|
|
185
|
+
testId?: string | undefined;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function GradientEditor({
|
|
189
|
+
value,
|
|
190
|
+
onChange,
|
|
191
|
+
onChangeEnd,
|
|
192
|
+
disabled,
|
|
193
|
+
recentColors,
|
|
194
|
+
onAddRecentColor,
|
|
195
|
+
testId = 'gradient-editor',
|
|
196
|
+
}: GradientEditorProps): React.ReactElement {
|
|
197
|
+
const [selectedStop, setSelectedStop] = useState(0);
|
|
198
|
+
|
|
199
|
+
const apply = (next: GradientValue, commit: boolean): void => {
|
|
200
|
+
if (commit) fireEnd({ onChange, onChangeEnd }, next);
|
|
201
|
+
else fireChange({ onChange }, next);
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const setStop = (index: number, partial: Partial<GradientStop>, commit: boolean): void => {
|
|
205
|
+
const stops = value.stops.map((s, i) => (i === index ? { ...s, ...partial } : s));
|
|
206
|
+
apply({ ...value, stops }, commit);
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const addStop = (): void => {
|
|
210
|
+
const last = value.stops[value.stops.length - 1]?.position ?? 1;
|
|
211
|
+
const first = value.stops[0]?.position ?? 0;
|
|
212
|
+
const pos = (last + first) / 2;
|
|
213
|
+
const stops = [...value.stops, { color: '#888888', position: pos }].sort(
|
|
214
|
+
(a, b) => a.position - b.position,
|
|
215
|
+
);
|
|
216
|
+
setSelectedStop(stops.length - 1);
|
|
217
|
+
apply({ ...value, stops }, true);
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const removeStop = (index: number): void => {
|
|
221
|
+
if (value.stops.length <= 2) return;
|
|
222
|
+
const stops = value.stops.filter((_, i) => i !== index);
|
|
223
|
+
setSelectedStop(Math.min(selectedStop, stops.length - 1));
|
|
224
|
+
apply({ ...value, stops }, true);
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const stopColor = value.stops[selectedStop]?.color ?? '#000000';
|
|
228
|
+
const stopHex = stopColor.startsWith('#') ? stopColor : rgbStringToHex(stopColor);
|
|
229
|
+
|
|
230
|
+
return (
|
|
231
|
+
<div data-testid={testId}>
|
|
232
|
+
<div style={{ position: 'relative', marginBottom: 8 }}>
|
|
233
|
+
<div
|
|
234
|
+
data-testid="gradient-preview"
|
|
235
|
+
style={{
|
|
236
|
+
height: 20,
|
|
237
|
+
borderRadius: THEME.radiusSmall,
|
|
238
|
+
border: `1px solid ${THEME.border}`,
|
|
239
|
+
background: composeGradient(value),
|
|
240
|
+
}}
|
|
241
|
+
/>
|
|
242
|
+
<GradientStopsTrack
|
|
243
|
+
stops={value.stops.map((s) => ({ position: s.position, css: s.color }))}
|
|
244
|
+
selected={selectedStop}
|
|
245
|
+
onSelect={setSelectedStop}
|
|
246
|
+
onMove={(i, position, commit) => setStop(i, { position }, commit)}
|
|
247
|
+
disabled={disabled}
|
|
248
|
+
testIdPrefix="gradient-stop"
|
|
249
|
+
/>
|
|
250
|
+
</div>
|
|
251
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
|
|
252
|
+
<ColorSwatch
|
|
253
|
+
testId="gradient-selected-color"
|
|
254
|
+
value={stopHex}
|
|
255
|
+
disabled={disabled}
|
|
256
|
+
onChange={(c) => setStop(selectedStop, { color: c }, false)}
|
|
257
|
+
onChangeEnd={(c) => setStop(selectedStop, { color: c }, true)}
|
|
258
|
+
recentColors={recentColors}
|
|
259
|
+
onAddRecentColor={onAddRecentColor}
|
|
260
|
+
/>
|
|
261
|
+
<span style={{ fontSize: 9, color: THEME.textMuted }}>Stop {selectedStop + 1}</span>
|
|
262
|
+
{value.stops.length > 2 && (
|
|
263
|
+
<Button
|
|
264
|
+
type="button"
|
|
265
|
+
variant="ghost"
|
|
266
|
+
size="compact"
|
|
267
|
+
aria-label="Remove gradient stop"
|
|
268
|
+
data-testid="gradient-remove-stop"
|
|
269
|
+
onClick={() => removeStop(selectedStop)}
|
|
270
|
+
>
|
|
271
|
+
×
|
|
272
|
+
</Button>
|
|
273
|
+
)}
|
|
274
|
+
<Button
|
|
275
|
+
type="button"
|
|
276
|
+
variant="ghost"
|
|
277
|
+
size="compact"
|
|
278
|
+
data-testid="gradient-add-stop"
|
|
279
|
+
onClick={addStop}
|
|
280
|
+
>
|
|
281
|
+
+ stop
|
|
282
|
+
</Button>
|
|
283
|
+
</div>
|
|
284
|
+
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
|
285
|
+
<div style={{ display: 'flex', gap: 1, flex: 1 }}>
|
|
286
|
+
<ToggleButton
|
|
287
|
+
testId="gradient-type-linear"
|
|
288
|
+
label="Linear"
|
|
289
|
+
active={value.type === 'linear'}
|
|
290
|
+
disabled={disabled}
|
|
291
|
+
onClick={() => apply({ ...value, type: 'linear' }, true)}
|
|
292
|
+
/>
|
|
293
|
+
<ToggleButton
|
|
294
|
+
testId="gradient-type-radial"
|
|
295
|
+
label="Radial"
|
|
296
|
+
active={value.type === 'radial'}
|
|
297
|
+
disabled={disabled}
|
|
298
|
+
onClick={() => apply({ ...value, type: 'radial' }, true)}
|
|
299
|
+
/>
|
|
300
|
+
</div>
|
|
301
|
+
{value.type === 'linear' && (
|
|
302
|
+
<ScrubbableInput
|
|
303
|
+
testId="gradient-angle"
|
|
304
|
+
label="°"
|
|
305
|
+
value={value.angle}
|
|
306
|
+
unit="deg"
|
|
307
|
+
disabled={disabled}
|
|
308
|
+
onChange={(v) => apply({ ...value, angle: v }, false)}
|
|
309
|
+
onChangeEnd={(v) => apply({ ...value, angle: v }, true)}
|
|
310
|
+
style={{ width: 70 }}
|
|
311
|
+
/>
|
|
312
|
+
)}
|
|
313
|
+
</div>
|
|
314
|
+
</div>
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// ---------------------------------------------------------------------------
|
|
319
|
+
// QuarksGradientEditor (W1b) — the same stops-track core over the particle
|
|
320
|
+
// system's three.quarks `Gradient` JSON (engine `GradientSchema`, typed
|
|
321
|
+
// structurally): color keys {value:{r,g,b}, pos} + alpha keys {value, pos}.
|
|
322
|
+
// ---------------------------------------------------------------------------
|
|
323
|
+
|
|
324
|
+
export interface QuarksColorKey {
|
|
325
|
+
value: { r: number; g: number; b: number };
|
|
326
|
+
pos: number;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export interface QuarksAlphaKey {
|
|
330
|
+
value: number;
|
|
331
|
+
pos: number;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export interface QuarksGradientValue {
|
|
335
|
+
type: 'Gradient';
|
|
336
|
+
color: { type: 'CLinearFunction'; subType: 'Color'; keys: QuarksColorKey[] };
|
|
337
|
+
alpha: { type: 'CLinearFunction'; subType: 'Number'; keys: QuarksAlphaKey[] };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export interface QuarksGradientEditorProps extends ChangeHandlers<QuarksGradientValue> {
|
|
341
|
+
testId?: string | undefined;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function rgb01ToHex(c: { r: number; g: number; b: number }): string {
|
|
345
|
+
const h = (v: number): string =>
|
|
346
|
+
Math.round(Math.max(0, Math.min(1, v)) * 255)
|
|
347
|
+
.toString(16)
|
|
348
|
+
.padStart(2, '0');
|
|
349
|
+
return `#${h(c.r)}${h(c.g)}${h(c.b)}`;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function hexToRgb01(hex: string): { r: number; g: number; b: number } {
|
|
353
|
+
return {
|
|
354
|
+
r: Number.parseInt(hex.slice(1, 3), 16) / 255,
|
|
355
|
+
g: Number.parseInt(hex.slice(3, 5), 16) / 255,
|
|
356
|
+
b: Number.parseInt(hex.slice(5, 7), 16) / 255,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** Piecewise-linear sample of {value, pos} keys (keys sorted by pos). */
|
|
361
|
+
function sampleKeys<V>(
|
|
362
|
+
keys: { value: V; pos: number }[],
|
|
363
|
+
pos: number,
|
|
364
|
+
lerpValue: (a: V, b: V, u: number) => V,
|
|
365
|
+
): V | undefined {
|
|
366
|
+
if (keys.length === 0) return undefined;
|
|
367
|
+
if (pos <= keys[0]!.pos) return keys[0]!.value;
|
|
368
|
+
const last = keys[keys.length - 1]!;
|
|
369
|
+
if (pos >= last.pos) return last.value;
|
|
370
|
+
for (let i = 0; i + 1 < keys.length; i++) {
|
|
371
|
+
const a = keys[i]!;
|
|
372
|
+
const b = keys[i + 1]!;
|
|
373
|
+
if (pos >= a.pos && pos <= b.pos) {
|
|
374
|
+
const u = b.pos > a.pos ? (pos - a.pos) / (b.pos - a.pos) : 0;
|
|
375
|
+
return lerpValue(a.value, b.value, u);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return last.value;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const lerpNum = (a: number, b: number, u: number): number => a + (b - a) * u;
|
|
382
|
+
const lerpRgb = (
|
|
383
|
+
a: { r: number; g: number; b: number },
|
|
384
|
+
b: { r: number; g: number; b: number },
|
|
385
|
+
u: number,
|
|
386
|
+
): { r: number; g: number; b: number } => ({
|
|
387
|
+
r: lerpNum(a.r, b.r, u),
|
|
388
|
+
g: lerpNum(a.g, b.g, u),
|
|
389
|
+
b: lerpNum(a.b, b.b, u),
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
/** Move key `i` to `pos`, keeping the list sorted; returns the moved key's new index. */
|
|
393
|
+
function moveKeyPos<K extends { pos: number }>(
|
|
394
|
+
keys: K[],
|
|
395
|
+
i: number,
|
|
396
|
+
pos: number,
|
|
397
|
+
): { keys: K[]; index: number } {
|
|
398
|
+
const moved = { ...keys[i]!, pos };
|
|
399
|
+
const next = [...keys.filter((_, j) => j !== i), moved].sort((a, b) => a.pos - b.pos);
|
|
400
|
+
return { keys: next, index: next.indexOf(moved) };
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
export function QuarksGradientEditor({
|
|
404
|
+
value,
|
|
405
|
+
onChange,
|
|
406
|
+
onChangeEnd,
|
|
407
|
+
disabled,
|
|
408
|
+
testId = 'quarks-gradient',
|
|
409
|
+
}: QuarksGradientEditorProps): React.ReactElement {
|
|
410
|
+
const [sel, setSel] = useState<{ track: 'color' | 'alpha'; i: number }>({ track: 'color', i: 0 });
|
|
411
|
+
|
|
412
|
+
const apply = (next: QuarksGradientValue, commit: boolean): void => {
|
|
413
|
+
if (commit) fireEnd({ onChange, onChangeEnd }, next);
|
|
414
|
+
else fireChange({ onChange }, next);
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
const withColorKeys = (keys: QuarksColorKey[]): QuarksGradientValue => ({
|
|
418
|
+
...value,
|
|
419
|
+
color: { ...value.color, keys },
|
|
420
|
+
});
|
|
421
|
+
const withAlphaKeys = (keys: QuarksAlphaKey[]): QuarksGradientValue => ({
|
|
422
|
+
...value,
|
|
423
|
+
alpha: { ...value.alpha, keys },
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
// Combined rgba preview: sample color+alpha at the union of key positions.
|
|
427
|
+
const positions = [
|
|
428
|
+
...new Set([...value.color.keys.map((k) => k.pos), ...value.alpha.keys.map((k) => k.pos)]),
|
|
429
|
+
].sort((a, b) => a - b);
|
|
430
|
+
const previewStops = positions
|
|
431
|
+
.map((pos) => {
|
|
432
|
+
const c = sampleKeys(value.color.keys, pos, lerpRgb) ?? { r: 1, g: 1, b: 1 };
|
|
433
|
+
const a = sampleKeys(value.alpha.keys, pos, lerpNum) ?? 1;
|
|
434
|
+
return `rgba(${Math.round(c.r * 255)},${Math.round(c.g * 255)},${Math.round(c.b * 255)},${Math.round(a * 1000) / 1000}) ${Math.round(pos * 100)}%`;
|
|
435
|
+
})
|
|
436
|
+
.join(', ');
|
|
437
|
+
|
|
438
|
+
const selColorKey = value.color.keys[sel.track === 'color' ? sel.i : 0];
|
|
439
|
+
const selAlphaKey = value.alpha.keys[sel.track === 'alpha' ? sel.i : 0];
|
|
440
|
+
|
|
441
|
+
return (
|
|
442
|
+
<div data-testid={testId}>
|
|
443
|
+
{/* alpha track (Unity-style: above the bar) */}
|
|
444
|
+
<GradientStopsTrack
|
|
445
|
+
stops={value.alpha.keys.map((k) => {
|
|
446
|
+
const g = Math.round(Math.max(0, Math.min(1, k.value)) * 255);
|
|
447
|
+
return { position: k.pos, css: `rgb(${g},${g},${g})` };
|
|
448
|
+
})}
|
|
449
|
+
selected={sel.track === 'alpha' ? sel.i : -1}
|
|
450
|
+
onSelect={(i) => setSel({ track: 'alpha', i })}
|
|
451
|
+
onMove={(i, pos, commit) => {
|
|
452
|
+
const { keys, index } = moveKeyPos(value.alpha.keys, i, pos);
|
|
453
|
+
setSel({ track: 'alpha', i: index });
|
|
454
|
+
apply(withAlphaKeys(keys), commit);
|
|
455
|
+
}}
|
|
456
|
+
disabled={disabled}
|
|
457
|
+
testIdPrefix={`${testId}-alpha-stop`}
|
|
458
|
+
/>
|
|
459
|
+
|
|
460
|
+
{/* checkerboard + rgba gradient preview */}
|
|
461
|
+
<div
|
|
462
|
+
data-testid={`${testId}-preview`}
|
|
463
|
+
style={{
|
|
464
|
+
height: 20,
|
|
465
|
+
borderRadius: THEME.radiusSmall,
|
|
466
|
+
border: `1px solid ${THEME.border}`,
|
|
467
|
+
backgroundImage: `linear-gradient(90deg, ${previewStops}), repeating-linear-gradient(45deg, #777 0 5px, #aaa 5px 10px)`,
|
|
468
|
+
}}
|
|
469
|
+
/>
|
|
470
|
+
|
|
471
|
+
{/* color track */}
|
|
472
|
+
<GradientStopsTrack
|
|
473
|
+
stops={value.color.keys.map((k) => ({ position: k.pos, css: rgb01ToHex(k.value) }))}
|
|
474
|
+
selected={sel.track === 'color' ? sel.i : -1}
|
|
475
|
+
onSelect={(i) => setSel({ track: 'color', i })}
|
|
476
|
+
onMove={(i, pos, commit) => {
|
|
477
|
+
const { keys, index } = moveKeyPos(value.color.keys, i, pos);
|
|
478
|
+
setSel({ track: 'color', i: index });
|
|
479
|
+
apply(withColorKeys(keys), commit);
|
|
480
|
+
}}
|
|
481
|
+
disabled={disabled}
|
|
482
|
+
testIdPrefix={`${testId}-color-stop`}
|
|
483
|
+
/>
|
|
484
|
+
|
|
485
|
+
{/* selected-stop controls */}
|
|
486
|
+
{sel.track === 'color' && selColorKey && (
|
|
487
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 4 }}>
|
|
488
|
+
<ColorSwatch
|
|
489
|
+
testId={`${testId}-color-swatch`}
|
|
490
|
+
value={rgb01ToHex(selColorKey.value)}
|
|
491
|
+
disabled={disabled}
|
|
492
|
+
onChange={(c) =>
|
|
493
|
+
apply(
|
|
494
|
+
withColorKeys(
|
|
495
|
+
value.color.keys.map((k, j) =>
|
|
496
|
+
j === sel.i ? { ...k, value: hexToRgb01(c) } : k,
|
|
497
|
+
),
|
|
498
|
+
),
|
|
499
|
+
false,
|
|
500
|
+
)
|
|
501
|
+
}
|
|
502
|
+
onChangeEnd={(c) =>
|
|
503
|
+
apply(
|
|
504
|
+
withColorKeys(
|
|
505
|
+
value.color.keys.map((k, j) =>
|
|
506
|
+
j === sel.i ? { ...k, value: hexToRgb01(c) } : k,
|
|
507
|
+
),
|
|
508
|
+
),
|
|
509
|
+
true,
|
|
510
|
+
)
|
|
511
|
+
}
|
|
512
|
+
/>
|
|
513
|
+
<span style={{ fontSize: 9, color: THEME.textMuted }}>
|
|
514
|
+
Color {sel.i + 1}/{value.color.keys.length}
|
|
515
|
+
</span>
|
|
516
|
+
{value.color.keys.length > 2 && (
|
|
517
|
+
<Button
|
|
518
|
+
type="button"
|
|
519
|
+
variant="ghost"
|
|
520
|
+
size="compact"
|
|
521
|
+
aria-label="Remove color stop"
|
|
522
|
+
data-testid={`${testId}-remove-color-stop`}
|
|
523
|
+
onClick={() => {
|
|
524
|
+
const keys = value.color.keys.filter((_, j) => j !== sel.i);
|
|
525
|
+
setSel({ track: 'color', i: Math.min(sel.i, keys.length - 1) });
|
|
526
|
+
apply(withColorKeys(keys), true);
|
|
527
|
+
}}
|
|
528
|
+
>
|
|
529
|
+
×
|
|
530
|
+
</Button>
|
|
531
|
+
)}
|
|
532
|
+
<Button
|
|
533
|
+
type="button"
|
|
534
|
+
variant="ghost"
|
|
535
|
+
size="compact"
|
|
536
|
+
data-testid={`${testId}-add-color-stop`}
|
|
537
|
+
onClick={() => {
|
|
538
|
+
const pos = 0.5;
|
|
539
|
+
const color = sampleKeys(value.color.keys, pos, lerpRgb) ?? { r: 1, g: 1, b: 1 };
|
|
540
|
+
const { keys, index } = moveKeyPos(
|
|
541
|
+
[...value.color.keys, { value: color, pos }],
|
|
542
|
+
value.color.keys.length,
|
|
543
|
+
pos,
|
|
544
|
+
);
|
|
545
|
+
setSel({ track: 'color', i: index });
|
|
546
|
+
apply(withColorKeys(keys), true);
|
|
547
|
+
}}
|
|
548
|
+
>
|
|
549
|
+
+ stop
|
|
550
|
+
</Button>
|
|
551
|
+
</div>
|
|
552
|
+
)}
|
|
553
|
+
{sel.track === 'alpha' && selAlphaKey && (
|
|
554
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 4 }}>
|
|
555
|
+
<NumberInput
|
|
556
|
+
label="a"
|
|
557
|
+
step={0.05}
|
|
558
|
+
testId={`${testId}-alpha-value`}
|
|
559
|
+
value={Math.round(selAlphaKey.value * 1000) / 1000}
|
|
560
|
+
onChange={(v) =>
|
|
561
|
+
apply(
|
|
562
|
+
withAlphaKeys(
|
|
563
|
+
value.alpha.keys.map((k, j) =>
|
|
564
|
+
j === sel.i ? { ...k, value: Math.max(0, Math.min(1, v)) } : k,
|
|
565
|
+
),
|
|
566
|
+
),
|
|
567
|
+
false,
|
|
568
|
+
)
|
|
569
|
+
}
|
|
570
|
+
onChangeEnd={(v) =>
|
|
571
|
+
apply(
|
|
572
|
+
withAlphaKeys(
|
|
573
|
+
value.alpha.keys.map((k, j) =>
|
|
574
|
+
j === sel.i ? { ...k, value: Math.max(0, Math.min(1, v)) } : k,
|
|
575
|
+
),
|
|
576
|
+
),
|
|
577
|
+
true,
|
|
578
|
+
)
|
|
579
|
+
}
|
|
580
|
+
/>
|
|
581
|
+
<span style={{ fontSize: 9, color: THEME.textMuted }}>
|
|
582
|
+
Alpha {sel.i + 1}/{value.alpha.keys.length}
|
|
583
|
+
</span>
|
|
584
|
+
{value.alpha.keys.length > 2 && (
|
|
585
|
+
<Button
|
|
586
|
+
type="button"
|
|
587
|
+
variant="ghost"
|
|
588
|
+
size="compact"
|
|
589
|
+
aria-label="Remove alpha stop"
|
|
590
|
+
data-testid={`${testId}-remove-alpha-stop`}
|
|
591
|
+
onClick={() => {
|
|
592
|
+
const keys = value.alpha.keys.filter((_, j) => j !== sel.i);
|
|
593
|
+
setSel({ track: 'alpha', i: Math.min(sel.i, keys.length - 1) });
|
|
594
|
+
apply(withAlphaKeys(keys), true);
|
|
595
|
+
}}
|
|
596
|
+
>
|
|
597
|
+
×
|
|
598
|
+
</Button>
|
|
599
|
+
)}
|
|
600
|
+
<Button
|
|
601
|
+
type="button"
|
|
602
|
+
variant="ghost"
|
|
603
|
+
size="compact"
|
|
604
|
+
data-testid={`${testId}-add-alpha-stop`}
|
|
605
|
+
onClick={() => {
|
|
606
|
+
const pos = 0.5;
|
|
607
|
+
const alpha = sampleKeys(value.alpha.keys, pos, lerpNum) ?? 1;
|
|
608
|
+
const { keys, index } = moveKeyPos(
|
|
609
|
+
[...value.alpha.keys, { value: alpha, pos }],
|
|
610
|
+
value.alpha.keys.length,
|
|
611
|
+
pos,
|
|
612
|
+
);
|
|
613
|
+
setSel({ track: 'alpha', i: index });
|
|
614
|
+
apply(withAlphaKeys(keys), true);
|
|
615
|
+
}}
|
|
616
|
+
>
|
|
617
|
+
+ stop
|
|
618
|
+
</Button>
|
|
619
|
+
</div>
|
|
620
|
+
)}
|
|
621
|
+
</div>
|
|
622
|
+
);
|
|
623
|
+
}
|