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.
@@ -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
+ }