castle-web-cli 0.4.105 → 0.4.107
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.js +64 -33
- package/dist/byo-accounts.d.ts +38 -0
- package/dist/byo-accounts.js +186 -0
- package/dist/byo-auth.d.ts +7 -1
- package/dist/byo-auth.js +198 -9
- package/dist/byo-login.d.ts +14 -0
- package/dist/byo-login.js +196 -0
- package/dist/castleJson.d.ts +15 -0
- package/dist/castleJson.js +24 -0
- package/dist/claude-shim-env.d.ts +1 -0
- package/dist/claude-shim-env.js +6 -0
- package/dist/get-deck.js +1 -11
- package/dist/ide.js +45 -62
- package/dist/imports.js +31 -61
- package/dist/init.js +3 -116
- package/dist/metering.d.ts +7 -0
- package/dist/metering.js +9 -0
- package/dist/native/playtest-browser.js +1 -1
- package/dist/native/tools.js +20 -18
- package/dist/save-deck.js +6 -3
- package/dist/shell/assets/index-B5mHPsrX.js +144 -0
- package/dist/shell/assets/index-wU4ol--C.css +1 -0
- package/dist/shell/index.html +2 -2
- package/kits/physics-2d/castle.json +1 -1
- package/kits/physics-2d/editors/PxArtEditor.jsx +71 -1
- package/kits/physics-2d/editors/SceneEditor.jsx +72 -1
- package/kits/physics-2d/editors/editorHistory.js +18 -0
- package/kits/physics-2d/editors/pxArtTimeline.jsx +41 -8
- package/kits/physics-2d/editors/pxArtTimeline.module.css +18 -0
- package/kits/physics-2d/engine/gestureZoomPan.js +145 -0
- package/kits/physics-2d/engine/ui.jsx +208 -71
- package/kits/physics-2d/engine/ui.module.css +162 -0
- package/package.json +2 -2
- package/dist/shell/assets/index-B7nQarKK.js +0 -144
- package/dist/shell/assets/index-DIcWN-RS.css +0 -1
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react';
|
|
2
|
+
|
|
3
|
+
// A multi-finger contact resolves to a TAP (undo/redo) only if the whole contact
|
|
4
|
+
// is this brief and never moved past the slop; longer or larger is a drag.
|
|
5
|
+
const TAP_MAX_MS = 450;
|
|
6
|
+
// Pinch/pan must move this many px (spread or centroid) before it's treated as a
|
|
7
|
+
// drag. Under it the two fingers are still a candidate tap and nothing is applied
|
|
8
|
+
// — this is what keeps a two-finger TAP (undo) cleanly apart from a pinch.
|
|
9
|
+
const GESTURE_SLOP = 10;
|
|
10
|
+
|
|
11
|
+
// Shared touch gestures for the canvas editors (scene + pxart):
|
|
12
|
+
// one finger -> the editor's own tool (draw / select), untouched here
|
|
13
|
+
// two-finger drag -> pinch-zoom about the centroid + pan (onGestureMove)
|
|
14
|
+
// two-finger TAP -> undo (onUndo)
|
|
15
|
+
// three-finger TAP -> redo (onRedo) [Procreate convention]
|
|
16
|
+
//
|
|
17
|
+
// Tap resolution waits for EVERY finger to lift and then keys off the PEAK finger
|
|
18
|
+
// count reached during the contact — so it doesn't matter whether the fingers land
|
|
19
|
+
// or lift together (an early first-lift used to resolve a three-finger tap as two).
|
|
20
|
+
//
|
|
21
|
+
// A second finger aborts the in-flight one-finger action (onGestureStart) so a
|
|
22
|
+
// gesture never strands a half-stroke. Gesture touches are stopPropagation'd so
|
|
23
|
+
// they never reach the editor's own pointer handlers. onGestureMove emits one
|
|
24
|
+
// step: { factor, cx, cy, dx, dy } — a zoom factor about the pinch centroid plus
|
|
25
|
+
// the centroid's screen-space movement — which each editor applies its own way.
|
|
26
|
+
// The target must have `touch-action: none` for the pinch/pan to beat the browser;
|
|
27
|
+
// the taps work without it (a tap doesn't move, so the browser never scrolls).
|
|
28
|
+
export function useTwoFingerZoomPan({
|
|
29
|
+
targetRef,
|
|
30
|
+
enabled = true,
|
|
31
|
+
onGestureStart,
|
|
32
|
+
onGestureMove,
|
|
33
|
+
onGestureEnd,
|
|
34
|
+
onUndo,
|
|
35
|
+
onRedo,
|
|
36
|
+
}) {
|
|
37
|
+
// Callbacks in a ref so the listeners attach once and never need re-binding.
|
|
38
|
+
const cb = useRef(null);
|
|
39
|
+
cb.current = { onGestureStart, onGestureMove, onGestureEnd, onUndo, onRedo };
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
if (!enabled) return undefined;
|
|
42
|
+
const el = targetRef.current;
|
|
43
|
+
if (!el) return undefined;
|
|
44
|
+
const pts = new Map(); // pointerId -> { x, y }
|
|
45
|
+
// Per touch-sequence state (a sequence = first finger down … all fingers up):
|
|
46
|
+
let maxFingers = 0; // peak simultaneous fingers — decides undo (2) vs redo (3)
|
|
47
|
+
let multiStart = 0; // timeStamp the contact first reached two fingers
|
|
48
|
+
let startDist = 0;
|
|
49
|
+
let startCx = 0;
|
|
50
|
+
let startCy = 0;
|
|
51
|
+
let committed = false; // moved past slop → a pinch/pan drag, not a tap
|
|
52
|
+
let lastDist = 0;
|
|
53
|
+
let lastCx = 0;
|
|
54
|
+
let lastCy = 0;
|
|
55
|
+
const isTouch = (event) => event.pointerType === 'touch';
|
|
56
|
+
const twoFinger = () => {
|
|
57
|
+
const [a, b] = [...pts.values()];
|
|
58
|
+
return {
|
|
59
|
+
cx: (a.x + b.x) / 2,
|
|
60
|
+
cy: (a.y + b.y) / 2,
|
|
61
|
+
dist: Math.hypot(a.x - b.x, a.y - b.y),
|
|
62
|
+
};
|
|
63
|
+
};
|
|
64
|
+
const onDown = (event) => {
|
|
65
|
+
if (!isTouch(event)) return;
|
|
66
|
+
pts.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
|
67
|
+
if (pts.size > maxFingers) maxFingers = pts.size;
|
|
68
|
+
if (pts.size === 2) {
|
|
69
|
+
const m = twoFinger();
|
|
70
|
+
multiStart = event.timeStamp;
|
|
71
|
+
startDist = lastDist = m.dist;
|
|
72
|
+
startCx = lastCx = m.cx;
|
|
73
|
+
startCy = lastCy = m.cy;
|
|
74
|
+
committed = false;
|
|
75
|
+
cb.current.onGestureStart?.(); // abort any in-flight one-finger action
|
|
76
|
+
}
|
|
77
|
+
if (pts.size >= 2) event.stopPropagation();
|
|
78
|
+
};
|
|
79
|
+
const onMove = (event) => {
|
|
80
|
+
if (!isTouch(event) || !pts.has(event.pointerId)) return;
|
|
81
|
+
pts.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
|
82
|
+
if (pts.size < 2) return;
|
|
83
|
+
event.preventDefault();
|
|
84
|
+
event.stopPropagation();
|
|
85
|
+
if (pts.size !== 2) return; // zoom/pan is a two-finger interaction only
|
|
86
|
+
const m = twoFinger();
|
|
87
|
+
if (!committed) {
|
|
88
|
+
const spread = Math.abs(m.dist - startDist);
|
|
89
|
+
const slid = Math.hypot(m.cx - startCx, m.cy - startCy);
|
|
90
|
+
if (spread <= GESTURE_SLOP && slid <= GESTURE_SLOP) return; // still a candidate tap
|
|
91
|
+
committed = true;
|
|
92
|
+
// Reset the baseline to here so the first applied step doesn't jump by the
|
|
93
|
+
// whole slop distance.
|
|
94
|
+
lastDist = m.dist;
|
|
95
|
+
lastCx = m.cx;
|
|
96
|
+
lastCy = m.cy;
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const factor = lastDist > 0 && m.dist > 0 ? m.dist / lastDist : 1;
|
|
100
|
+
cb.current.onGestureMove?.({
|
|
101
|
+
factor,
|
|
102
|
+
cx: m.cx,
|
|
103
|
+
cy: m.cy,
|
|
104
|
+
dx: m.cx - lastCx,
|
|
105
|
+
dy: m.cy - lastCy,
|
|
106
|
+
});
|
|
107
|
+
lastDist = m.dist;
|
|
108
|
+
lastCx = m.cx;
|
|
109
|
+
lastCy = m.cy;
|
|
110
|
+
};
|
|
111
|
+
const onUp = (event) => {
|
|
112
|
+
if (pts.size >= 2 || committed) event.stopPropagation();
|
|
113
|
+
if (!pts.delete(event.pointerId)) return;
|
|
114
|
+
if (committed && pts.size < 2) cb.current.onGestureEnd?.();
|
|
115
|
+
if (pts.size > 0) return;
|
|
116
|
+
// Every finger is up — resolve the whole contact. A still (never a drag),
|
|
117
|
+
// brief multi-finger tap is undo at peak 2, redo at peak 3. Keying off the
|
|
118
|
+
// peak count makes it immune to the order fingers landed or lifted.
|
|
119
|
+
if (!committed && maxFingers >= 2 && event.timeStamp - multiStart < TAP_MAX_MS) {
|
|
120
|
+
if (maxFingers === 2) cb.current.onUndo?.();
|
|
121
|
+
else if (maxFingers === 3) cb.current.onRedo?.();
|
|
122
|
+
}
|
|
123
|
+
maxFingers = 0;
|
|
124
|
+
committed = false;
|
|
125
|
+
multiStart = 0;
|
|
126
|
+
};
|
|
127
|
+
// CAPTURE phase, not bubble: the artboard canvas captures the first finger
|
|
128
|
+
// (startTool → setPointerCapture) and consumes touches, so a bubble-phase
|
|
129
|
+
// listener misses fingers landing on/near it — three-finger taps rarely
|
|
130
|
+
// registered there. Capturing means we see every pointer BEFORE any descendant
|
|
131
|
+
// can swallow it, so the finger count is reliable wherever the taps land; and
|
|
132
|
+
// stopPropagation on the 2nd/3rd finger then keeps them out of the canvas too.
|
|
133
|
+
const opts = { passive: false, capture: true };
|
|
134
|
+
el.addEventListener('pointerdown', onDown, opts);
|
|
135
|
+
el.addEventListener('pointermove', onMove, opts);
|
|
136
|
+
el.addEventListener('pointerup', onUp, opts);
|
|
137
|
+
el.addEventListener('pointercancel', onUp, opts);
|
|
138
|
+
return () => {
|
|
139
|
+
el.removeEventListener('pointerdown', onDown, opts);
|
|
140
|
+
el.removeEventListener('pointermove', onMove, opts);
|
|
141
|
+
el.removeEventListener('pointerup', onUp, opts);
|
|
142
|
+
el.removeEventListener('pointercancel', onUp, opts);
|
|
143
|
+
};
|
|
144
|
+
}, [targetRef, enabled]);
|
|
145
|
+
}
|
|
@@ -286,87 +286,222 @@ export function TextField({ label, value, onChange, overridden, defaultValue, on
|
|
|
286
286
|
</FieldRow>
|
|
287
287
|
);
|
|
288
288
|
}
|
|
289
|
+
function numberText(v) {
|
|
290
|
+
return Number.isFinite(v) ? String(v) : '0';
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Keys in reading order for a 4-col grid: 0 spans the bottom three, Done the last.
|
|
294
|
+
const NUMPAD_KEYS = [
|
|
295
|
+
'7', '8', '9', 'back',
|
|
296
|
+
'4', '5', '6', 'sign',
|
|
297
|
+
'1', '2', '3', 'dot',
|
|
298
|
+
'0', 'done',
|
|
299
|
+
];
|
|
300
|
+
const NUMPAD_GLYPH = { back: '⌫', sign: '±', dot: '.', done: 'Done' };
|
|
301
|
+
|
|
302
|
+
// A custom numeric pad shown as a bottom sheet — the ONLY reliable way to offer a
|
|
303
|
+
// minus on mobile (the native numpad has none, and there's no web API to add one).
|
|
304
|
+
// The field has no <input>, so no native keyboard ever appears; this edits a draft
|
|
305
|
+
// string and commits on Done / dismiss.
|
|
306
|
+
function NumpadSheet({ label, value, min, max, step, onCommit, onClose }) {
|
|
307
|
+
const [draft, setDraft] = useState(() => numberText(value));
|
|
308
|
+
const pristineRef = useRef(true); // first digit replaces the seeded value
|
|
309
|
+
function finish() {
|
|
310
|
+
let v = Number(draft);
|
|
311
|
+
if (!Number.isFinite(v)) v = value;
|
|
312
|
+
if (min != null && v < min) v = min;
|
|
313
|
+
if (max != null && v > max) v = max;
|
|
314
|
+
onCommit(v);
|
|
315
|
+
onClose();
|
|
316
|
+
}
|
|
317
|
+
function press(key) {
|
|
318
|
+
if (key === 'done') return finish();
|
|
319
|
+
if (key === 'back') {
|
|
320
|
+
pristineRef.current = false;
|
|
321
|
+
return setDraft((d) => d.slice(0, -1));
|
|
322
|
+
}
|
|
323
|
+
if (key === 'sign') {
|
|
324
|
+
pristineRef.current = false;
|
|
325
|
+
return setDraft((d) => (d.startsWith('-') ? d.slice(1) : '-' + (d === '0' ? '' : d)));
|
|
326
|
+
}
|
|
327
|
+
// The first digit/dot after opening replaces the seeded value (calculator-style).
|
|
328
|
+
const fresh = pristineRef.current;
|
|
329
|
+
pristineRef.current = false;
|
|
330
|
+
if (key === 'dot') {
|
|
331
|
+
return setDraft((d) =>
|
|
332
|
+
fresh ? '0.' : d.includes('.') ? d : d === '' || d === '-' ? d + '0.' : d + '.'
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
setDraft((d) => {
|
|
336
|
+
if (fresh) return key;
|
|
337
|
+
if (d === '0') return key;
|
|
338
|
+
if (d === '-0') return '-' + key;
|
|
339
|
+
return d + key;
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
return createPortal(
|
|
343
|
+
<div className={styles.numpadBackdrop} onPointerDown={finish}>
|
|
344
|
+
<div
|
|
345
|
+
className={styles.numpadSheet}
|
|
346
|
+
role="dialog"
|
|
347
|
+
aria-label={`Edit ${label}`}
|
|
348
|
+
onPointerDown={(event) => event.stopPropagation()}>
|
|
349
|
+
<div className={styles.numpadValueRow}>
|
|
350
|
+
<span className={styles.numpadValueLabel}>{label}</span>
|
|
351
|
+
<span className={styles.numpadValueNum}>{draft === '' || draft === '-' ? '0' : draft}</span>
|
|
352
|
+
</div>
|
|
353
|
+
<div className={styles.numpadGrid}>
|
|
354
|
+
{NUMPAD_KEYS.map((key) => (
|
|
355
|
+
<button
|
|
356
|
+
key={key}
|
|
357
|
+
type="button"
|
|
358
|
+
className={cx(
|
|
359
|
+
styles.numpadKey,
|
|
360
|
+
key === 'done' && styles.numpadKeyDone,
|
|
361
|
+
key === '0' && styles.numpadKeyZero
|
|
362
|
+
)}
|
|
363
|
+
onClick={() => press(key)}>
|
|
364
|
+
{NUMPAD_GLYPH[key] ?? key}
|
|
365
|
+
</button>
|
|
366
|
+
))}
|
|
367
|
+
</div>
|
|
368
|
+
</div>
|
|
369
|
+
</div>,
|
|
370
|
+
document.body
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Scrub tuning: horizontal travel before a drag counts as a scrub (vs a tap), the
|
|
375
|
+
// base pixels-per-step, and how fast the per-pixel step grows with drag distance
|
|
376
|
+
// (acceleration, so a big range is reachable without running off the screen edge).
|
|
377
|
+
const SCRUB_THRESHOLD = 12;
|
|
378
|
+
const SCRUB_PX_PER_STEP = 12;
|
|
379
|
+
const SCRUB_ACCEL_RANGE = 240;
|
|
380
|
+
|
|
289
381
|
export function NumberField({ label, value, onChange, min, max, step = 1, overridden, defaultValue, onReset }) {
|
|
290
382
|
const current = Number.isFinite(value) ? (value ?? 0) : 0;
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
383
|
+
// A field with BOTH a min and max is a true slider (fill + thumb), the pointer's X
|
|
384
|
+
// mapped onto [min,max]. Everything else is a horizontal scrubber that can pass
|
|
385
|
+
// into negatives, marked with a grip so it reads as draggable.
|
|
386
|
+
const bounded = min != null && max != null && max > min;
|
|
387
|
+
const [padOpen, setPadOpen] = useState(false);
|
|
388
|
+
// While scrubbing, a value bubble floats above the finger (which occludes the
|
|
389
|
+
// field itself) — { x, y } client coords, or null when not scrubbing.
|
|
390
|
+
const [scrubPos, setScrubPos] = useState(null);
|
|
391
|
+
const boxRef = useRef(null);
|
|
392
|
+
const dragRef = useRef(null);
|
|
393
|
+
function clampVal(v) {
|
|
394
|
+
if (min != null && v < min) v = min;
|
|
395
|
+
if (max != null && v > max) v = max;
|
|
396
|
+
return v;
|
|
297
397
|
}
|
|
298
|
-
function
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
if (
|
|
302
|
-
if (max != null && next > max) next = max;
|
|
303
|
-
setDraft(String(next));
|
|
304
|
-
onChange(next);
|
|
398
|
+
function scrubTo(v) {
|
|
399
|
+
if (step) v = Math.round(v / step) * step;
|
|
400
|
+
v = clampVal(v);
|
|
401
|
+
if (v !== current) onChange(v);
|
|
305
402
|
}
|
|
306
|
-
function
|
|
307
|
-
|
|
308
|
-
if (
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
403
|
+
function onPointerDown(event) {
|
|
404
|
+
if (padOpen) return;
|
|
405
|
+
if (event.pointerType === 'mouse' && event.button !== 0) return;
|
|
406
|
+
// Don't capture or preventDefault yet: `touch-action: pan-y` lets the browser
|
|
407
|
+
// own a VERTICAL drag (scroll the field list); we only claim a HORIZONTAL one.
|
|
408
|
+
dragRef.current = {
|
|
409
|
+
id: event.pointerId,
|
|
410
|
+
startX: event.clientX,
|
|
411
|
+
startY: event.clientY,
|
|
412
|
+
startValue: current,
|
|
413
|
+
mode: 'pending',
|
|
414
|
+
};
|
|
312
415
|
}
|
|
416
|
+
function onPointerMove(event) {
|
|
417
|
+
const drag = dragRef.current;
|
|
418
|
+
if (!drag || drag.id !== event.pointerId) return;
|
|
419
|
+
const dx = event.clientX - drag.startX;
|
|
420
|
+
const dy = event.clientY - drag.startY;
|
|
421
|
+
if (drag.mode === 'pending') {
|
|
422
|
+
if (Math.abs(dx) < SCRUB_THRESHOLD && Math.abs(dy) < SCRUB_THRESHOLD) return;
|
|
423
|
+
if (Math.abs(dy) >= Math.abs(dx)) {
|
|
424
|
+
drag.mode = 'scroll'; // vertical intent — leave it to the list scroll
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
drag.mode = 'scrub';
|
|
428
|
+
boxRef.current?.setPointerCapture?.(event.pointerId);
|
|
429
|
+
}
|
|
430
|
+
if (drag.mode !== 'scrub') return;
|
|
431
|
+
event.preventDefault();
|
|
432
|
+
setScrubPos({ x: event.clientX, y: event.clientY });
|
|
433
|
+
if (bounded) {
|
|
434
|
+
const rect = boxRef.current.getBoundingClientRect();
|
|
435
|
+
const frac = Math.min(1, Math.max(0, (event.clientX - rect.left) / rect.width));
|
|
436
|
+
scrubTo(min + frac * (max - min));
|
|
437
|
+
} else {
|
|
438
|
+
// Acceleration: each pixel counts for more the farther you've dragged.
|
|
439
|
+
const accel = 1 + Math.abs(dx) / SCRUB_ACCEL_RANGE;
|
|
440
|
+
scrubTo(drag.startValue + Math.round((dx / SCRUB_PX_PER_STEP) * accel) * step);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
function onPointerUp(event) {
|
|
444
|
+
const drag = dragRef.current;
|
|
445
|
+
if (!drag || drag.id !== event.pointerId) return;
|
|
446
|
+
if (drag.mode === 'scrub') boxRef.current?.releasePointerCapture?.(event.pointerId);
|
|
447
|
+
const wasTap = drag.mode === 'pending';
|
|
448
|
+
dragRef.current = null;
|
|
449
|
+
setScrubPos(null);
|
|
450
|
+
if (wasTap) setPadOpen(true); // a plain tap opens the numpad
|
|
451
|
+
}
|
|
452
|
+
// A pointercancel = the browser took the gesture (a vertical scroll under pan-y).
|
|
453
|
+
// Just drop the drag; it must NOT be treated as a tap.
|
|
454
|
+
function onPointerCancel() {
|
|
455
|
+
dragRef.current = null;
|
|
456
|
+
setScrubPos(null);
|
|
457
|
+
}
|
|
458
|
+
const fillPct = bounded ? Math.min(100, Math.max(0, ((current - min) / (max - min)) * 100)) : 0;
|
|
313
459
|
return (
|
|
314
460
|
<FieldRow label={label} overridden={overridden} defaultValue={defaultValue} onReset={onReset}>
|
|
315
|
-
<div
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
461
|
+
<div
|
|
462
|
+
ref={boxRef}
|
|
463
|
+
className={cx(styles.input, styles.numberScrub, bounded ? styles.numberSlider : styles.numberGrip)}
|
|
464
|
+
style={{ touchAction: 'pan-y' }}
|
|
465
|
+
onPointerDown={onPointerDown}
|
|
466
|
+
onPointerMove={onPointerMove}
|
|
467
|
+
onPointerUp={onPointerUp}
|
|
468
|
+
onPointerCancel={onPointerCancel}>
|
|
469
|
+
{bounded ? (
|
|
470
|
+
<>
|
|
471
|
+
<div className={styles.sliderFill} style={{ width: `${fillPct}%` }} aria-hidden="true" />
|
|
472
|
+
<div className={styles.sliderThumb} style={{ left: `${fillPct}%` }} aria-hidden="true" />
|
|
473
|
+
</>
|
|
474
|
+
) : (
|
|
475
|
+
<span className={styles.scrubGrip} aria-hidden="true">
|
|
476
|
+
⋮⋮
|
|
477
|
+
</span>
|
|
478
|
+
)}
|
|
479
|
+
<span className={styles.numberValue}>{numberText(current)}</span>
|
|
480
|
+
</div>
|
|
481
|
+
{scrubPos
|
|
482
|
+
? createPortal(
|
|
483
|
+
<div
|
|
484
|
+
className={styles.scrubBubble}
|
|
485
|
+
style={{ left: scrubPos.x, top: scrubPos.y - 40 }}
|
|
486
|
+
aria-hidden="true">
|
|
487
|
+
{numberText(current)}
|
|
488
|
+
</div>,
|
|
489
|
+
document.body
|
|
490
|
+
)
|
|
491
|
+
: null}
|
|
492
|
+
{padOpen ? (
|
|
493
|
+
<NumpadSheet
|
|
494
|
+
label={label}
|
|
495
|
+
value={current}
|
|
319
496
|
min={min}
|
|
320
497
|
max={max}
|
|
321
498
|
step={step}
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
editingRef.current = true;
|
|
325
|
-
}}
|
|
326
|
-
onChange={(event) => setDraft(event.target.value)}
|
|
327
|
-
onBlur={(event) => {
|
|
328
|
-
editingRef.current = false;
|
|
329
|
-
if (cancelRef.current) {
|
|
330
|
-
cancelRef.current = false;
|
|
331
|
-
setDraft(String(current));
|
|
332
|
-
return;
|
|
333
|
-
}
|
|
334
|
-
commit(event.target.value);
|
|
335
|
-
}}
|
|
336
|
-
onKeyDown={(event) => {
|
|
337
|
-
if (event.key === 'Enter') {
|
|
338
|
-
event.preventDefault();
|
|
339
|
-
commit(event.currentTarget.value);
|
|
340
|
-
event.currentTarget.blur();
|
|
341
|
-
} else if (event.key === 'Escape') {
|
|
342
|
-
event.preventDefault();
|
|
343
|
-
cancelRef.current = true;
|
|
344
|
-
setDraft(String(current));
|
|
345
|
-
event.currentTarget.blur();
|
|
346
|
-
}
|
|
499
|
+
onCommit={(v) => {
|
|
500
|
+
if (v !== current) onChange(v);
|
|
347
501
|
}}
|
|
502
|
+
onClose={() => setPadOpen(false)}
|
|
348
503
|
/>
|
|
349
|
-
|
|
350
|
-
<button
|
|
351
|
-
type="button"
|
|
352
|
-
className={styles.numberStepper}
|
|
353
|
-
tabIndex={-1}
|
|
354
|
-
aria-label="Increment"
|
|
355
|
-
disabled={max != null && current >= max}
|
|
356
|
-
onClick={() => stepBy(step)}>
|
|
357
|
-
<Icon name="chevron-up" />
|
|
358
|
-
</button>
|
|
359
|
-
<button
|
|
360
|
-
type="button"
|
|
361
|
-
className={styles.numberStepper}
|
|
362
|
-
tabIndex={-1}
|
|
363
|
-
aria-label="Decrement"
|
|
364
|
-
disabled={min != null && current <= min}
|
|
365
|
-
onClick={() => stepBy(-step)}>
|
|
366
|
-
<Icon name="chevron-down" />
|
|
367
|
-
</button>
|
|
368
|
-
</div>
|
|
369
|
-
</div>
|
|
504
|
+
) : null}
|
|
370
505
|
</FieldRow>
|
|
371
506
|
);
|
|
372
507
|
}
|
|
@@ -494,9 +629,11 @@ function saveStoredSheetHeight(key, px) {
|
|
|
494
629
|
// storage full/unavailable -- height still works in-memory this session
|
|
495
630
|
}
|
|
496
631
|
}
|
|
497
|
-
export function useMobileSheet({ open = true, baseClassName, storageKey }) {
|
|
632
|
+
export function useMobileSheet({ open = true, baseClassName, storageKey, defaultPeek = false }) {
|
|
498
633
|
const [height, setHeightState] = useState(() =>
|
|
499
|
-
clampSheetHeight(
|
|
634
|
+
clampSheetHeight(
|
|
635
|
+
loadStoredSheetHeight(storageKey) ?? (defaultPeek ? sheetSizes().peek : sheetSizes().expanded)
|
|
636
|
+
)
|
|
500
637
|
);
|
|
501
638
|
const [dragging, setDragging] = useState(false);
|
|
502
639
|
const heightRef = useRef(height);
|
|
@@ -1191,6 +1191,160 @@
|
|
|
1191
1191
|
border-bottom: 1px solid var(--castle-inspector-divider);
|
|
1192
1192
|
}
|
|
1193
1193
|
|
|
1194
|
+
/* Number fields are drag scrubbers: drag the field to change the value — a bounded
|
|
1195
|
+
field (min AND max) maps the pointer across [min,max] like a slider and shows a
|
|
1196
|
+
fill; an unbounded field scrubs relatively and into negatives — tap to type. The
|
|
1197
|
+
input sits transparent on top and only takes pointer events while editing, so a
|
|
1198
|
+
drag reaches the box; a tap focuses it (JS) to raise the keyboard. */
|
|
1199
|
+
/* Number fields are drag scrubbers with a tap-to-open custom numpad (no native
|
|
1200
|
+
keyboard). A bounded field (min AND max) is a slider with fill + thumb; an
|
|
1201
|
+
unbounded one shows a grip and scrubs into negatives. touch-action: pan-y (set
|
|
1202
|
+
inline) lets a vertical drag scroll the field list while horizontal scrubs. */
|
|
1203
|
+
.numberScrub {
|
|
1204
|
+
position: relative;
|
|
1205
|
+
display: flex;
|
|
1206
|
+
align-items: center;
|
|
1207
|
+
gap: 6px;
|
|
1208
|
+
overflow: hidden;
|
|
1209
|
+
padding: 5px 8px;
|
|
1210
|
+
cursor: ew-resize;
|
|
1211
|
+
user-select: none;
|
|
1212
|
+
-webkit-user-select: none;
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
.numberSlider {
|
|
1216
|
+
cursor: pointer;
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
.numberValue {
|
|
1220
|
+
position: relative;
|
|
1221
|
+
z-index: 1;
|
|
1222
|
+
flex: 1 1 auto;
|
|
1223
|
+
min-width: 0;
|
|
1224
|
+
text-align: right;
|
|
1225
|
+
font-variant-numeric: tabular-nums;
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
.scrubGrip {
|
|
1229
|
+
position: relative;
|
|
1230
|
+
z-index: 1;
|
|
1231
|
+
flex: 0 0 auto;
|
|
1232
|
+
color: var(--castle-inspector-muted);
|
|
1233
|
+
font-size: 12px;
|
|
1234
|
+
line-height: 1;
|
|
1235
|
+
letter-spacing: -2px;
|
|
1236
|
+
opacity: 0.4;
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
/* Value bubble that floats above the finger while scrubbing (the finger covers the
|
|
1240
|
+
field, so the live number needs to show somewhere visible). */
|
|
1241
|
+
.scrubBubble {
|
|
1242
|
+
position: fixed;
|
|
1243
|
+
z-index: 1001;
|
|
1244
|
+
transform: translate(-50%, -100%);
|
|
1245
|
+
padding: 3px 10px;
|
|
1246
|
+
border-radius: 8px;
|
|
1247
|
+
background: rgba(20, 24, 30, 0.96);
|
|
1248
|
+
border: 1px solid var(--castle-inspector-border);
|
|
1249
|
+
color: var(--castle-inspector-text);
|
|
1250
|
+
font-size: 15px;
|
|
1251
|
+
font-variant-numeric: tabular-nums;
|
|
1252
|
+
pointer-events: none;
|
|
1253
|
+
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.45);
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
.sliderFill {
|
|
1257
|
+
position: absolute;
|
|
1258
|
+
left: 0;
|
|
1259
|
+
top: 0;
|
|
1260
|
+
bottom: 0;
|
|
1261
|
+
background: rgba(90, 140, 210, 0.3);
|
|
1262
|
+
pointer-events: none;
|
|
1263
|
+
z-index: 0;
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
.sliderThumb {
|
|
1267
|
+
position: absolute;
|
|
1268
|
+
top: 50%;
|
|
1269
|
+
width: 3px;
|
|
1270
|
+
height: 60%;
|
|
1271
|
+
border-radius: 2px;
|
|
1272
|
+
background: rgba(150, 190, 240, 0.95);
|
|
1273
|
+
transform: translate(-50%, -50%);
|
|
1274
|
+
pointer-events: none;
|
|
1275
|
+
z-index: 1;
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
/* Custom numeric pad, shown as a bottom sheet. */
|
|
1279
|
+
.numpadBackdrop {
|
|
1280
|
+
position: fixed;
|
|
1281
|
+
inset: 0;
|
|
1282
|
+
z-index: 1000;
|
|
1283
|
+
display: flex;
|
|
1284
|
+
flex-direction: column;
|
|
1285
|
+
justify-content: flex-end;
|
|
1286
|
+
background: rgba(0, 0, 0, 0.35);
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
.numpadSheet {
|
|
1290
|
+
background: var(--castle-inspector-bg, #16181d);
|
|
1291
|
+
border-top: 1px solid var(--castle-inspector-border);
|
|
1292
|
+
border-radius: 14px 14px 0 0;
|
|
1293
|
+
padding: 10px 10px calc(10px + env(safe-area-inset-bottom, 0px));
|
|
1294
|
+
box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.4);
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
.numpadValueRow {
|
|
1298
|
+
display: flex;
|
|
1299
|
+
align-items: baseline;
|
|
1300
|
+
justify-content: space-between;
|
|
1301
|
+
gap: 12px;
|
|
1302
|
+
padding: 6px 8px 12px;
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
.numpadValueLabel {
|
|
1306
|
+
color: var(--castle-inspector-muted);
|
|
1307
|
+
font-size: 13px;
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
.numpadValueNum {
|
|
1311
|
+
color: var(--castle-inspector-text);
|
|
1312
|
+
font-size: 22px;
|
|
1313
|
+
font-variant-numeric: tabular-nums;
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
.numpadGrid {
|
|
1317
|
+
display: grid;
|
|
1318
|
+
grid-template-columns: repeat(4, 1fr);
|
|
1319
|
+
gap: 8px;
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
.numpadKey {
|
|
1323
|
+
height: 52px;
|
|
1324
|
+
border: 1px solid var(--castle-inspector-border);
|
|
1325
|
+
border-radius: 10px;
|
|
1326
|
+
background: var(--castle-inspector-input-bg);
|
|
1327
|
+
color: var(--castle-inspector-text);
|
|
1328
|
+
font-size: 20px;
|
|
1329
|
+
cursor: pointer;
|
|
1330
|
+
touch-action: manipulation;
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
.numpadKey:active {
|
|
1334
|
+
background: var(--castle-inspector-divider);
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
.numpadKeyZero {
|
|
1338
|
+
grid-column: span 3;
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
.numpadKeyDone {
|
|
1342
|
+
background: rgba(90, 140, 210, 0.9);
|
|
1343
|
+
border-color: transparent;
|
|
1344
|
+
color: #fff;
|
|
1345
|
+
font-size: 16px;
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1194
1348
|
.numberStepper:hover {
|
|
1195
1349
|
background: var(--castle-inspector-input-bg);
|
|
1196
1350
|
color: var(--castle-inspector-text);
|
|
@@ -1358,6 +1512,14 @@
|
|
|
1358
1512
|
display: none;
|
|
1359
1513
|
}
|
|
1360
1514
|
|
|
1515
|
+
/* Stack the tool rail and the undo/redo rail vertically (the shared rule above is
|
|
1516
|
+
a flex row, which pushed the second rail out of the 50px column). */
|
|
1517
|
+
.artboardToolColumn {
|
|
1518
|
+
flex-direction: column;
|
|
1519
|
+
align-items: center;
|
|
1520
|
+
gap: 8px;
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1361
1523
|
.drawingToolColumn {
|
|
1362
1524
|
display: flex;
|
|
1363
1525
|
align-items: flex-start;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "castle-web-cli",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.107",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"castle-web": "./dist/index.js"
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
"scripts": {
|
|
9
9
|
"build": "rm -rf dist && tsc && vite build && cp -R src/castle-host dist/castle-host && rm -rf kits && cp -R ../kits kits",
|
|
10
10
|
"dev": "tsc --watch",
|
|
11
|
-
"check": "eslint . && jscpd && tsc --noEmit"
|
|
11
|
+
"check": "eslint . && jscpd && tsc --noEmit && tsc --noEmit -p src/shell/tsconfig.json"
|
|
12
12
|
},
|
|
13
13
|
"jscpd": {
|
|
14
14
|
"path": [
|