castle-web-cli 0.4.183 → 0.4.185
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/agent-failures.d.ts +1 -0
- package/dist/agent-failures.js +31 -4
- package/dist/agent-prompts.d.ts +0 -3
- package/dist/agent-prompts.js +14 -28
- package/dist/agent.d.ts +1 -13
- package/dist/agent.js +191 -938
- package/dist/byo-accounts.d.ts +4 -4
- package/dist/byo-accounts.js +17 -38
- package/dist/byo-auth.d.ts +2 -5
- package/dist/byo-auth.js +4 -52
- package/dist/byo-login.d.ts +4 -5
- package/dist/byo-login.js +28 -56
- package/dist/mcpPlaytest.js +1 -2
- package/dist/metering.d.ts +35 -23
- package/dist/metering.js +57 -52
- package/dist/openrouter-catalog.d.ts +0 -1
- package/dist/openrouter-catalog.js +0 -11
- package/dist/platformDoc.d.ts +1 -1
- package/dist/platformDoc.js +9 -12
- package/dist/serve.js +3 -5
- package/dist/shell/assets/index-BHWfx7Tz.css +1 -0
- package/dist/shell/assets/index-BMu42DTS.js +449 -0
- package/dist/shell/index.html +2 -2
- package/kits/physics-2d/CLAUDE.md +5 -1
- package/kits/physics-2d/castle.json +1 -1
- package/kits/physics-2d/editors/SceneEditor.jsx +2 -1
- package/kits/physics-2d/engine/autoInspector.jsx +32 -3
- package/kits/physics-2d/engine/fields/fields.jsx +305 -5
- package/kits/physics-2d/engine/fields/fields.module.css +76 -0
- package/kits/physics-2d/engine/paletteField.jsx +8 -3
- package/kits/physics-2d/engine/spriteField.jsx +3 -3
- package/kits/physics-2d/engine/ui.jsx +5 -277
- package/kits/physics-2d/engine/ui.module.css +0 -69
- package/package.json +1 -1
- package/dist/shell/assets/index-DnNy-Z5u.js +0 -447
- package/dist/shell/assets/index-cdsH2lna.css +0 -1
package/dist/shell/index.html
CHANGED
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
<link rel="icon" type="image/png" sizes="32x32" href="/__castle/ide/favicon-32x32.png" />
|
|
11
11
|
<link rel="icon" type="image/png" sizes="16x16" href="/__castle/ide/favicon-16x16.png" />
|
|
12
12
|
<link rel="icon" href="/__castle/ide/favicon.ico" sizes="any" />
|
|
13
|
-
<script type="module" crossorigin src="/__castle/ide/assets/index-
|
|
14
|
-
<link rel="stylesheet" crossorigin href="/__castle/ide/assets/index-
|
|
13
|
+
<script type="module" crossorigin src="/__castle/ide/assets/index-BMu42DTS.js"></script>
|
|
14
|
+
<link rel="stylesheet" crossorigin href="/__castle/ide/assets/index-BHWfx7Tz.css">
|
|
15
15
|
</head>
|
|
16
16
|
<body>
|
|
17
17
|
<div id="root"></div>
|
|
@@ -249,9 +249,13 @@ export class MyThing {
|
|
|
249
249
|
// Optional. Inspector ranges for numeric props. Without one, a number field is
|
|
250
250
|
// an unbounded scrubber stepping by 1 — wrong for anything living in 0..1.
|
|
251
251
|
// Giving BOTH min and max makes it a real slider. A `max` also caps typed
|
|
252
|
-
// entry, so only set one where going past it is meaningless.
|
|
252
|
+
// entry, so only set one where going past it is meaningless. `unit` suffixes
|
|
253
|
+
// the value with what it is measured in, and `options` makes a string or
|
|
254
|
+
// number prop a select over a fixed set of values.
|
|
253
255
|
static propertyMeta = {
|
|
254
256
|
power: { min: 0, max: 1, step: 0.05 },
|
|
257
|
+
speed: { min: 0, max: 600, unit: 'px/s' },
|
|
258
|
+
mode: { options: ['chase', 'patrol'] },
|
|
255
259
|
};
|
|
256
260
|
|
|
257
261
|
// Optional. Other components this one needs BESIDE IT on the same actor.
|
|
@@ -57,6 +57,7 @@ import {
|
|
|
57
57
|
ConfirmDialog,
|
|
58
58
|
EditorBody,
|
|
59
59
|
EditorHeader,
|
|
60
|
+
fieldStyles,
|
|
60
61
|
Icon,
|
|
61
62
|
IconButton,
|
|
62
63
|
NumberField,
|
|
@@ -2414,7 +2415,7 @@ function AddBehaviorPicker({ available, onAdd }) {
|
|
|
2414
2415
|
borderBottom: '1px solid var(--castle-inspector-divider)',
|
|
2415
2416
|
}}>
|
|
2416
2417
|
<select
|
|
2417
|
-
className={
|
|
2418
|
+
className={cx(fieldStyles.input, fieldStyles.select)}
|
|
2418
2419
|
value=""
|
|
2419
2420
|
onChange={(event) => {
|
|
2420
2421
|
if (event.target.value) onAdd(event.target.value);
|
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
CheckboxField,
|
|
4
|
+
ColorField,
|
|
5
|
+
FieldNote,
|
|
6
|
+
NumberField,
|
|
7
|
+
Panel,
|
|
8
|
+
SelectField,
|
|
9
|
+
TextField,
|
|
10
|
+
isHexColor,
|
|
11
|
+
} from './ui';
|
|
3
12
|
// Build the override-indicator field props (purple tint + "Default: X [Reset]")
|
|
4
13
|
// for a single property from a behavior panel's override context. Returns an
|
|
5
14
|
// empty object when there's no context (blueprint template editing) or the
|
|
@@ -16,7 +25,12 @@ export function overrideProps(override, prop) {
|
|
|
16
25
|
// `{ min, max, step }` there to get a real range instead of NumberField's
|
|
17
26
|
// defaults -- which are an unbounded scrubber stepping by whole numbers, wrong
|
|
18
27
|
// for anything that lives in 0..1. Declaring BOTH min and max makes it a true
|
|
19
|
-
// slider (fill + thumb).
|
|
28
|
+
// slider (fill + thumb). `unit` suffixes the value with what it is measured in.
|
|
29
|
+
// Props that declare nothing are unchanged.
|
|
30
|
+
//
|
|
31
|
+
// `options` turns a string or number prop into a select -- `['box', 'circle']`,
|
|
32
|
+
// or `{ value, label }` entries when the label should read differently. Not a
|
|
33
|
+
// boolean: a select hands back strings, and a `true` would come back `'true'`.
|
|
20
34
|
//
|
|
21
35
|
// Any prop can also declare `hint`, a line of explanation shown under the field.
|
|
22
36
|
// As a function it receives the prop's current value and the whole component, so
|
|
@@ -24,8 +38,22 @@ export function overrideProps(override, prop) {
|
|
|
24
38
|
// halves of a toggle at once -- which is the only way a one-line note beats the
|
|
25
39
|
// prop's own name.
|
|
26
40
|
function fieldFor({ label, current, set, sample, entry, ov }) {
|
|
41
|
+
if (entry?.options && (typeof sample === 'string' || typeof sample === 'number')) {
|
|
42
|
+
// A select's value is a string in the DOM; a numeric prop converts back so
|
|
43
|
+
// its type does not change under it.
|
|
44
|
+
const numeric = typeof sample === 'number';
|
|
45
|
+
return (
|
|
46
|
+
<SelectField
|
|
47
|
+
label={label}
|
|
48
|
+
value={current == null ? '' : String(current)}
|
|
49
|
+
onChange={(next) => set(numeric ? Number(next) : next)}
|
|
50
|
+
options={entry.options}
|
|
51
|
+
{...ov}
|
|
52
|
+
/>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
27
55
|
if (typeof sample === 'number') {
|
|
28
|
-
const { min, max, step } = entry ?? {};
|
|
56
|
+
const { min, max, step, unit } = entry ?? {};
|
|
29
57
|
return (
|
|
30
58
|
<NumberField
|
|
31
59
|
label={label}
|
|
@@ -34,6 +62,7 @@ function fieldFor({ label, current, set, sample, entry, ov }) {
|
|
|
34
62
|
{...(min == null ? {} : { min })}
|
|
35
63
|
{...(max == null ? {} : { max })}
|
|
36
64
|
{...(step == null ? {} : { step })}
|
|
65
|
+
{...(unit == null ? {} : { unit })}
|
|
37
66
|
{...ov}
|
|
38
67
|
/>
|
|
39
68
|
);
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
|
|
4
4
|
// The inspector's field widgets: one source for every place that edits a value
|
|
5
5
|
// with them, so a param in the shell scrubs exactly like a behavior prop in a
|
|
6
|
-
// kit's scene inspector.
|
|
6
|
+
// kit's scene inspector. Text, number, checkbox, color, select and file, plus
|
|
7
|
+
// the row chrome and the note line under a field.
|
|
7
8
|
//
|
|
8
9
|
// The canonical copy of this file is shared/fields/fields.tsx. Edit it there and
|
|
9
10
|
// run `npm run sync:shared`: scripts/sync-shared.mjs copies it byte-for-byte to
|
|
@@ -14,7 +15,7 @@
|
|
|
14
15
|
// The host supplies the --castle-* tokens the CSS reads. Anything portaled to
|
|
15
16
|
// document.body leaves the host's tree, so a host whose tokens live on a panel
|
|
16
17
|
// root names a class for the portal root through FieldsChrome.
|
|
17
|
-
import { createContext, useContext, useEffect, useRef, useState } from 'react';
|
|
18
|
+
import { createContext, useContext, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
18
19
|
import { createPortal } from 'react-dom';
|
|
19
20
|
import styles from './fields.module.css';
|
|
20
21
|
function cx(...parts) {
|
|
@@ -102,6 +103,12 @@ export function TextField({ value, onChange, ...row }) {
|
|
|
102
103
|
function numberText(v) {
|
|
103
104
|
return Number.isFinite(v) ? String(v) : '0';
|
|
104
105
|
}
|
|
106
|
+
// `0.08 s`. The space is a nbsp because a value and its unit are one token to
|
|
107
|
+
// read and must never wrap apart.
|
|
108
|
+
function UnitSuffix({ unit }) {
|
|
109
|
+
if (!unit) return null;
|
|
110
|
+
return <span className={styles.unitSuffix}>{`\u00a0${unit}`}</span>;
|
|
111
|
+
}
|
|
105
112
|
function clamp(v, min, max) {
|
|
106
113
|
if (typeof min === 'number' && v < min) v = min;
|
|
107
114
|
if (typeof max === 'number' && v > max) v = max;
|
|
@@ -124,7 +131,7 @@ const NUMPAD_GLYPH = {
|
|
|
124
131
|
// minus on mobile (the native numpad has none, and there's no web API to add one).
|
|
125
132
|
// The field has no <input>, so no native keyboard ever appears; this edits a draft
|
|
126
133
|
// string and commits on Done / dismiss.
|
|
127
|
-
function NumpadSheet({ label, value, min, max, onCommit, onClose }) {
|
|
134
|
+
function NumpadSheet({ label, value, min, max, unit, onCommit, onClose }) {
|
|
128
135
|
const [draft, setDraft] = useState(() => numberText(value));
|
|
129
136
|
const pristineRef = useRef(true); // first digit replaces the seeded value
|
|
130
137
|
function finish() {
|
|
@@ -169,6 +176,7 @@ function NumpadSheet({ label, value, min, max, onCommit, onClose }) {
|
|
|
169
176
|
<span className={styles.numpadValueLabel}>{label}</span>
|
|
170
177
|
<span className={styles.numpadValueNum}>
|
|
171
178
|
{draft === '' || draft === '-' ? '0' : draft}
|
|
179
|
+
<UnitSuffix unit={unit} />
|
|
172
180
|
</span>
|
|
173
181
|
</div>
|
|
174
182
|
<div className={styles.numpadGrid}>
|
|
@@ -256,7 +264,7 @@ function snapToStep(v, step) {
|
|
|
256
264
|
function sliderRange(min, max) {
|
|
257
265
|
return typeof min === 'number' && typeof max === 'number' && max > min ? { min, max } : null;
|
|
258
266
|
}
|
|
259
|
-
export function NumberField({ value, onChange, min, max, step = 1, ...row }) {
|
|
267
|
+
export function NumberField({ value, onChange, min, max, step = 1, unit, ...row }) {
|
|
260
268
|
const current = Number.isFinite(value) ? (value ?? 0) : 0;
|
|
261
269
|
const range = sliderRange(min, max);
|
|
262
270
|
// Drag-to-scrub is for every pointer, but what a plain CLICK/TAP opens differs:
|
|
@@ -366,6 +374,7 @@ export function NumberField({ value, onChange, min, max, step = 1, ...row }) {
|
|
|
366
374
|
range ? styles.numberSlider : styles.numberGrip
|
|
367
375
|
)}
|
|
368
376
|
data-field="number"
|
|
377
|
+
data-slider={range ? '' : undefined}
|
|
369
378
|
style={{ touchAction: 'pan-y' }}
|
|
370
379
|
onPointerDown={onPointerDown}
|
|
371
380
|
onPointerMove={onPointerMove}
|
|
@@ -389,7 +398,10 @@ export function NumberField({ value, onChange, min, max, step = 1, ...row }) {
|
|
|
389
398
|
⋮⋮
|
|
390
399
|
</span>
|
|
391
400
|
)}
|
|
392
|
-
<span className={styles.numberValue}>
|
|
401
|
+
<span className={styles.numberValue}>
|
|
402
|
+
{numberText(current)}
|
|
403
|
+
<UnitSuffix unit={unit} />
|
|
404
|
+
</span>
|
|
393
405
|
</div>
|
|
394
406
|
{scrubPos ? (
|
|
395
407
|
<Overlay>
|
|
@@ -398,6 +410,7 @@ export function NumberField({ value, onChange, min, max, step = 1, ...row }) {
|
|
|
398
410
|
style={{ left: scrubPos.x, top: scrubPos.y - 40 }}
|
|
399
411
|
aria-hidden="true">
|
|
400
412
|
{numberText(current)}
|
|
413
|
+
<UnitSuffix unit={unit} />
|
|
401
414
|
</div>
|
|
402
415
|
</Overlay>
|
|
403
416
|
) : null}
|
|
@@ -407,6 +420,7 @@ export function NumberField({ value, onChange, min, max, step = 1, ...row }) {
|
|
|
407
420
|
value={current}
|
|
408
421
|
min={min}
|
|
409
422
|
max={max}
|
|
423
|
+
unit={unit}
|
|
410
424
|
onCommit={commit}
|
|
411
425
|
onClose={() => setPadOpen(false)}
|
|
412
426
|
/>
|
|
@@ -438,6 +452,38 @@ export function CheckboxField({ checked, onChange, ...row }) {
|
|
|
438
452
|
</FieldRow>
|
|
439
453
|
);
|
|
440
454
|
}
|
|
455
|
+
function optionPair(option) {
|
|
456
|
+
if (typeof option === 'object') return { value: String(option.value), label: option.label };
|
|
457
|
+
return { value: String(option), label: String(option) };
|
|
458
|
+
}
|
|
459
|
+
// A select's value is a string in the DOM whatever the underlying type is; a
|
|
460
|
+
// host that holds numbers converts on the way back out.
|
|
461
|
+
export function SelectField({ value, onChange, options, ...row }) {
|
|
462
|
+
const current = value ?? '';
|
|
463
|
+
const listed = options.map(optionPair);
|
|
464
|
+
// A current value the options don't have is prepended rather than dropped: a
|
|
465
|
+
// select that renders blank for it reads as unset, and a stale or misspelled
|
|
466
|
+
// value is exactly the case where you need to see what it actually says.
|
|
467
|
+
const shown =
|
|
468
|
+
current === '' || listed.some((option) => option.value === current)
|
|
469
|
+
? listed
|
|
470
|
+
: [{ value: current, label: current }, ...listed];
|
|
471
|
+
return (
|
|
472
|
+
<FieldRow {...row}>
|
|
473
|
+
<select
|
|
474
|
+
className={cx(styles.input, styles.select)}
|
|
475
|
+
data-field="select"
|
|
476
|
+
value={current}
|
|
477
|
+
onChange={(event) => onChange(event.target.value)}>
|
|
478
|
+
{shown.map((option) => (
|
|
479
|
+
<option key={option.value} value={option.value}>
|
|
480
|
+
{option.label}
|
|
481
|
+
</option>
|
|
482
|
+
))}
|
|
483
|
+
</select>
|
|
484
|
+
</FieldRow>
|
|
485
|
+
);
|
|
486
|
+
}
|
|
441
487
|
// Explanatory text under a field, for a prop whose name doesn't carry its
|
|
442
488
|
// meaning. Rendered inside the panel's field grid, so it lands under the control
|
|
443
489
|
// rather than under the label.
|
|
@@ -473,3 +519,257 @@ function normalizeHex(value) {
|
|
|
473
519
|
if (/^#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$/.test(raw)) return raw;
|
|
474
520
|
return '#000000';
|
|
475
521
|
}
|
|
522
|
+
// ── file paths ───────────────────────────────────────────────────────────────
|
|
523
|
+
// Which extensions are an image, a sound or a video. A browser fact rather than
|
|
524
|
+
// any one kit's taxonomy -- the same kind the CLI's own unsupportedMedia list
|
|
525
|
+
// holds -- so the host that asks for `file: 'image'` need not carry its own.
|
|
526
|
+
export const FILE_KINDS = {
|
|
527
|
+
image: ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'],
|
|
528
|
+
audio: ['.mp3', '.wav', '.m4a'],
|
|
529
|
+
video: ['.mp4'],
|
|
530
|
+
};
|
|
531
|
+
export function isFileKind(path, kind) {
|
|
532
|
+
if (kind === 'any') return true;
|
|
533
|
+
const exts = FILE_KINDS[kind];
|
|
534
|
+
return exts ? exts.some((ext) => path.toLowerCase().endsWith(ext)) : false;
|
|
535
|
+
}
|
|
536
|
+
/** Which kind a path is, or null for a file that is none of them. */
|
|
537
|
+
export function mediaKindOf(path) {
|
|
538
|
+
for (const kind of Object.keys(FILE_KINDS)) {
|
|
539
|
+
if (isFileKind(path, kind)) return kind;
|
|
540
|
+
}
|
|
541
|
+
return null;
|
|
542
|
+
}
|
|
543
|
+
function basenameOf(path) {
|
|
544
|
+
if (!path) return '';
|
|
545
|
+
return path.slice(path.lastIndexOf('/') + 1);
|
|
546
|
+
}
|
|
547
|
+
let measureCanvasCtx = null;
|
|
548
|
+
function measureTextWidth(text, font) {
|
|
549
|
+
if (!measureCanvasCtx) {
|
|
550
|
+
measureCanvasCtx = document.createElement('canvas').getContext('2d');
|
|
551
|
+
}
|
|
552
|
+
if (!measureCanvasCtx) return 0;
|
|
553
|
+
measureCanvasCtx.font = font;
|
|
554
|
+
return measureCanvasCtx.measureText(text).width;
|
|
555
|
+
}
|
|
556
|
+
// `getComputedStyle().font` is empty in some browsers; rebuild it when it is.
|
|
557
|
+
function fontOf(style) {
|
|
558
|
+
return style.font && style.font !== '0px'
|
|
559
|
+
? style.font
|
|
560
|
+
: `${style.fontWeight} ${style.fontSize} ${style.fontFamily}`;
|
|
561
|
+
}
|
|
562
|
+
// Keep the filename intact; the directory is what yields. Binary-search the
|
|
563
|
+
// prefix of the directory until `{prefix}…/{basename}` fits `maxWidth`.
|
|
564
|
+
function fitPathKeepBasename(path, maxWidth, font) {
|
|
565
|
+
const measure = (text) => measureTextWidth(text, font);
|
|
566
|
+
if (maxWidth <= 0 || measure(path) <= maxWidth) return path;
|
|
567
|
+
const slash = path.lastIndexOf('/');
|
|
568
|
+
const dir = slash > 0 ? path.slice(0, slash) : '';
|
|
569
|
+
const base = slash > 0 ? path.slice(slash + 1) : path;
|
|
570
|
+
const ellipsed = `…/${base}`;
|
|
571
|
+
if (!dir || measure(ellipsed) > maxWidth) return fitKeepEnd(base, maxWidth, measure);
|
|
572
|
+
let lo = 0;
|
|
573
|
+
let hi = dir.length;
|
|
574
|
+
let best = ellipsed;
|
|
575
|
+
while (lo <= hi) {
|
|
576
|
+
const mid = (lo + hi) >> 1;
|
|
577
|
+
const candidate = `${dir.slice(0, mid)}…/${base}`;
|
|
578
|
+
if (measure(candidate) <= maxWidth) {
|
|
579
|
+
best = candidate;
|
|
580
|
+
lo = mid + 1;
|
|
581
|
+
} else {
|
|
582
|
+
hi = mid - 1;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
return best;
|
|
586
|
+
}
|
|
587
|
+
function fitKeepEnd(text, maxWidth, measure) {
|
|
588
|
+
if (measure(text) <= maxWidth) return text;
|
|
589
|
+
if (measure('…') > maxWidth) return '';
|
|
590
|
+
let lo = 0;
|
|
591
|
+
let hi = text.length;
|
|
592
|
+
let best = '…';
|
|
593
|
+
while (lo <= hi) {
|
|
594
|
+
const mid = (lo + hi) >> 1;
|
|
595
|
+
const candidate = `…${text.slice(text.length - mid)}`;
|
|
596
|
+
if (measure(candidate) <= maxWidth) {
|
|
597
|
+
best = candidate;
|
|
598
|
+
lo = mid + 1;
|
|
599
|
+
} else {
|
|
600
|
+
hi = mid - 1;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
return best;
|
|
604
|
+
}
|
|
605
|
+
function FilePathLabel({ path }) {
|
|
606
|
+
const ref = useRef(null);
|
|
607
|
+
const [shown, setShown] = useState(path);
|
|
608
|
+
useLayoutEffect(() => {
|
|
609
|
+
const node = ref.current;
|
|
610
|
+
if (!node) return undefined;
|
|
611
|
+
function fit() {
|
|
612
|
+
const max = node.clientWidth;
|
|
613
|
+
if (max <= 0) return;
|
|
614
|
+
setShown(fitPathKeepBasename(path, max, fontOf(getComputedStyle(node))));
|
|
615
|
+
}
|
|
616
|
+
fit();
|
|
617
|
+
const observer = new ResizeObserver(fit);
|
|
618
|
+
observer.observe(node);
|
|
619
|
+
return () => observer.disconnect();
|
|
620
|
+
}, [path]);
|
|
621
|
+
return (
|
|
622
|
+
<span ref={ref} className={styles.filePickerPath} title={path}>
|
|
623
|
+
{shown}
|
|
624
|
+
</span>
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
function pickerRowChrome(list) {
|
|
628
|
+
if (!list) return 26;
|
|
629
|
+
const box = getComputedStyle(list);
|
|
630
|
+
const opt = list.querySelector('button');
|
|
631
|
+
const op = opt ? getComputedStyle(opt) : null;
|
|
632
|
+
return (
|
|
633
|
+
(parseFloat(box.paddingLeft) || 0) +
|
|
634
|
+
(parseFloat(box.paddingRight) || 0) +
|
|
635
|
+
(parseFloat(box.borderLeftWidth) || 0) +
|
|
636
|
+
(parseFloat(box.borderRightWidth) || 0) +
|
|
637
|
+
(op ? (parseFloat(op.paddingLeft) || 0) + (parseFloat(op.paddingRight) || 0) : 16)
|
|
638
|
+
);
|
|
639
|
+
}
|
|
640
|
+
function longestPathWidth(list, paths) {
|
|
641
|
+
if (!list || !paths.length) return 0;
|
|
642
|
+
const probe = document.createElement('span');
|
|
643
|
+
probe.style.cssText = 'position:absolute;left:-9999px;white-space:nowrap;pointer-events:none;';
|
|
644
|
+
const opt = list.querySelector('button');
|
|
645
|
+
if (opt) probe.style.font = fontOf(getComputedStyle(opt));
|
|
646
|
+
list.appendChild(probe);
|
|
647
|
+
let max = 0;
|
|
648
|
+
for (const path of paths) {
|
|
649
|
+
probe.textContent = path || 'None';
|
|
650
|
+
max = Math.max(max, probe.offsetWidth);
|
|
651
|
+
}
|
|
652
|
+
probe.remove();
|
|
653
|
+
return max;
|
|
654
|
+
}
|
|
655
|
+
// Place a body-portaled popover now and again on every resize or scroll, so it
|
|
656
|
+
// keeps to its anchor. Scroll is captured because it doesn't bubble, and the
|
|
657
|
+
// anchor can sit in any scrolling ancestor. Returns the cleanup.
|
|
658
|
+
export function followAnchor(place) {
|
|
659
|
+
place();
|
|
660
|
+
window.addEventListener('resize', place);
|
|
661
|
+
window.addEventListener('scroll', place, true);
|
|
662
|
+
return () => {
|
|
663
|
+
window.removeEventListener('resize', place);
|
|
664
|
+
window.removeEventListener('scroll', place, true);
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
// File path field. Closed chrome shows the basename; the open list shows full
|
|
668
|
+
// paths. Callers pass the candidate paths — this does not decide what a sprite
|
|
669
|
+
// or sound is. `allowEmpty` adds a None row that writes ''.
|
|
670
|
+
export function FileField({ value, onChange, files = [], allowEmpty = false, ...row }) {
|
|
671
|
+
const current = value ?? '';
|
|
672
|
+
const [open, setOpen] = useState(false);
|
|
673
|
+
const buttonRef = useRef(null);
|
|
674
|
+
const listRef = useRef(null);
|
|
675
|
+
const [pos, setPos] = useState(null);
|
|
676
|
+
const paths = [];
|
|
677
|
+
if (allowEmpty) paths.push('');
|
|
678
|
+
for (const path of files) {
|
|
679
|
+
if (path && !paths.includes(path)) paths.push(path);
|
|
680
|
+
}
|
|
681
|
+
if (current && !paths.includes(current)) paths.splice(allowEmpty ? 1 : 0, 0, current);
|
|
682
|
+
useLayoutEffect(() => {
|
|
683
|
+
if (!open || !buttonRef.current) return undefined;
|
|
684
|
+
function place() {
|
|
685
|
+
const anchor = buttonRef.current.getBoundingClientRect();
|
|
686
|
+
const list = listRef.current;
|
|
687
|
+
const margin = 8;
|
|
688
|
+
const maxWidth = window.innerWidth - margin * 2;
|
|
689
|
+
const needed = longestPathWidth(list, paths) + pickerRowChrome(list) + 4;
|
|
690
|
+
const width = Math.min(Math.max(needed, anchor.width), maxWidth);
|
|
691
|
+
// Grow left from the field if the paths need more than the control width.
|
|
692
|
+
let left = anchor.right - width;
|
|
693
|
+
if (left < margin) left = margin;
|
|
694
|
+
if (left + width > window.innerWidth - margin) {
|
|
695
|
+
left = Math.max(margin, window.innerWidth - width - margin);
|
|
696
|
+
}
|
|
697
|
+
const gap = 4;
|
|
698
|
+
const height = list?.getBoundingClientRect().height ?? 0;
|
|
699
|
+
let top = anchor.bottom + gap;
|
|
700
|
+
if (height && top + height > window.innerHeight - margin) {
|
|
701
|
+
top = Math.max(margin, anchor.top - gap - height);
|
|
702
|
+
}
|
|
703
|
+
setPos({ top, left, width });
|
|
704
|
+
}
|
|
705
|
+
return followAnchor(place);
|
|
706
|
+
}, [open, paths.length]);
|
|
707
|
+
useEffect(() => {
|
|
708
|
+
if (!open) return undefined;
|
|
709
|
+
function onKeyDown(event) {
|
|
710
|
+
if (event.key === 'Escape') setOpen(false);
|
|
711
|
+
}
|
|
712
|
+
function onPointerDown(event) {
|
|
713
|
+
const target = event.target;
|
|
714
|
+
if (listRef.current?.contains(target) || buttonRef.current?.contains(target)) return;
|
|
715
|
+
setOpen(false);
|
|
716
|
+
}
|
|
717
|
+
window.addEventListener('keydown', onKeyDown);
|
|
718
|
+
window.addEventListener('pointerdown', onPointerDown);
|
|
719
|
+
return () => {
|
|
720
|
+
window.removeEventListener('keydown', onKeyDown);
|
|
721
|
+
window.removeEventListener('pointerdown', onPointerDown);
|
|
722
|
+
};
|
|
723
|
+
}, [open]);
|
|
724
|
+
return (
|
|
725
|
+
<FieldRow
|
|
726
|
+
{...row}
|
|
727
|
+
defaultValue={row.defaultValue ? basenameOf(String(row.defaultValue)) : row.defaultValue}>
|
|
728
|
+
<button
|
|
729
|
+
ref={buttonRef}
|
|
730
|
+
type="button"
|
|
731
|
+
className={cx(styles.input, styles.select, styles.fileField)}
|
|
732
|
+
data-field="file"
|
|
733
|
+
aria-haspopup="listbox"
|
|
734
|
+
aria-expanded={open}
|
|
735
|
+
onClick={() => setOpen((next) => !next)}>
|
|
736
|
+
<span className={cx(styles.fileFieldLabel, !current && styles.fileFieldEmpty)}>
|
|
737
|
+
{current ? basenameOf(current) : 'None'}
|
|
738
|
+
</span>
|
|
739
|
+
</button>
|
|
740
|
+
{open ? (
|
|
741
|
+
<Overlay>
|
|
742
|
+
<div
|
|
743
|
+
ref={listRef}
|
|
744
|
+
className={styles.filePicker}
|
|
745
|
+
role="listbox"
|
|
746
|
+
aria-label={row.label}
|
|
747
|
+
style={pos ?? { visibility: 'hidden' }}>
|
|
748
|
+
{paths.map((path) => {
|
|
749
|
+
const selected = path === current;
|
|
750
|
+
return (
|
|
751
|
+
<button
|
|
752
|
+
key={path || '__none'}
|
|
753
|
+
type="button"
|
|
754
|
+
role="option"
|
|
755
|
+
aria-selected={selected}
|
|
756
|
+
className={cx(
|
|
757
|
+
styles.filePickerOption,
|
|
758
|
+
selected && styles.filePickerOptionSelected,
|
|
759
|
+
!path && styles.fileFieldEmpty
|
|
760
|
+
)}
|
|
761
|
+
title={path || 'None'}
|
|
762
|
+
onClick={() => {
|
|
763
|
+
onChange(path);
|
|
764
|
+
setOpen(false);
|
|
765
|
+
}}>
|
|
766
|
+
{path ? <FilePathLabel path={path} /> : 'None'}
|
|
767
|
+
</button>
|
|
768
|
+
);
|
|
769
|
+
})}
|
|
770
|
+
</div>
|
|
771
|
+
</Overlay>
|
|
772
|
+
) : null}
|
|
773
|
+
</FieldRow>
|
|
774
|
+
);
|
|
775
|
+
}
|
|
@@ -113,6 +113,18 @@
|
|
|
113
113
|
font: inherit;
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
/* Worn with .input, which carries the box; this is only what makes it a select:
|
|
117
|
+
the native chrome off and our own arrow in its place. */
|
|
118
|
+
.select {
|
|
119
|
+
appearance: none;
|
|
120
|
+
-webkit-appearance: none;
|
|
121
|
+
padding-right: 26px;
|
|
122
|
+
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath d='M2.5 4.5L6 8l3.5-3.5' fill='none' stroke='%23888888' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
|
123
|
+
background-repeat: no-repeat;
|
|
124
|
+
background-position: right 8px center;
|
|
125
|
+
text-overflow: ellipsis;
|
|
126
|
+
}
|
|
127
|
+
|
|
116
128
|
.numberField {
|
|
117
129
|
position: relative;
|
|
118
130
|
width: 100%;
|
|
@@ -159,6 +171,10 @@
|
|
|
159
171
|
font-variant-numeric: tabular-nums;
|
|
160
172
|
}
|
|
161
173
|
|
|
174
|
+
.unitSuffix {
|
|
175
|
+
color: var(--castle-inspector-muted);
|
|
176
|
+
}
|
|
177
|
+
|
|
162
178
|
.scrubGrip {
|
|
163
179
|
position: relative;
|
|
164
180
|
z-index: 1;
|
|
@@ -328,3 +344,63 @@
|
|
|
328
344
|
.checkboxOn .checkboxMark {
|
|
329
345
|
display: block;
|
|
330
346
|
}
|
|
347
|
+
|
|
348
|
+
/* The file picker's closed chrome wears .input and .select as well; this is the
|
|
349
|
+
part that makes it a button rather than a box with text in it. */
|
|
350
|
+
.fileField {
|
|
351
|
+
display: flex;
|
|
352
|
+
align-items: center;
|
|
353
|
+
font: inherit;
|
|
354
|
+
box-shadow: none;
|
|
355
|
+
cursor: pointer;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
.fileFieldLabel {
|
|
359
|
+
min-width: 0;
|
|
360
|
+
overflow: hidden;
|
|
361
|
+
text-overflow: ellipsis;
|
|
362
|
+
white-space: nowrap;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
.fileFieldEmpty {
|
|
366
|
+
color: var(--castle-inspector-muted);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
.filePicker {
|
|
370
|
+
position: fixed;
|
|
371
|
+
z-index: 200;
|
|
372
|
+
box-sizing: border-box;
|
|
373
|
+
max-height: min(240px, calc(100vh - 16px));
|
|
374
|
+
overflow: auto;
|
|
375
|
+
padding: 4px;
|
|
376
|
+
border: 1px solid var(--castle-inspector-border);
|
|
377
|
+
border-radius: var(--castle-radius);
|
|
378
|
+
background: var(--castle-workspace-bg);
|
|
379
|
+
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.28);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
.filePickerOption {
|
|
383
|
+
display: block;
|
|
384
|
+
width: 100%;
|
|
385
|
+
overflow: hidden;
|
|
386
|
+
text-align: left;
|
|
387
|
+
border: 0;
|
|
388
|
+
border-radius: 3px;
|
|
389
|
+
background: transparent;
|
|
390
|
+
color: var(--castle-inspector-text);
|
|
391
|
+
font: inherit;
|
|
392
|
+
font-size: 13px;
|
|
393
|
+
padding: 6px 8px;
|
|
394
|
+
cursor: pointer;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
.filePickerPath {
|
|
398
|
+
display: block;
|
|
399
|
+
overflow: hidden;
|
|
400
|
+
white-space: nowrap;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
.filePickerOption:hover,
|
|
404
|
+
.filePickerOptionSelected {
|
|
405
|
+
background: var(--castle-inspector-input-bg);
|
|
406
|
+
}
|
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
pickerPageIndexFor,
|
|
6
6
|
pickerPagesFor,
|
|
7
7
|
} from './palettes';
|
|
8
|
-
import { cx, FieldRow, followAnchor, IconButton, styles } from './ui';
|
|
8
|
+
import { cx, FieldRow, fieldStyles, followAnchor, IconButton, styles } from './ui';
|
|
9
9
|
import { attachDismissListeners } from './popoverDismiss';
|
|
10
10
|
|
|
11
11
|
export function sameHex(a, b) {
|
|
@@ -180,7 +180,12 @@ export function PaletteColorField({
|
|
|
180
180
|
<button
|
|
181
181
|
ref={buttonRef}
|
|
182
182
|
type="button"
|
|
183
|
-
className={cx(
|
|
183
|
+
className={cx(
|
|
184
|
+
fieldStyles.input,
|
|
185
|
+
fieldStyles.select,
|
|
186
|
+
fieldStyles.fileField,
|
|
187
|
+
styles.paletteColorField
|
|
188
|
+
)}
|
|
184
189
|
aria-haspopup="dialog"
|
|
185
190
|
aria-expanded={open}
|
|
186
191
|
onClick={() => setOpen((next) => !next)}>
|
|
@@ -200,7 +205,7 @@ export function PaletteColorField({
|
|
|
200
205
|
}
|
|
201
206
|
}
|
|
202
207
|
/>
|
|
203
|
-
<span className={cx(
|
|
208
|
+
<span className={cx(fieldStyles.fileFieldLabel, !current && fieldStyles.fileFieldEmpty)}>
|
|
204
209
|
{current || 'None'}
|
|
205
210
|
</span>
|
|
206
211
|
</button>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
2
2
|
import { createPortal } from 'react-dom';
|
|
3
3
|
import { ArtThumbnail } from './artThumbnail';
|
|
4
|
-
import { cx, FieldRow, followAnchor, Icon, styles } from './ui';
|
|
4
|
+
import { cx, FieldRow, fieldStyles, followAnchor, Icon, styles } from './ui';
|
|
5
5
|
import { attachDismissListeners } from './popoverDismiss';
|
|
6
6
|
|
|
7
7
|
// Visual sprite / image picker for inspector fields that point at art files.
|
|
@@ -124,12 +124,12 @@ export function SpriteField({
|
|
|
124
124
|
<button
|
|
125
125
|
ref={buttonRef}
|
|
126
126
|
type="button"
|
|
127
|
-
className={cx(
|
|
127
|
+
className={cx(fieldStyles.input, fieldStyles.select, fieldStyles.fileField, styles.spriteField)}
|
|
128
128
|
aria-haspopup="listbox"
|
|
129
129
|
aria-expanded={open}
|
|
130
130
|
onClick={() => setOpen((next) => !next)}>
|
|
131
131
|
<ArtThumbnail art={art} className={styles.spriteFieldThumb} />
|
|
132
|
-
<span className={cx(
|
|
132
|
+
<span className={cx(fieldStyles.fileFieldLabel, !current && fieldStyles.fileFieldEmpty)}>
|
|
133
133
|
{current ? name : 'None'}
|
|
134
134
|
</span>
|
|
135
135
|
</button>
|