castle-web-cli 0.4.182 → 0.4.184

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.
@@ -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}>{numberText(current)}</span>
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
+ }
@@ -15,11 +15,14 @@
15
15
  margin-bottom: 0;
16
16
  }
17
17
 
18
+ /* `MAX_ENEMY_SPEED` is a single unbreakable token to the line breaker -- no
19
+ space, and an underscore is not a break opportunity -- so a long param name
20
+ has to be allowed to break mid-word or it overflows the label column. */
18
21
  .fieldLabel {
19
22
  color: var(--castle-inspector-text);
20
23
  font-size: 14px;
21
- overflow: hidden;
22
- text-overflow: ellipsis;
24
+ min-width: 0;
25
+ overflow-wrap: anywhere;
23
26
  }
24
27
 
25
28
  /* Instance override: purple label + input border, plus a "Default: X [Reset]"
@@ -110,6 +113,18 @@
110
113
  font: inherit;
111
114
  }
112
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
+
113
128
  .numberField {
114
129
  position: relative;
115
130
  width: 100%;
@@ -156,6 +171,10 @@
156
171
  font-variant-numeric: tabular-nums;
157
172
  }
158
173
 
174
+ .unitSuffix {
175
+ color: var(--castle-inspector-muted);
176
+ }
177
+
159
178
  .scrubGrip {
160
179
  position: relative;
161
180
  z-index: 1;
@@ -325,3 +344,63 @@
325
344
  .checkboxOn .checkboxMark {
326
345
  display: block;
327
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(styles.select, styles.fileField, styles.paletteColorField)}
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(styles.fileFieldLabel, !current && styles.fileFieldEmpty)}>
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(styles.select, styles.fileField, styles.spriteField)}
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(styles.fileFieldLabel, !current && styles.fileFieldEmpty)}>
132
+ <span className={cx(fieldStyles.fileFieldLabel, !current && fieldStyles.fileFieldEmpty)}>
133
133
  {current ? name : 'None'}
134
134
  </span>
135
135
  </button>