castle-web-cli 0.4.181 → 0.4.183
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/dist/shell/assets/{index-C5DS_7UM.js → index-DnNy-Z5u.js} +90 -90
- package/dist/shell/assets/index-cdsH2lna.css +1 -0
- package/dist/shell/index.html +2 -2
- package/kits/physics-2d/CLAUDE.md +1 -0
- package/kits/physics-2d/castle.json +1 -1
- package/kits/physics-2d/engine/fields/fields.jsx +475 -0
- package/kits/physics-2d/engine/fields/fields.module.css +330 -0
- package/kits/physics-2d/engine/paletteField.jsx +2 -8
- package/kits/physics-2d/engine/scene.js +12 -3
- package/kits/physics-2d/engine/spriteField.jsx +2 -11
- package/kits/physics-2d/engine/ui.jsx +24 -448
- package/kits/physics-2d/engine/ui.module.css +2 -375
- package/kits/physics-2d/package.json +1 -1
- package/package.json +3 -2
- package/dist/shell/assets/index-CoU3ETYM.css +0 -1
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
// GENERATED from shared/fields/fields.tsx by scripts/sync-shared.mjs -- do not edit.
|
|
2
|
+
// Change the source and run `npm run sync:shared`.
|
|
3
|
+
|
|
4
|
+
// The inspector's field widgets: one source for every place that edits a value
|
|
5
|
+
// with them, so a param in the shell scrubs exactly like a behavior prop in a
|
|
6
|
+
// kit's scene inspector.
|
|
7
|
+
//
|
|
8
|
+
// The canonical copy of this file is shared/fields/fields.tsx. Edit it there and
|
|
9
|
+
// run `npm run sync:shared`: scripts/sync-shared.mjs copies it byte-for-byte to
|
|
10
|
+
// cli/src/shell/fields/ and builds kits/physics-2d/engine/fields/fields.jsx from
|
|
11
|
+
// it (types stripped, formatted in the kit's style), and `check` fails when
|
|
12
|
+
// either copy drifts.
|
|
13
|
+
//
|
|
14
|
+
// The host supplies the --castle-* tokens the CSS reads. Anything portaled to
|
|
15
|
+
// document.body leaves the host's tree, so a host whose tokens live on a panel
|
|
16
|
+
// root names a class for the portal root through FieldsChrome.
|
|
17
|
+
import { createContext, useContext, useEffect, useRef, useState } from 'react';
|
|
18
|
+
import { createPortal } from 'react-dom';
|
|
19
|
+
import styles from './fields.module.css';
|
|
20
|
+
function cx(...parts) {
|
|
21
|
+
return parts.filter(Boolean).join(' ');
|
|
22
|
+
}
|
|
23
|
+
// Live `matchMedia` as a hook: re-renders when the query starts/stops matching
|
|
24
|
+
// (plugging in a mouse, rotating, resizing a panel).
|
|
25
|
+
export function useMediaQuery(query) {
|
|
26
|
+
const [matches, setMatches] = useState(
|
|
27
|
+
() => typeof window !== 'undefined' && window.matchMedia(query).matches
|
|
28
|
+
);
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
const mql = window.matchMedia(query);
|
|
31
|
+
const onChange = (event) => setMatches(event.matches);
|
|
32
|
+
mql.addEventListener('change', onChange);
|
|
33
|
+
setMatches(mql.matches);
|
|
34
|
+
return () => mql.removeEventListener('change', onChange);
|
|
35
|
+
}, [query]);
|
|
36
|
+
return matches;
|
|
37
|
+
}
|
|
38
|
+
// True when the primary pointer is a finger rather than a mouse/trackpad/stylus
|
|
39
|
+
// — i.e. the person can't reliably hit a small target, has no hover, and gets a
|
|
40
|
+
// native keyboard instead of a physical one. The right gate for touch-first
|
|
41
|
+
// affordances (the numpad sheet, big hit areas), since it stays false on a
|
|
42
|
+
// desktop no matter how narrow the panel gets.
|
|
43
|
+
export function useCoarsePointer() {
|
|
44
|
+
return useMediaQuery('(pointer: coarse)');
|
|
45
|
+
}
|
|
46
|
+
export const FieldsChrome = createContext({});
|
|
47
|
+
// With no portalClassName the children portal bare, which is what the kit
|
|
48
|
+
// inspector has always rendered.
|
|
49
|
+
function Overlay({ children }) {
|
|
50
|
+
const { portalClassName } = useContext(FieldsChrome);
|
|
51
|
+
return createPortal(
|
|
52
|
+
portalClassName ? <div className={portalClassName}>{children}</div> : children,
|
|
53
|
+
document.body
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
// Render the "Default: X" value in an instance override's sub-line.
|
|
57
|
+
function formatFieldDefault(value) {
|
|
58
|
+
if (typeof value === 'boolean') return value ? 'On' : 'Off';
|
|
59
|
+
if (value === null || value === undefined || value === '') return 'none';
|
|
60
|
+
return String(value);
|
|
61
|
+
}
|
|
62
|
+
export function FieldRow({ id, label, overridden, defaultValue, onReset, children }) {
|
|
63
|
+
const row = (
|
|
64
|
+
<label
|
|
65
|
+
className={cx(styles.fieldRow, overridden && styles.fieldRowOverridden)}
|
|
66
|
+
data-field-id={id}>
|
|
67
|
+
<span className={cx(styles.fieldLabel, overridden && styles.fieldLabelOverridden)}>
|
|
68
|
+
{label}
|
|
69
|
+
</span>
|
|
70
|
+
{children}
|
|
71
|
+
</label>
|
|
72
|
+
);
|
|
73
|
+
if (!overridden) return row;
|
|
74
|
+
return (
|
|
75
|
+
<div className={styles.fieldOverride}>
|
|
76
|
+
{row}
|
|
77
|
+
<div className={styles.fieldDefault}>
|
|
78
|
+
<div className={styles.fieldDefaultInner}>
|
|
79
|
+
<span>Default: {formatFieldDefault(defaultValue)}</span>
|
|
80
|
+
{onReset ? (
|
|
81
|
+
<button type="button" className={styles.fieldResetBtn} onClick={onReset}>
|
|
82
|
+
Reset
|
|
83
|
+
</button>
|
|
84
|
+
) : null}
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
export function TextField({ value, onChange, ...row }) {
|
|
91
|
+
return (
|
|
92
|
+
<FieldRow {...row}>
|
|
93
|
+
<input
|
|
94
|
+
className={styles.input}
|
|
95
|
+
data-field="text"
|
|
96
|
+
value={value ?? ''}
|
|
97
|
+
onChange={(event) => onChange(event.target.value)}
|
|
98
|
+
/>
|
|
99
|
+
</FieldRow>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
function numberText(v) {
|
|
103
|
+
return Number.isFinite(v) ? String(v) : '0';
|
|
104
|
+
}
|
|
105
|
+
function clamp(v, min, max) {
|
|
106
|
+
if (typeof min === 'number' && v < min) v = min;
|
|
107
|
+
if (typeof max === 'number' && v > max) v = max;
|
|
108
|
+
return v;
|
|
109
|
+
}
|
|
110
|
+
// Keys in reading order for a 4-col grid: 0 spans the bottom three, Done the last.
|
|
111
|
+
const NUMPAD_KEYS = [
|
|
112
|
+
['7', '8', '9', 'back'],
|
|
113
|
+
['4', '5', '6', 'sign'],
|
|
114
|
+
['1', '2', '3', 'dot'],
|
|
115
|
+
['0', 'done'],
|
|
116
|
+
].flat();
|
|
117
|
+
const NUMPAD_GLYPH = {
|
|
118
|
+
back: '⌫',
|
|
119
|
+
sign: '±',
|
|
120
|
+
dot: '.',
|
|
121
|
+
done: 'Done',
|
|
122
|
+
};
|
|
123
|
+
// A custom numeric pad shown as a bottom sheet — the ONLY reliable way to offer a
|
|
124
|
+
// minus on mobile (the native numpad has none, and there's no web API to add one).
|
|
125
|
+
// The field has no <input>, so no native keyboard ever appears; this edits a draft
|
|
126
|
+
// string and commits on Done / dismiss.
|
|
127
|
+
function NumpadSheet({ label, value, min, max, onCommit, onClose }) {
|
|
128
|
+
const [draft, setDraft] = useState(() => numberText(value));
|
|
129
|
+
const pristineRef = useRef(true); // first digit replaces the seeded value
|
|
130
|
+
function finish() {
|
|
131
|
+
const v = Number(draft);
|
|
132
|
+
onCommit(clamp(Number.isFinite(v) ? v : value, min, max));
|
|
133
|
+
onClose();
|
|
134
|
+
}
|
|
135
|
+
function press(key) {
|
|
136
|
+
if (key === 'done') return finish();
|
|
137
|
+
if (key === 'back') {
|
|
138
|
+
pristineRef.current = false;
|
|
139
|
+
return setDraft((d) => d.slice(0, -1));
|
|
140
|
+
}
|
|
141
|
+
if (key === 'sign') {
|
|
142
|
+
pristineRef.current = false;
|
|
143
|
+
return setDraft((d) => (d.startsWith('-') ? d.slice(1) : '-' + (d === '0' ? '' : d)));
|
|
144
|
+
}
|
|
145
|
+
// The first digit/dot after opening replaces the seeded value (calculator-style).
|
|
146
|
+
const fresh = pristineRef.current;
|
|
147
|
+
pristineRef.current = false;
|
|
148
|
+
if (key === 'dot') {
|
|
149
|
+
return setDraft((d) =>
|
|
150
|
+
fresh ? '0.' : d.includes('.') ? d : d === '' || d === '-' ? d + '0.' : d + '.'
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
setDraft((d) => {
|
|
154
|
+
if (fresh) return key;
|
|
155
|
+
if (d === '0') return key;
|
|
156
|
+
if (d === '-0') return '-' + key;
|
|
157
|
+
return d + key;
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
return (
|
|
161
|
+
<Overlay>
|
|
162
|
+
<div className={styles.numpadBackdrop} onPointerDown={finish}>
|
|
163
|
+
<div
|
|
164
|
+
className={styles.numpadSheet}
|
|
165
|
+
role="dialog"
|
|
166
|
+
aria-label={`Edit ${label}`}
|
|
167
|
+
onPointerDown={(event) => event.stopPropagation()}>
|
|
168
|
+
<div className={styles.numpadValueRow}>
|
|
169
|
+
<span className={styles.numpadValueLabel}>{label}</span>
|
|
170
|
+
<span className={styles.numpadValueNum}>
|
|
171
|
+
{draft === '' || draft === '-' ? '0' : draft}
|
|
172
|
+
</span>
|
|
173
|
+
</div>
|
|
174
|
+
<div className={styles.numpadGrid}>
|
|
175
|
+
{NUMPAD_KEYS.map((key) => (
|
|
176
|
+
<button
|
|
177
|
+
key={key}
|
|
178
|
+
type="button"
|
|
179
|
+
className={cx(
|
|
180
|
+
styles.numpadKey,
|
|
181
|
+
key === 'done' && styles.numpadKeyDone,
|
|
182
|
+
key === '0' && styles.numpadKeyZero
|
|
183
|
+
)}
|
|
184
|
+
onClick={() => press(key)}>
|
|
185
|
+
{NUMPAD_GLYPH[key] ?? key}
|
|
186
|
+
</button>
|
|
187
|
+
))}
|
|
188
|
+
</div>
|
|
189
|
+
</div>
|
|
190
|
+
</div>
|
|
191
|
+
</Overlay>
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
// Scrub tuning: horizontal travel before a drag counts as a scrub (vs a tap), the
|
|
195
|
+
// base pixels-per-step, and how fast the per-pixel step grows with drag distance
|
|
196
|
+
// (acceleration, so a big range is reachable without running off the screen edge).
|
|
197
|
+
const SCRUB_THRESHOLD = 12;
|
|
198
|
+
const SCRUB_PX_PER_STEP = 12;
|
|
199
|
+
const SCRUB_ACCEL_RANGE = 240;
|
|
200
|
+
// The mouse/trackpad answer to "I want to type an exact number": a real input in
|
|
201
|
+
// place of the scrub box, focused and selected so typing replaces the value.
|
|
202
|
+
// Enter/blur commits, Esc reverts. The numpad sheet exists because a phone has
|
|
203
|
+
// no minus key and we don't want the native keyboard covering the field — with a
|
|
204
|
+
// physical keyboard neither is a problem, so a fine pointer never sees it.
|
|
205
|
+
function NumberInlineInput({ value, min, max, step, onCommit, onDone }) {
|
|
206
|
+
const [draft, setDraft] = useState(() => numberText(value));
|
|
207
|
+
const cancelRef = useRef(false);
|
|
208
|
+
function finish(raw) {
|
|
209
|
+
if (cancelRef.current) return onDone();
|
|
210
|
+
const next = Number(raw);
|
|
211
|
+
onCommit(Number.isFinite(next) ? next : value);
|
|
212
|
+
onDone();
|
|
213
|
+
}
|
|
214
|
+
return (
|
|
215
|
+
<div className={styles.numberField}>
|
|
216
|
+
<input
|
|
217
|
+
className={cx(styles.input, styles.numberInput)}
|
|
218
|
+
type="number"
|
|
219
|
+
min={min}
|
|
220
|
+
max={max}
|
|
221
|
+
step={step}
|
|
222
|
+
value={draft}
|
|
223
|
+
autoFocus
|
|
224
|
+
onFocus={(event) => event.currentTarget.select()}
|
|
225
|
+
onChange={(event) => setDraft(event.target.value)}
|
|
226
|
+
onBlur={(event) => finish(event.target.value)}
|
|
227
|
+
onKeyDown={(event) => {
|
|
228
|
+
if (event.key === 'Enter') {
|
|
229
|
+
event.preventDefault();
|
|
230
|
+
event.currentTarget.blur();
|
|
231
|
+
} else if (event.key === 'Escape') {
|
|
232
|
+
event.preventDefault();
|
|
233
|
+
cancelRef.current = true;
|
|
234
|
+
event.currentTarget.blur();
|
|
235
|
+
}
|
|
236
|
+
}}
|
|
237
|
+
/>
|
|
238
|
+
</div>
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
// Snap to a multiple of `step`. `Math.round(v / step) * step` alone reintroduces
|
|
242
|
+
// binary error the moment `step` isn't representable in base 2 -- 0.05 x 14 is
|
|
243
|
+
// 0.7000000000000001, which then gets stored and rendered in full -- so re-round
|
|
244
|
+
// to the decimal places the step itself implies.
|
|
245
|
+
function snapToStep(v, step) {
|
|
246
|
+
const snapped = Math.round(v / step) * step;
|
|
247
|
+
const text = String(step);
|
|
248
|
+
const decimals = text.includes('e-')
|
|
249
|
+
? Number(text.split('e-')[1])
|
|
250
|
+
: (text.split('.')[1] ?? '').length;
|
|
251
|
+
return Number(snapped.toFixed(Math.min(decimals, 100)));
|
|
252
|
+
}
|
|
253
|
+
// A field with BOTH a min and max is a true slider (fill + thumb), the pointer's X
|
|
254
|
+
// mapped onto [min,max]. Everything else is a horizontal scrubber that can pass
|
|
255
|
+
// into negatives, marked with a grip so it reads as draggable.
|
|
256
|
+
function sliderRange(min, max) {
|
|
257
|
+
return typeof min === 'number' && typeof max === 'number' && max > min ? { min, max } : null;
|
|
258
|
+
}
|
|
259
|
+
export function NumberField({ value, onChange, min, max, step = 1, ...row }) {
|
|
260
|
+
const current = Number.isFinite(value) ? (value ?? 0) : 0;
|
|
261
|
+
const range = sliderRange(min, max);
|
|
262
|
+
// Drag-to-scrub is for every pointer, but what a plain CLICK/TAP opens differs:
|
|
263
|
+
// a finger gets the numpad sheet, a mouse gets an inline text input (see
|
|
264
|
+
// NumberInlineInput). Both are the "type an exact value" path for their device.
|
|
265
|
+
const coarse = useCoarsePointer();
|
|
266
|
+
const [padOpen, setPadOpen] = useState(false);
|
|
267
|
+
const [typing, setTyping] = useState(false);
|
|
268
|
+
// While scrubbing, a value bubble floats above the finger (which occludes the
|
|
269
|
+
// field itself) — { x, y } client coords, or null when not scrubbing.
|
|
270
|
+
const [scrubPos, setScrubPos] = useState(null);
|
|
271
|
+
const boxRef = useRef(null);
|
|
272
|
+
const dragRef = useRef(null);
|
|
273
|
+
function scrubTo(v, dragStep) {
|
|
274
|
+
if (dragStep) v = snapToStep(v, dragStep);
|
|
275
|
+
v = clamp(v, min, max);
|
|
276
|
+
if (v !== current) onChange(v);
|
|
277
|
+
}
|
|
278
|
+
function onPointerDown(event) {
|
|
279
|
+
if (padOpen || typing) return;
|
|
280
|
+
if (event.pointerType === 'mouse' && event.button !== 0) return;
|
|
281
|
+
// Don't capture or preventDefault yet: `touch-action: pan-y` lets the browser
|
|
282
|
+
// own a VERTICAL drag (scroll the field list); we only claim a HORIZONTAL one.
|
|
283
|
+
dragRef.current = {
|
|
284
|
+
id: event.pointerId,
|
|
285
|
+
startX: event.clientX,
|
|
286
|
+
startY: event.clientY,
|
|
287
|
+
startValue: current,
|
|
288
|
+
step,
|
|
289
|
+
mode: 'pending',
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
function onPointerMove(event) {
|
|
293
|
+
const drag = dragRef.current;
|
|
294
|
+
if (!drag || drag.id !== event.pointerId) return;
|
|
295
|
+
const dx = event.clientX - drag.startX;
|
|
296
|
+
const dy = event.clientY - drag.startY;
|
|
297
|
+
if (drag.mode === 'pending') {
|
|
298
|
+
if (Math.abs(dx) < SCRUB_THRESHOLD && Math.abs(dy) < SCRUB_THRESHOLD) return;
|
|
299
|
+
if (Math.abs(dy) >= Math.abs(dx)) {
|
|
300
|
+
drag.mode = 'scroll'; // vertical intent — leave it to the list scroll
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
drag.mode = 'scrub';
|
|
304
|
+
boxRef.current?.setPointerCapture?.(event.pointerId);
|
|
305
|
+
}
|
|
306
|
+
if (drag.mode !== 'scrub') return;
|
|
307
|
+
event.preventDefault();
|
|
308
|
+
setScrubPos({ x: event.clientX, y: event.clientY });
|
|
309
|
+
if (range) {
|
|
310
|
+
const rect = boxRef.current.getBoundingClientRect();
|
|
311
|
+
const frac = Math.min(1, Math.max(0, (event.clientX - rect.left) / rect.width));
|
|
312
|
+
scrubTo(range.min + frac * (range.max - range.min), drag.step);
|
|
313
|
+
} else {
|
|
314
|
+
// Acceleration: each pixel counts for more the farther you've dragged.
|
|
315
|
+
const accel = 1 + Math.abs(dx) / SCRUB_ACCEL_RANGE;
|
|
316
|
+
scrubTo(
|
|
317
|
+
drag.startValue + Math.round((dx / SCRUB_PX_PER_STEP) * accel) * drag.step,
|
|
318
|
+
drag.step
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
function onPointerUp(event) {
|
|
323
|
+
const drag = dragRef.current;
|
|
324
|
+
if (!drag || drag.id !== event.pointerId) return;
|
|
325
|
+
if (drag.mode === 'scrub') boxRef.current?.releasePointerCapture?.(event.pointerId);
|
|
326
|
+
const wasTap = drag.mode === 'pending';
|
|
327
|
+
dragRef.current = null;
|
|
328
|
+
setScrubPos(null);
|
|
329
|
+
// A plain tap/click opens the device-appropriate way to type a value.
|
|
330
|
+
if (wasTap) (coarse ? setPadOpen : setTyping)(true);
|
|
331
|
+
}
|
|
332
|
+
// A pointercancel = the browser took the gesture (a vertical scroll under pan-y).
|
|
333
|
+
// Just drop the drag; it must NOT be treated as a tap.
|
|
334
|
+
function onPointerCancel() {
|
|
335
|
+
dragRef.current = null;
|
|
336
|
+
setScrubPos(null);
|
|
337
|
+
}
|
|
338
|
+
const fillPct = range
|
|
339
|
+
? Math.min(100, Math.max(0, ((current - range.min) / (range.max - range.min)) * 100))
|
|
340
|
+
: 0;
|
|
341
|
+
const commit = (v) => {
|
|
342
|
+
const next = clamp(v, min, max);
|
|
343
|
+
if (next !== current) onChange(next);
|
|
344
|
+
};
|
|
345
|
+
if (typing) {
|
|
346
|
+
return (
|
|
347
|
+
<FieldRow {...row}>
|
|
348
|
+
<NumberInlineInput
|
|
349
|
+
value={current}
|
|
350
|
+
min={min}
|
|
351
|
+
max={max}
|
|
352
|
+
step={step}
|
|
353
|
+
onCommit={commit}
|
|
354
|
+
onDone={() => setTyping(false)}
|
|
355
|
+
/>
|
|
356
|
+
</FieldRow>
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
return (
|
|
360
|
+
<FieldRow {...row}>
|
|
361
|
+
<div
|
|
362
|
+
ref={boxRef}
|
|
363
|
+
className={cx(
|
|
364
|
+
styles.input,
|
|
365
|
+
styles.numberScrub,
|
|
366
|
+
range ? styles.numberSlider : styles.numberGrip
|
|
367
|
+
)}
|
|
368
|
+
data-field="number"
|
|
369
|
+
style={{ touchAction: 'pan-y' }}
|
|
370
|
+
onPointerDown={onPointerDown}
|
|
371
|
+
onPointerMove={onPointerMove}
|
|
372
|
+
onPointerUp={onPointerUp}
|
|
373
|
+
onPointerCancel={onPointerCancel}>
|
|
374
|
+
{range ? (
|
|
375
|
+
<>
|
|
376
|
+
<div
|
|
377
|
+
className={styles.sliderFill}
|
|
378
|
+
style={{ width: `${fillPct}%` }}
|
|
379
|
+
aria-hidden="true"
|
|
380
|
+
/>
|
|
381
|
+
<div
|
|
382
|
+
className={styles.sliderThumb}
|
|
383
|
+
style={{ left: `${fillPct}%` }}
|
|
384
|
+
aria-hidden="true"
|
|
385
|
+
/>
|
|
386
|
+
</>
|
|
387
|
+
) : (
|
|
388
|
+
<span className={styles.scrubGrip} aria-hidden="true">
|
|
389
|
+
⋮⋮
|
|
390
|
+
</span>
|
|
391
|
+
)}
|
|
392
|
+
<span className={styles.numberValue}>{numberText(current)}</span>
|
|
393
|
+
</div>
|
|
394
|
+
{scrubPos ? (
|
|
395
|
+
<Overlay>
|
|
396
|
+
<div
|
|
397
|
+
className={styles.scrubBubble}
|
|
398
|
+
style={{ left: scrubPos.x, top: scrubPos.y - 40 }}
|
|
399
|
+
aria-hidden="true">
|
|
400
|
+
{numberText(current)}
|
|
401
|
+
</div>
|
|
402
|
+
</Overlay>
|
|
403
|
+
) : null}
|
|
404
|
+
{padOpen ? (
|
|
405
|
+
<NumpadSheet
|
|
406
|
+
label={row.label}
|
|
407
|
+
value={current}
|
|
408
|
+
min={min}
|
|
409
|
+
max={max}
|
|
410
|
+
onCommit={commit}
|
|
411
|
+
onClose={() => setPadOpen(false)}
|
|
412
|
+
/>
|
|
413
|
+
) : null}
|
|
414
|
+
</FieldRow>
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
export function CheckboxField({ checked, onChange, ...row }) {
|
|
418
|
+
return (
|
|
419
|
+
<FieldRow {...row}>
|
|
420
|
+
<button
|
|
421
|
+
type="button"
|
|
422
|
+
className={cx(styles.checkbox, checked && styles.checkboxOn)}
|
|
423
|
+
data-field="checkbox"
|
|
424
|
+
role="checkbox"
|
|
425
|
+
aria-checked={!!checked}
|
|
426
|
+
onClick={() => onChange(!checked)}>
|
|
427
|
+
<svg className={styles.checkboxMark} viewBox="0 0 12 12" aria-hidden="true">
|
|
428
|
+
<path
|
|
429
|
+
d="M2 6.2L4.8 9 10 3"
|
|
430
|
+
fill="none"
|
|
431
|
+
stroke="currentColor"
|
|
432
|
+
strokeWidth="1.8"
|
|
433
|
+
strokeLinecap="round"
|
|
434
|
+
strokeLinejoin="round"
|
|
435
|
+
/>
|
|
436
|
+
</svg>
|
|
437
|
+
</button>
|
|
438
|
+
</FieldRow>
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
// Explanatory text under a field, for a prop whose name doesn't carry its
|
|
442
|
+
// meaning. Rendered inside the panel's field grid, so it lands under the control
|
|
443
|
+
// rather than under the label.
|
|
444
|
+
export function FieldNote({ children }) {
|
|
445
|
+
if (!children) return null;
|
|
446
|
+
return (
|
|
447
|
+
<div className={styles.fieldNote}>
|
|
448
|
+
<div className={styles.fieldNoteInner}>{children}</div>
|
|
449
|
+
</div>
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
export function ColorField({ value, onChange, ...row }) {
|
|
453
|
+
const hex = normalizeHex(value);
|
|
454
|
+
const alpha = hex.length === 9 ? hex.slice(7) : '';
|
|
455
|
+
const rgb = hex.slice(0, 7);
|
|
456
|
+
return (
|
|
457
|
+
<FieldRow {...row}>
|
|
458
|
+
<input
|
|
459
|
+
className={styles.colorInput}
|
|
460
|
+
data-field="color"
|
|
461
|
+
type="color"
|
|
462
|
+
value={rgb}
|
|
463
|
+
onChange={(event) => onChange(event.target.value + alpha)}
|
|
464
|
+
/>
|
|
465
|
+
</FieldRow>
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
function normalizeHex(value) {
|
|
469
|
+
const raw = (value ?? '').trim();
|
|
470
|
+
if (/^#[0-9a-fA-F]{3}$/.test(raw)) {
|
|
471
|
+
return '#' + raw.slice(1).replace(/./g, (c) => c + c);
|
|
472
|
+
}
|
|
473
|
+
if (/^#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$/.test(raw)) return raw;
|
|
474
|
+
return '#000000';
|
|
475
|
+
}
|