@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,359 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CurveEditor (W1b) — SVG piecewise-bezier curve editor over the
|
|
3
|
+
* `CurveSegment[]` shape (`curve-utils.ts`), which is structurally the
|
|
4
|
+
* engine schema's `PiecewiseBezier.functions` AND three.quarks' native JSON.
|
|
5
|
+
*
|
|
6
|
+
* Deliberately generic (value curve in, curve out — zero particle coupling):
|
|
7
|
+
* the particle inspector's over-lifetime modules use it today; future
|
|
8
|
+
* consumers reuse it as-is.
|
|
9
|
+
*
|
|
10
|
+
* Interactions: drag keys (endpoints value-only, interior keys time+value),
|
|
11
|
+
* drag tangent handles of the selected key, double-click the plot to insert
|
|
12
|
+
* a key (exact de Casteljau split), Delete/Backspace or the footer × to
|
|
13
|
+
* remove an interior key. Y-range auto-fits to the curve with axis labels.
|
|
14
|
+
* Contract: `ChangeHandlers<CurveSegment[]>` — `onChange` fires live during
|
|
15
|
+
* a drag, `onChangeEnd` once per gesture (the caller's single undo step).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { useMemo, useRef, useState } from 'react';
|
|
19
|
+
import { Button, NumberInput } from '../design-system';
|
|
20
|
+
import {
|
|
21
|
+
type CurveSegment,
|
|
22
|
+
curveKeys,
|
|
23
|
+
curveValueRange,
|
|
24
|
+
deleteKey,
|
|
25
|
+
moveKey,
|
|
26
|
+
moveTangent,
|
|
27
|
+
segmentEnd,
|
|
28
|
+
splitAt,
|
|
29
|
+
} from './curve-utils';
|
|
30
|
+
import { type ChangeHandlers, fireChange, fireEnd, THEME } from './shared';
|
|
31
|
+
|
|
32
|
+
export type { CurveBezier, CurveKey, CurveSegment } from './curve-utils';
|
|
33
|
+
|
|
34
|
+
export interface CurveEditorProps extends ChangeHandlers<CurveSegment[]> {
|
|
35
|
+
/** Plot height in px (default 90). */
|
|
36
|
+
height?: number | undefined;
|
|
37
|
+
/** Curve stroke color (default the theme accent). */
|
|
38
|
+
color?: string | undefined;
|
|
39
|
+
/** Root-element test id (default `curve-editor`). */
|
|
40
|
+
testId?: string | undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const PAD_L = 34; // room for y-axis labels
|
|
44
|
+
const PAD_R = 6;
|
|
45
|
+
const PAD_T = 6;
|
|
46
|
+
const PAD_B = 14; // room for x-axis labels
|
|
47
|
+
const WIDTH = 260; // viewBox width — the element itself is 100% wide
|
|
48
|
+
|
|
49
|
+
function formatAxis(v: number): string {
|
|
50
|
+
if (Math.abs(v) >= 100) return v.toFixed(0);
|
|
51
|
+
if (Math.abs(v) >= 1) return String(Math.round(v * 100) / 100);
|
|
52
|
+
return String(Math.round(v * 1000) / 1000);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function CurveEditor({
|
|
56
|
+
value,
|
|
57
|
+
onChange,
|
|
58
|
+
onChangeEnd,
|
|
59
|
+
disabled,
|
|
60
|
+
height = 90,
|
|
61
|
+
color,
|
|
62
|
+
testId = 'curve-editor',
|
|
63
|
+
}: CurveEditorProps): React.ReactElement {
|
|
64
|
+
const [selectedKey, setSelectedKey] = useState(0);
|
|
65
|
+
const svgRef = useRef<SVGSVGElement | null>(null);
|
|
66
|
+
// The in-flight gesture's latest curve — window-level listeners see stale
|
|
67
|
+
// React props, so drags accumulate against this ref, not `value`.
|
|
68
|
+
const dragCurve = useRef<CurveSegment[] | null>(null);
|
|
69
|
+
|
|
70
|
+
const keys = useMemo(() => curveKeys(value), [value]);
|
|
71
|
+
|
|
72
|
+
// --- plot coordinate mapping (y auto-fit with padding) ---
|
|
73
|
+
const range = useMemo(() => {
|
|
74
|
+
const { min, max } = curveValueRange(value);
|
|
75
|
+
if (max - min < 1e-6) return { min: min - 1, max: max + 1 };
|
|
76
|
+
const pad = (max - min) * 0.1;
|
|
77
|
+
return { min: min - pad, max: max + pad };
|
|
78
|
+
}, [value]);
|
|
79
|
+
|
|
80
|
+
const plotW = WIDTH - PAD_L - PAD_R;
|
|
81
|
+
const plotH = height - PAD_T - PAD_B;
|
|
82
|
+
const xOf = (t: number): number => PAD_L + t * plotW;
|
|
83
|
+
const yOf = (v: number): number =>
|
|
84
|
+
PAD_T + (1 - (v - range.min) / (range.max - range.min)) * plotH;
|
|
85
|
+
const tOf = (x: number): number => Math.max(0, Math.min(1, (x - PAD_L) / plotW));
|
|
86
|
+
const vOf = (y: number): number =>
|
|
87
|
+
range.min + (1 - (y - PAD_T) / plotH) * (range.max - range.min);
|
|
88
|
+
|
|
89
|
+
/** Pointer event → plot-space (t, v) using the rendered element's box. */
|
|
90
|
+
const plotPoint = (e: { clientX: number; clientY: number }): { t: number; v: number } => {
|
|
91
|
+
const rect = svgRef.current?.getBoundingClientRect();
|
|
92
|
+
if (!rect) return { t: 0, v: 0 };
|
|
93
|
+
const x = ((e.clientX - rect.left) / rect.width) * WIDTH;
|
|
94
|
+
const y = ((e.clientY - rect.top) / rect.height) * height;
|
|
95
|
+
return { t: tOf(x), v: vOf(y) };
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// The curve path: x is linear in each segment's normalized time, so the
|
|
99
|
+
// plotted curve is an EXACT svg cubic with x-controls at 1/3 and 2/3.
|
|
100
|
+
const path = useMemo(() => {
|
|
101
|
+
if (value.length === 0) return '';
|
|
102
|
+
let d = `M ${xOf(value[0]!.start)} ${yOf(value[0]!.function.p0)}`;
|
|
103
|
+
for (let i = 0; i < value.length; i++) {
|
|
104
|
+
const s = value[i]!;
|
|
105
|
+
const end = segmentEnd(value, i);
|
|
106
|
+
const w = end - s.start;
|
|
107
|
+
d += ` C ${xOf(s.start + w / 3)} ${yOf(s.function.p1)}, ${xOf(s.start + (2 * w) / 3)} ${yOf(s.function.p2)}, ${xOf(end)} ${yOf(s.function.p3)}`;
|
|
108
|
+
}
|
|
109
|
+
return d;
|
|
110
|
+
}, [value, range, height]);
|
|
111
|
+
|
|
112
|
+
const commit = (next: CurveSegment[]): void => fireEnd({ onChange, onChangeEnd }, next);
|
|
113
|
+
const live = (next: CurveSegment[]): void => fireChange({ onChange }, next);
|
|
114
|
+
|
|
115
|
+
/** Shared drag loop: apply(t, v) produces the next curve from a pointer position. */
|
|
116
|
+
const beginDrag = (
|
|
117
|
+
e: React.PointerEvent,
|
|
118
|
+
apply: (base: CurveSegment[], t: number, v: number) => CurveSegment[],
|
|
119
|
+
): void => {
|
|
120
|
+
if (disabled) return;
|
|
121
|
+
e.preventDefault();
|
|
122
|
+
e.stopPropagation();
|
|
123
|
+
dragCurve.current = value;
|
|
124
|
+
const onMove = (me: PointerEvent): void => {
|
|
125
|
+
const { t, v } = plotPoint(me);
|
|
126
|
+
const next = apply(dragCurve.current ?? value, t, v);
|
|
127
|
+
dragCurve.current = next;
|
|
128
|
+
live(next);
|
|
129
|
+
};
|
|
130
|
+
const onUp = (me: PointerEvent): void => {
|
|
131
|
+
const { t, v } = plotPoint(me);
|
|
132
|
+
const next = apply(dragCurve.current ?? value, t, v);
|
|
133
|
+
dragCurve.current = null;
|
|
134
|
+
commit(next);
|
|
135
|
+
window.removeEventListener('pointermove', onMove);
|
|
136
|
+
window.removeEventListener('pointerup', onUp);
|
|
137
|
+
};
|
|
138
|
+
window.addEventListener('pointermove', onMove);
|
|
139
|
+
window.addEventListener('pointerup', onUp);
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const stroke = color ?? THEME.accent;
|
|
143
|
+
const n = value.length;
|
|
144
|
+
const selected = keys[selectedKey];
|
|
145
|
+
const canDelete = selectedKey > 0 && selectedKey < n;
|
|
146
|
+
|
|
147
|
+
// Tangent handle geometry for the selected key.
|
|
148
|
+
const outSeg = selectedKey < n ? value[selectedKey] : undefined;
|
|
149
|
+
const inSeg = selectedKey > 0 ? value[selectedKey - 1] : undefined;
|
|
150
|
+
const outHandle = outSeg
|
|
151
|
+
? {
|
|
152
|
+
x: xOf(outSeg.start + (segmentEnd(value, selectedKey) - outSeg.start) / 3),
|
|
153
|
+
y: yOf(outSeg.function.p1),
|
|
154
|
+
}
|
|
155
|
+
: null;
|
|
156
|
+
const inHandle = inSeg
|
|
157
|
+
? {
|
|
158
|
+
x: xOf(inSeg.start + ((segmentEnd(value, selectedKey - 1) - inSeg.start) * 2) / 3),
|
|
159
|
+
y: yOf(inSeg.function.p2),
|
|
160
|
+
}
|
|
161
|
+
: null;
|
|
162
|
+
|
|
163
|
+
const yTicks = [range.min, (range.min + range.max) / 2, range.max];
|
|
164
|
+
|
|
165
|
+
return (
|
|
166
|
+
<div data-testid={testId}>
|
|
167
|
+
<svg
|
|
168
|
+
ref={svgRef}
|
|
169
|
+
data-testid={`${testId}-plot`}
|
|
170
|
+
viewBox={`0 0 ${WIDTH} ${height}`}
|
|
171
|
+
style={{
|
|
172
|
+
width: '100%',
|
|
173
|
+
height,
|
|
174
|
+
display: 'block',
|
|
175
|
+
background: THEME.inputBg,
|
|
176
|
+
border: `1px solid ${THEME.border}`,
|
|
177
|
+
borderRadius: THEME.radiusSmall,
|
|
178
|
+
touchAction: 'none',
|
|
179
|
+
outline: 'none',
|
|
180
|
+
}}
|
|
181
|
+
// biome-ignore lint/a11y/noNoninteractiveTabindex: the plot IS the interactive surface (role=application); focus enables the Delete-key path
|
|
182
|
+
tabIndex={0}
|
|
183
|
+
role="application"
|
|
184
|
+
aria-label="Curve editor"
|
|
185
|
+
onKeyDown={(e) => {
|
|
186
|
+
if ((e.key === 'Delete' || e.key === 'Backspace') && canDelete && !disabled) {
|
|
187
|
+
e.preventDefault();
|
|
188
|
+
commit(deleteKey(value, selectedKey));
|
|
189
|
+
setSelectedKey(Math.max(0, selectedKey - 1));
|
|
190
|
+
}
|
|
191
|
+
}}
|
|
192
|
+
onDoubleClick={(e) => {
|
|
193
|
+
if (disabled) return;
|
|
194
|
+
const { t } = plotPoint(e);
|
|
195
|
+
const next = splitAt(value, t);
|
|
196
|
+
if (next !== value) {
|
|
197
|
+
commit(next);
|
|
198
|
+
setSelectedKey(next.findIndex((s) => Math.abs(s.start - t) < 1e-6));
|
|
199
|
+
}
|
|
200
|
+
}}
|
|
201
|
+
>
|
|
202
|
+
{/* axes + grid */}
|
|
203
|
+
{yTicks.map((v) => (
|
|
204
|
+
<g key={v}>
|
|
205
|
+
<line
|
|
206
|
+
x1={PAD_L}
|
|
207
|
+
x2={WIDTH - PAD_R}
|
|
208
|
+
y1={yOf(v)}
|
|
209
|
+
y2={yOf(v)}
|
|
210
|
+
stroke={THEME.border}
|
|
211
|
+
strokeWidth={0.5}
|
|
212
|
+
/>
|
|
213
|
+
<text
|
|
214
|
+
x={PAD_L - 3}
|
|
215
|
+
y={yOf(v) + 2.5}
|
|
216
|
+
fontSize={7}
|
|
217
|
+
fill={THEME.textMuted}
|
|
218
|
+
textAnchor="end"
|
|
219
|
+
>
|
|
220
|
+
{formatAxis(v)}
|
|
221
|
+
</text>
|
|
222
|
+
</g>
|
|
223
|
+
))}
|
|
224
|
+
{[0, 0.5, 1].map((t) => (
|
|
225
|
+
<text
|
|
226
|
+
key={t}
|
|
227
|
+
x={xOf(t)}
|
|
228
|
+
y={height - 3}
|
|
229
|
+
fontSize={7}
|
|
230
|
+
fill={THEME.textMuted}
|
|
231
|
+
textAnchor="middle"
|
|
232
|
+
>
|
|
233
|
+
{t}
|
|
234
|
+
</text>
|
|
235
|
+
))}
|
|
236
|
+
|
|
237
|
+
{/* the curve */}
|
|
238
|
+
<path d={path} fill="none" stroke={stroke} strokeWidth={1.5} />
|
|
239
|
+
|
|
240
|
+
{/* tangent handles for the selected key */}
|
|
241
|
+
{selected && outHandle && (
|
|
242
|
+
<g>
|
|
243
|
+
<line
|
|
244
|
+
x1={xOf(selected.time)}
|
|
245
|
+
y1={yOf(selected.value)}
|
|
246
|
+
x2={outHandle.x}
|
|
247
|
+
y2={outHandle.y}
|
|
248
|
+
stroke={THEME.textMuted}
|
|
249
|
+
strokeDasharray="2 2"
|
|
250
|
+
strokeWidth={0.75}
|
|
251
|
+
/>
|
|
252
|
+
<circle
|
|
253
|
+
data-testid={`${testId}-tangent-out`}
|
|
254
|
+
cx={outHandle.x}
|
|
255
|
+
cy={outHandle.y}
|
|
256
|
+
r={3}
|
|
257
|
+
fill={THEME.surface}
|
|
258
|
+
stroke={THEME.textMuted}
|
|
259
|
+
style={{ cursor: disabled ? 'not-allowed' : 'ns-resize' }}
|
|
260
|
+
onPointerDown={(e) =>
|
|
261
|
+
beginDrag(e, (base, _t, v) => moveTangent(base, selectedKey, 'out', v))
|
|
262
|
+
}
|
|
263
|
+
/>
|
|
264
|
+
</g>
|
|
265
|
+
)}
|
|
266
|
+
{selected && inHandle && (
|
|
267
|
+
<g>
|
|
268
|
+
<line
|
|
269
|
+
x1={xOf(selected.time)}
|
|
270
|
+
y1={yOf(selected.value)}
|
|
271
|
+
x2={inHandle.x}
|
|
272
|
+
y2={inHandle.y}
|
|
273
|
+
stroke={THEME.textMuted}
|
|
274
|
+
strokeDasharray="2 2"
|
|
275
|
+
strokeWidth={0.75}
|
|
276
|
+
/>
|
|
277
|
+
<circle
|
|
278
|
+
data-testid={`${testId}-tangent-in`}
|
|
279
|
+
cx={inHandle.x}
|
|
280
|
+
cy={inHandle.y}
|
|
281
|
+
r={3}
|
|
282
|
+
fill={THEME.surface}
|
|
283
|
+
stroke={THEME.textMuted}
|
|
284
|
+
style={{ cursor: disabled ? 'not-allowed' : 'ns-resize' }}
|
|
285
|
+
onPointerDown={(e) =>
|
|
286
|
+
beginDrag(e, (base, _t, v) => moveTangent(base, selectedKey, 'in', v))
|
|
287
|
+
}
|
|
288
|
+
/>
|
|
289
|
+
</g>
|
|
290
|
+
)}
|
|
291
|
+
|
|
292
|
+
{/* keys */}
|
|
293
|
+
{keys.map((k, i) => (
|
|
294
|
+
<circle
|
|
295
|
+
key={i}
|
|
296
|
+
data-testid={`${testId}-key-${i}`}
|
|
297
|
+
cx={xOf(k.time)}
|
|
298
|
+
cy={yOf(k.value)}
|
|
299
|
+
r={4}
|
|
300
|
+
fill={i === selectedKey ? THEME.accent : THEME.surface}
|
|
301
|
+
stroke={i === selectedKey ? THEME.onAccent : stroke}
|
|
302
|
+
strokeWidth={1.25}
|
|
303
|
+
style={{ cursor: disabled ? 'not-allowed' : 'move' }}
|
|
304
|
+
onPointerDown={(e) => {
|
|
305
|
+
setSelectedKey(i);
|
|
306
|
+
beginDrag(e, (base, t, v) => moveKey(base, i, t, v));
|
|
307
|
+
}}
|
|
308
|
+
/>
|
|
309
|
+
))}
|
|
310
|
+
</svg>
|
|
311
|
+
|
|
312
|
+
{/* selected-key detail row */}
|
|
313
|
+
<div style={{ display: 'flex', gap: 4, alignItems: 'center', marginTop: 3 }}>
|
|
314
|
+
<span style={{ fontSize: 9, color: THEME.textMuted, flex: '0 0 auto' }}>
|
|
315
|
+
Key {selectedKey + 1}/{keys.length}
|
|
316
|
+
</span>
|
|
317
|
+
<NumberInput
|
|
318
|
+
label="t"
|
|
319
|
+
value={selected ? Math.round(selected.time * 1000) / 1000 : null}
|
|
320
|
+
step={0.05}
|
|
321
|
+
testId={`${testId}-key-time`}
|
|
322
|
+
onChange={(t) => {
|
|
323
|
+
if (selected) live(moveKey(value, selectedKey, t, selected.value));
|
|
324
|
+
}}
|
|
325
|
+
onChangeEnd={(t) => {
|
|
326
|
+
if (selected) commit(moveKey(value, selectedKey, t, selected.value));
|
|
327
|
+
}}
|
|
328
|
+
/>
|
|
329
|
+
<NumberInput
|
|
330
|
+
label="v"
|
|
331
|
+
value={selected ? Math.round(selected.value * 1000) / 1000 : null}
|
|
332
|
+
step={0.05}
|
|
333
|
+
testId={`${testId}-key-value`}
|
|
334
|
+
onChange={(v) => {
|
|
335
|
+
if (selected) live(moveKey(value, selectedKey, selected.time, v));
|
|
336
|
+
}}
|
|
337
|
+
onChangeEnd={(v) => {
|
|
338
|
+
if (selected) commit(moveKey(value, selectedKey, selected.time, v));
|
|
339
|
+
}}
|
|
340
|
+
/>
|
|
341
|
+
<Button
|
|
342
|
+
type="button"
|
|
343
|
+
variant="ghost"
|
|
344
|
+
size="compact"
|
|
345
|
+
data-testid={`${testId}-delete-key`}
|
|
346
|
+
disabled={!canDelete || disabled === true}
|
|
347
|
+
aria-label="Delete curve key"
|
|
348
|
+
title={canDelete ? 'Delete key' : 'Endpoint keys cannot be deleted'}
|
|
349
|
+
onClick={() => {
|
|
350
|
+
commit(deleteKey(value, selectedKey));
|
|
351
|
+
setSelectedKey(Math.max(0, selectedKey - 1));
|
|
352
|
+
}}
|
|
353
|
+
>
|
|
354
|
+
×
|
|
355
|
+
</Button>
|
|
356
|
+
</div>
|
|
357
|
+
</div>
|
|
358
|
+
);
|
|
359
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FilterEditor — `filter`/`backdrop-filter` function sliders (blur/
|
|
3
|
+
* brightness/contrast/saturate/grayscale/hue-rotate), add/remove-by-slider
|
|
4
|
+
* (each function resets to its identity default when dragged back to it —
|
|
5
|
+
* matching the reference's "omit default-valued functions from the composed
|
|
6
|
+
* string" behavior) (spec 27 §5 C1). Ported from `visual-edit/inspector.tsx`'s
|
|
7
|
+
* `FilterEditor`/`FILTER_DEFS` (:1530-1620); the reference's expand/collapse
|
|
8
|
+
* chrome is left to the calling SECTION (Effects) — this widget renders its
|
|
9
|
+
* sliders directly given a value, matching this kit's "widgets are pure,
|
|
10
|
+
* sections own layout/disclosure" split.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { ScrubbableInput } from './ScrubbableInput';
|
|
14
|
+
import { type ChangeHandlers, fireChange, fireEnd, THEME } from './shared';
|
|
15
|
+
|
|
16
|
+
interface FilterDef {
|
|
17
|
+
name: string;
|
|
18
|
+
unit: string;
|
|
19
|
+
min: number;
|
|
20
|
+
max: number;
|
|
21
|
+
step: number;
|
|
22
|
+
default: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const FILTER_DEFS: FilterDef[] = [
|
|
26
|
+
{ name: 'blur', unit: 'px', min: 0, max: 50, step: 0.5, default: 0 },
|
|
27
|
+
{ name: 'brightness', unit: '%', min: 0, max: 300, step: 1, default: 100 },
|
|
28
|
+
{ name: 'contrast', unit: '%', min: 0, max: 300, step: 1, default: 100 },
|
|
29
|
+
{ name: 'saturate', unit: '%', min: 0, max: 300, step: 1, default: 100 },
|
|
30
|
+
{ name: 'grayscale', unit: '%', min: 0, max: 100, step: 1, default: 0 },
|
|
31
|
+
{ name: 'hue-rotate', unit: 'deg', min: 0, max: 360, step: 1, default: 0 },
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
export interface FilterFunc {
|
|
35
|
+
name: string;
|
|
36
|
+
value: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function parseFilterFunctions(filter: string): FilterFunc[] {
|
|
40
|
+
if (!filter || filter === 'none') {
|
|
41
|
+
return FILTER_DEFS.map((d) => ({ name: d.name, value: d.default }));
|
|
42
|
+
}
|
|
43
|
+
return FILTER_DEFS.map((def) => {
|
|
44
|
+
const regex = new RegExp(`${def.name}\\(([\\d.]+)${def.unit}?\\)`);
|
|
45
|
+
const m = filter.match(regex);
|
|
46
|
+
return { name: def.name, value: m?.[1] ? Number.parseFloat(m[1]) : def.default };
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function composeFilter(funcs: FilterFunc[]): string {
|
|
51
|
+
const parts: string[] = [];
|
|
52
|
+
for (let i = 0; i < funcs.length; i++) {
|
|
53
|
+
const f = funcs[i];
|
|
54
|
+
const def = FILTER_DEFS[i];
|
|
55
|
+
if (f && def && f.value !== def.default) parts.push(`${f.name}(${f.value}${def.unit})`);
|
|
56
|
+
}
|
|
57
|
+
return parts.length ? parts.join(' ') : 'none';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface FilterEditorProps extends ChangeHandlers<FilterFunc[]> {
|
|
61
|
+
/** Cosmetic only — "Filter" vs "Backdrop Filter" label for the caller's section. */
|
|
62
|
+
label?: string | undefined;
|
|
63
|
+
/** Root-element test id. Defaults to the stable `filter-editor`; a node with
|
|
64
|
+
* BOTH a `filter` and a `backdropFilter` row renders two of these, so the
|
|
65
|
+
* inspector passes a per-row id to keep the roots distinct (spec 27 §5 C2, S1). */
|
|
66
|
+
testId?: string | undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function FilterEditor({
|
|
70
|
+
value,
|
|
71
|
+
onChange,
|
|
72
|
+
onChangeEnd,
|
|
73
|
+
disabled,
|
|
74
|
+
label = 'Filter',
|
|
75
|
+
testId = 'filter-editor',
|
|
76
|
+
}: FilterEditorProps): React.ReactElement {
|
|
77
|
+
const apply = (index: number, v: number, commit: boolean): void => {
|
|
78
|
+
const next = value.map((f, i) => (i === index ? { ...f, value: v } : f));
|
|
79
|
+
if (commit) fireEnd({ onChange, onChangeEnd }, next);
|
|
80
|
+
else fireChange({ onChange }, next);
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<div data-testid={testId}>
|
|
85
|
+
<div style={{ fontSize: 9, color: THEME.textMuted, marginBottom: 4 }}>{label}</div>
|
|
86
|
+
{value.map((f, i) => {
|
|
87
|
+
const def = FILTER_DEFS[i];
|
|
88
|
+
if (!def) return null;
|
|
89
|
+
return (
|
|
90
|
+
<div key={f.name} data-testid={`filter-row-${f.name}`} style={{ marginBottom: 3 }}>
|
|
91
|
+
<ScrubbableInput
|
|
92
|
+
testId={`filter-${f.name}`}
|
|
93
|
+
label={f.name.replace('hue-rotate', 'hue')}
|
|
94
|
+
value={f.value}
|
|
95
|
+
unit={def.unit}
|
|
96
|
+
min={def.min}
|
|
97
|
+
max={def.max}
|
|
98
|
+
step={def.step}
|
|
99
|
+
disabled={disabled}
|
|
100
|
+
onChange={(v) => apply(i, v, false)}
|
|
101
|
+
onChangeEnd={(v) => apply(i, v, true)}
|
|
102
|
+
/>
|
|
103
|
+
</div>
|
|
104
|
+
);
|
|
105
|
+
})}
|
|
106
|
+
</div>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { Button, EditorPopover, TextInput } from '../design-system';
|
|
2
|
+
/**
|
|
3
|
+
* FontPicker — a searchable, windowed (virtualized-by-hand, no new dep) font
|
|
4
|
+
* list (spec 27 §5 C1). Ported from `visual-edit/inspector.tsx`'s
|
|
5
|
+
* `FontPicker` (:1958-2093); the reference fetched fonts async through its
|
|
6
|
+
* `ParentBridge.getAvailableFonts()` (an iframe-bridge RPC this editor has no
|
|
7
|
+
* equivalent of) — here `fonts` is passed in directly by the caller, wired
|
|
8
|
+
* at C2 to the editor's OWN font source, `ui-source/inspect.ts:
|
|
9
|
+
* getAvailableFonts()` (browser `document.fonts` + the web-safe fallback
|
|
10
|
+
* list, already merged/sorted there — no re-implementation needed).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
14
|
+
import { DisclosureIcon } from '../design-system';
|
|
15
|
+
import { type ChangeHandlers, THEME } from './shared';
|
|
16
|
+
|
|
17
|
+
const ITEM_HEIGHT = 28;
|
|
18
|
+
const MAX_VISIBLE = 10;
|
|
19
|
+
|
|
20
|
+
export interface FontPickerProps extends ChangeHandlers<string> {
|
|
21
|
+
/** The available font families — wire to `getAvailableFonts()` at C2. */
|
|
22
|
+
fonts: readonly string[];
|
|
23
|
+
/** Root-element test id. Defaults to the stable `font-picker`; a node with a
|
|
24
|
+
* fontFamily on BOTH the style row and a component prop renders two, so the
|
|
25
|
+
* inspector passes a per-row id to keep the roots distinct (spec 27 §5 C2, S1). */
|
|
26
|
+
testId?: string | undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function FontPicker({
|
|
30
|
+
value,
|
|
31
|
+
onChange,
|
|
32
|
+
disabled,
|
|
33
|
+
fonts,
|
|
34
|
+
testId = 'font-picker',
|
|
35
|
+
}: FontPickerProps): React.ReactElement {
|
|
36
|
+
const [open, setOpen] = useState(false);
|
|
37
|
+
const [search, setSearch] = useState('');
|
|
38
|
+
const [scrollTop, setScrollTop] = useState(0);
|
|
39
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
40
|
+
const searchRef = useRef<HTMLInputElement>(null);
|
|
41
|
+
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
if (open) searchRef.current?.focus();
|
|
44
|
+
}, [open]);
|
|
45
|
+
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
if (!open) return;
|
|
48
|
+
const handler = (e: MouseEvent): void => {
|
|
49
|
+
if (containerRef.current && !containerRef.current.contains(e.target as Node)) setOpen(false);
|
|
50
|
+
};
|
|
51
|
+
document.addEventListener('mousedown', handler);
|
|
52
|
+
return () => document.removeEventListener('mousedown', handler);
|
|
53
|
+
}, [open]);
|
|
54
|
+
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
if (!open) return;
|
|
57
|
+
const handler = (e: KeyboardEvent): void => {
|
|
58
|
+
if (e.key === 'Escape') setOpen(false);
|
|
59
|
+
};
|
|
60
|
+
document.addEventListener('keydown', handler);
|
|
61
|
+
return () => document.removeEventListener('keydown', handler);
|
|
62
|
+
}, [open]);
|
|
63
|
+
|
|
64
|
+
const filtered = useMemo(() => {
|
|
65
|
+
if (!search) return fonts;
|
|
66
|
+
const q = search.toLowerCase();
|
|
67
|
+
return fonts.filter((f) => f.toLowerCase().includes(q));
|
|
68
|
+
}, [fonts, search]);
|
|
69
|
+
|
|
70
|
+
const totalHeight = filtered.length * ITEM_HEIGHT;
|
|
71
|
+
const startIndex = Math.floor(scrollTop / ITEM_HEIGHT);
|
|
72
|
+
const endIndex = Math.min(filtered.length, startIndex + MAX_VISIBLE + 2);
|
|
73
|
+
const visible = filtered.slice(startIndex, endIndex);
|
|
74
|
+
|
|
75
|
+
const select = (font: string): void => {
|
|
76
|
+
onChange(font);
|
|
77
|
+
setOpen(false);
|
|
78
|
+
setSearch('');
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const displayValue = value.replace(/^["']|["']$/g, '');
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<div ref={containerRef} data-testid={testId} style={{ position: 'relative', flex: 1 }}>
|
|
85
|
+
<Button
|
|
86
|
+
type="button"
|
|
87
|
+
variant="secondary"
|
|
88
|
+
className="vgai-field-trigger"
|
|
89
|
+
/* This trigger opens a listbox popover and never said so: the ARIA was
|
|
90
|
+
missing, which is both an a11y defect and — since the widget classes
|
|
91
|
+
landed — what decides whether it paints as a menu well or a
|
|
92
|
+
pushbutton (`theme.css`, the `wcol_menu` rule). */
|
|
93
|
+
aria-haspopup="listbox"
|
|
94
|
+
aria-expanded={open}
|
|
95
|
+
disabled={disabled}
|
|
96
|
+
data-testid="font-picker-input"
|
|
97
|
+
onClick={() => {
|
|
98
|
+
if (!disabled) setOpen((v) => !v);
|
|
99
|
+
}}
|
|
100
|
+
>
|
|
101
|
+
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
102
|
+
{displayValue || '—'}
|
|
103
|
+
</span>
|
|
104
|
+
<DisclosureIcon direction="down" tone="muted" style={{ marginLeft: 4 }} />
|
|
105
|
+
</Button>
|
|
106
|
+
{open && (
|
|
107
|
+
<EditorPopover
|
|
108
|
+
data-testid="font-picker-dropdown"
|
|
109
|
+
style={{
|
|
110
|
+
position: 'absolute',
|
|
111
|
+
top: '100%',
|
|
112
|
+
left: 0,
|
|
113
|
+
right: 0,
|
|
114
|
+
zIndex: 'var(--vgai-z-dropdown)',
|
|
115
|
+
marginTop: 2,
|
|
116
|
+
}}
|
|
117
|
+
>
|
|
118
|
+
<div style={{ padding: 4 }}>
|
|
119
|
+
<TextInput
|
|
120
|
+
ref={searchRef}
|
|
121
|
+
type="text"
|
|
122
|
+
data-testid="font-picker-search"
|
|
123
|
+
value={search}
|
|
124
|
+
onChange={(e) => {
|
|
125
|
+
setSearch(e.target.value);
|
|
126
|
+
setScrollTop(0);
|
|
127
|
+
}}
|
|
128
|
+
placeholder="Search fonts…"
|
|
129
|
+
data-density="compact"
|
|
130
|
+
style={{
|
|
131
|
+
width: '100%',
|
|
132
|
+
boxSizing: 'border-box',
|
|
133
|
+
}}
|
|
134
|
+
/>
|
|
135
|
+
</div>
|
|
136
|
+
<div
|
|
137
|
+
data-testid="font-picker-list"
|
|
138
|
+
onScroll={(e) => setScrollTop((e.target as HTMLDivElement).scrollTop)}
|
|
139
|
+
style={{ maxHeight: MAX_VISIBLE * ITEM_HEIGHT, overflowY: 'auto' }}
|
|
140
|
+
>
|
|
141
|
+
<div style={{ height: totalHeight, position: 'relative' }}>
|
|
142
|
+
{visible.map((font, i) => {
|
|
143
|
+
const actualIndex = startIndex + i;
|
|
144
|
+
const isActive = font.toLowerCase() === displayValue.toLowerCase();
|
|
145
|
+
return (
|
|
146
|
+
<div
|
|
147
|
+
key={font}
|
|
148
|
+
data-testid={`font-picker-option-${font}`}
|
|
149
|
+
onClick={() => select(font)}
|
|
150
|
+
style={{
|
|
151
|
+
position: 'absolute',
|
|
152
|
+
top: actualIndex * ITEM_HEIGHT,
|
|
153
|
+
left: 0,
|
|
154
|
+
right: 0,
|
|
155
|
+
height: ITEM_HEIGHT,
|
|
156
|
+
display: 'flex',
|
|
157
|
+
alignItems: 'center',
|
|
158
|
+
padding: '0 8px',
|
|
159
|
+
cursor: 'pointer',
|
|
160
|
+
fontSize: 12,
|
|
161
|
+
background: isActive ? THEME.accent : 'transparent',
|
|
162
|
+
color: isActive ? THEME.onAccent : THEME.text,
|
|
163
|
+
}}
|
|
164
|
+
>
|
|
165
|
+
<span
|
|
166
|
+
style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
|
|
167
|
+
>
|
|
168
|
+
{font}
|
|
169
|
+
</span>
|
|
170
|
+
</div>
|
|
171
|
+
);
|
|
172
|
+
})}
|
|
173
|
+
</div>
|
|
174
|
+
</div>
|
|
175
|
+
{filtered.length === 0 && (
|
|
176
|
+
<div
|
|
177
|
+
style={{
|
|
178
|
+
padding: '8px 12px',
|
|
179
|
+
color: THEME.textMuted,
|
|
180
|
+
fontSize: 10,
|
|
181
|
+
textAlign: 'center',
|
|
182
|
+
}}
|
|
183
|
+
>
|
|
184
|
+
No matching fonts
|
|
185
|
+
</div>
|
|
186
|
+
)}
|
|
187
|
+
</EditorPopover>
|
|
188
|
+
)}
|
|
189
|
+
</div>
|
|
190
|
+
);
|
|
191
|
+
}
|