castle-web-cli 0.4.10 → 0.4.12
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-prompts.d.ts +31 -0
- package/dist/agent-prompts.js +100 -0
- package/dist/agent.d.ts +17 -0
- package/dist/agent.js +894 -0
- package/dist/chat-client.d.ts +1 -0
- package/dist/chat-client.js +398 -0
- package/dist/commonInstructions.d.ts +1 -0
- package/dist/commonInstructions.js +8 -0
- package/dist/ide-client.js +46 -14
- package/dist/ide.d.ts +2 -0
- package/dist/ide.js +321 -36
- package/dist/init.js +12 -2
- package/dist/serve.js +62 -3
- package/kits/basic-2d/CLAUDE.md +3 -1
- package/kits/basic-2d/package.json +0 -1
- package/kits/basic-3d/.prettierrc +8 -0
- package/kits/basic-3d/CLAUDE.md +162 -0
- package/kits/basic-3d/behaviors/Camera.jsx +56 -0
- package/kits/basic-3d/behaviors/Collider.jsx +78 -0
- package/kits/basic-3d/behaviors/Mesh.jsx +82 -0
- package/kits/basic-3d/behaviors/Model.jsx +61 -0
- package/kits/basic-3d/behaviors/Transform.jsx +35 -0
- package/kits/basic-3d/editors/App.jsx +147 -0
- package/kits/basic-3d/editors/CodeEditor.jsx +112 -0
- package/kits/basic-3d/editors/FileBrowser.jsx +143 -0
- package/kits/basic-3d/editors/ModelEditor.jsx +400 -0
- package/kits/basic-3d/editors/PlayOnly.jsx +14 -0
- package/kits/basic-3d/editors/SceneEditor.jsx +1087 -0
- package/kits/basic-3d/editors/behaviorRegistry.js +24 -0
- package/kits/basic-3d/editors/editorHistory.js +52 -0
- package/kits/basic-3d/editors/viewportRig.js +90 -0
- package/kits/basic-3d/engine/ScenePlayer.jsx +55 -0
- package/kits/basic-3d/engine/SceneUI.jsx +67 -0
- package/kits/basic-3d/engine/SceneViewport.jsx +102 -0
- package/kits/basic-3d/engine/TouchControls.jsx +136 -0
- package/kits/basic-3d/engine/autoInspector.jsx +51 -0
- package/kits/basic-3d/engine/files.js +73 -0
- package/kits/basic-3d/engine/scene.js +502 -0
- package/kits/basic-3d/engine/threeUtil.js +260 -0
- package/kits/basic-3d/engine/ui.jsx +352 -0
- package/kits/basic-3d/engine/ui.module.css +944 -0
- package/kits/basic-3d/eslint.config.js +51 -0
- package/kits/basic-3d/index.html +11 -0
- package/kits/basic-3d/main.jsx +10 -0
- package/kits/basic-3d/models/block.model +14 -0
- package/kits/basic-3d/package-lock.json +2713 -0
- package/kits/basic-3d/package.json +41 -0
- package/kits/basic-3d/scenes/main.scene +76 -0
- package/kits/basic-3d/vite.config.js +1 -0
- package/package.json +6 -1
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import * as THREE from 'three';
|
|
2
|
+
|
|
3
|
+
// Shared three.js helpers: scene lighting, geometry/material/model builders
|
|
4
|
+
// (used by the Mesh + Model behaviors and the model editor preview), camera
|
|
5
|
+
// spec application, and disposal.
|
|
6
|
+
|
|
7
|
+
export const defaultLighting = {
|
|
8
|
+
sky: '#cfd9ff',
|
|
9
|
+
ground: '#3d3a33',
|
|
10
|
+
hemiIntensity: 0.9,
|
|
11
|
+
sun: '#fff4e0',
|
|
12
|
+
sunIntensity: 2.4,
|
|
13
|
+
sunAzimuth: 35,
|
|
14
|
+
sunElevation: 55,
|
|
15
|
+
shadows: true,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function createSceneLights() {
|
|
19
|
+
const group = new THREE.Group();
|
|
20
|
+
const hemi = new THREE.HemisphereLight(0xffffff, 0x444444, 1);
|
|
21
|
+
const sun = new THREE.DirectionalLight(0xffffff, 1);
|
|
22
|
+
sun.castShadow = true;
|
|
23
|
+
sun.shadow.mapSize.set(2048, 2048);
|
|
24
|
+
sun.shadow.camera.left = -40;
|
|
25
|
+
sun.shadow.camera.right = 40;
|
|
26
|
+
sun.shadow.camera.top = 40;
|
|
27
|
+
sun.shadow.camera.bottom = -40;
|
|
28
|
+
sun.shadow.camera.near = 0.5;
|
|
29
|
+
sun.shadow.camera.far = 200;
|
|
30
|
+
sun.shadow.bias = -0.0005;
|
|
31
|
+
group.add(hemi, sun, sun.target);
|
|
32
|
+
return { group, hemi, sun };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function applyLightingSettings(lights, settings) {
|
|
36
|
+
const s = { ...defaultLighting, ...(settings ?? {}) };
|
|
37
|
+
lights.hemi.color.set(s.sky);
|
|
38
|
+
lights.hemi.groundColor.set(s.ground);
|
|
39
|
+
lights.hemi.intensity = s.hemiIntensity;
|
|
40
|
+
lights.sun.color.set(s.sun);
|
|
41
|
+
lights.sun.intensity = s.sunIntensity;
|
|
42
|
+
lights.sun.castShadow = !!s.shadows;
|
|
43
|
+
const azimuth = (s.sunAzimuth * Math.PI) / 180;
|
|
44
|
+
const elevation = (s.sunElevation * Math.PI) / 180;
|
|
45
|
+
const radius = 60;
|
|
46
|
+
lights.sun.position.set(
|
|
47
|
+
radius * Math.cos(elevation) * Math.sin(azimuth),
|
|
48
|
+
radius * Math.sin(elevation),
|
|
49
|
+
radius * Math.cos(elevation) * Math.cos(azimuth)
|
|
50
|
+
);
|
|
51
|
+
lights.sun.target.position.set(0, 0, 0);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const geometryKinds = ['box', 'sphere', 'cylinder', 'cone', 'capsule', 'torus', 'plane'];
|
|
55
|
+
|
|
56
|
+
// Build a sized primitive geometry. `width`/`height`/`depth` are the bounding
|
|
57
|
+
// dimensions for every kind, so swapping `geometry` keeps the actor's size.
|
|
58
|
+
export function buildGeometry(kind, width, height, depth) {
|
|
59
|
+
const w = Math.max(0.01, width ?? 1);
|
|
60
|
+
const h = Math.max(0.01, height ?? 1);
|
|
61
|
+
const d = Math.max(0.01, depth ?? 1);
|
|
62
|
+
switch (kind) {
|
|
63
|
+
case 'sphere':
|
|
64
|
+
return scaledToBox(new THREE.SphereGeometry(0.5, 32, 16), w, h, d);
|
|
65
|
+
case 'cylinder':
|
|
66
|
+
return scaledToBox(new THREE.CylinderGeometry(0.5, 0.5, 1, 32), w, h, d);
|
|
67
|
+
case 'cone':
|
|
68
|
+
return scaledToBox(new THREE.ConeGeometry(0.5, 1, 32), w, h, d);
|
|
69
|
+
case 'capsule':
|
|
70
|
+
return scaledToBox(new THREE.CapsuleGeometry(0.25, 0.5, 8, 16), w, h, d);
|
|
71
|
+
case 'torus':
|
|
72
|
+
return scaledToBox(new THREE.TorusGeometry(0.35, 0.15, 16, 48), w, h, d);
|
|
73
|
+
case 'plane': {
|
|
74
|
+
const plane = new THREE.PlaneGeometry(w, d);
|
|
75
|
+
plane.rotateX(-Math.PI / 2);
|
|
76
|
+
return plane;
|
|
77
|
+
}
|
|
78
|
+
case 'box':
|
|
79
|
+
default:
|
|
80
|
+
return new THREE.BoxGeometry(w, h, d);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Normalize a unit-ish primitive into a w x h x d bounding box.
|
|
85
|
+
function scaledToBox(geometry, w, h, d) {
|
|
86
|
+
geometry.computeBoundingBox();
|
|
87
|
+
const size = new THREE.Vector3();
|
|
88
|
+
geometry.boundingBox.getSize(size);
|
|
89
|
+
geometry.scale(w / (size.x || 1), h / (size.y || 1), d / (size.z || 1));
|
|
90
|
+
return geometry;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function buildStandardMaterial(props) {
|
|
94
|
+
const material = new THREE.MeshStandardMaterial();
|
|
95
|
+
applyStandardMaterial(material, props);
|
|
96
|
+
return material;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function applyStandardMaterial(material, props) {
|
|
100
|
+
material.color.set(colorOf(props.color ?? '#ffffffff'));
|
|
101
|
+
material.roughness = props.roughness ?? 0.8;
|
|
102
|
+
material.metalness = props.metalness ?? 0;
|
|
103
|
+
material.emissive.set(colorOf(props.emissive ?? '#00000000'));
|
|
104
|
+
const opacity = alphaOf(props.color ?? '#ffffffff') * (props.opacity ?? 1);
|
|
105
|
+
material.transparent = opacity < 1;
|
|
106
|
+
material.opacity = opacity;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// `#rgb`, `#rrggbb`, or `#rrggbbaa` -- THREE.Color does not parse the alpha
|
|
110
|
+
// byte, so strip it and surface it via `alphaOf`.
|
|
111
|
+
export function colorOf(value) {
|
|
112
|
+
const raw = typeof value === 'string' ? value.trim() : '';
|
|
113
|
+
if (/^#[0-9a-fA-F]{8}$/.test(raw)) return raw.slice(0, 7);
|
|
114
|
+
return raw || '#ffffff';
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function alphaOf(value) {
|
|
118
|
+
const raw = typeof value === 'string' ? value.trim() : '';
|
|
119
|
+
if (/^#[0-9a-fA-F]{8}$/.test(raw)) return parseInt(raw.slice(7, 9), 16) / 255;
|
|
120
|
+
return 1;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Multiply two hex colors (tint application for Model parts).
|
|
124
|
+
export function multiplyColors(base, tint) {
|
|
125
|
+
const a = new THREE.Color(colorOf(base));
|
|
126
|
+
const b = new THREE.Color(colorOf(tint));
|
|
127
|
+
a.multiply(b);
|
|
128
|
+
return `#${a.getHexString()}`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export const defaultModelPart = {
|
|
132
|
+
geometry: 'box',
|
|
133
|
+
x: 0,
|
|
134
|
+
y: 0,
|
|
135
|
+
z: 0,
|
|
136
|
+
rotationX: 0,
|
|
137
|
+
rotationY: 0,
|
|
138
|
+
rotationZ: 0,
|
|
139
|
+
width: 1,
|
|
140
|
+
height: 1,
|
|
141
|
+
depth: 1,
|
|
142
|
+
color: '#ffffffff',
|
|
143
|
+
roughness: 0.8,
|
|
144
|
+
metalness: 0,
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
// Build a THREE.Group from `.model` file data: `{ parts: [partProps...] }`.
|
|
148
|
+
// Each mesh carries `userData.partIndex` so editors can raycast to a part.
|
|
149
|
+
export function buildModelGroup(modelData, tint) {
|
|
150
|
+
const group = new THREE.Group();
|
|
151
|
+
for (const [index, rawPart] of (modelData?.parts ?? []).entries()) {
|
|
152
|
+
const part = { ...defaultModelPart, ...rawPart };
|
|
153
|
+
const geometry = buildGeometry(part.geometry, part.width, part.height, part.depth);
|
|
154
|
+
const color = tint ? multiplyColors(part.color, tint) : part.color;
|
|
155
|
+
const material = buildStandardMaterial({ ...part, color });
|
|
156
|
+
const mesh = new THREE.Mesh(geometry, material);
|
|
157
|
+
mesh.position.set(part.x, part.y, part.z);
|
|
158
|
+
mesh.rotation.set(
|
|
159
|
+
degreesToRadians(part.rotationX),
|
|
160
|
+
degreesToRadians(part.rotationY),
|
|
161
|
+
degreesToRadians(part.rotationZ)
|
|
162
|
+
);
|
|
163
|
+
mesh.castShadow = true;
|
|
164
|
+
mesh.receiveShadow = true;
|
|
165
|
+
mesh.userData.partIndex = index;
|
|
166
|
+
group.add(mesh);
|
|
167
|
+
}
|
|
168
|
+
return group;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function degreesToRadians(degrees) {
|
|
172
|
+
return ((degrees ?? 0) * Math.PI) / 180;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function radiansToDegrees(radians) {
|
|
176
|
+
return ((radians ?? 0) * 180) / Math.PI;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Recursively dispose geometries + materials under an object.
|
|
180
|
+
export function disposeObject3D(object) {
|
|
181
|
+
object.traverse((child) => {
|
|
182
|
+
child.geometry?.dispose?.();
|
|
183
|
+
const materials = Array.isArray(child.material) ? child.material : [child.material];
|
|
184
|
+
for (const material of materials) material?.dispose?.();
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Common prologue for render behaviors' `sync`: resolve the actor's group and
|
|
189
|
+
// its `runtime` cache, or null when hidden (`actor.runtime.hidden`) / absent.
|
|
190
|
+
export function renderTarget(actor, scene) {
|
|
191
|
+
if (actor.runtime?.hidden) return null;
|
|
192
|
+
const group = scene.actorGroup(actor);
|
|
193
|
+
if (!group) return null;
|
|
194
|
+
return { group, cache: actor.runtime };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Remove a behavior's cached object from the actor group and dispose it.
|
|
198
|
+
// Behaviors call this when their props key changes (and the kit calls it on
|
|
199
|
+
// actor teardown via group disposal).
|
|
200
|
+
export function replaceGroupChild(group, previous, next) {
|
|
201
|
+
if (previous) {
|
|
202
|
+
group.remove(previous);
|
|
203
|
+
disposeObject3D(previous);
|
|
204
|
+
}
|
|
205
|
+
if (next) group.add(next);
|
|
206
|
+
return next;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Create the WebGL renderer for a viewport canvas. `preserveDrawingBuffer`
|
|
210
|
+
// keeps the frame readable for screenshots/preview capture.
|
|
211
|
+
export function createRenderer(canvas) {
|
|
212
|
+
const renderer = new THREE.WebGLRenderer({
|
|
213
|
+
canvas,
|
|
214
|
+
antialias: true,
|
|
215
|
+
preserveDrawingBuffer: true,
|
|
216
|
+
});
|
|
217
|
+
renderer.shadowMap.enabled = true;
|
|
218
|
+
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
|
219
|
+
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
|
220
|
+
return renderer;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Match the renderer + camera to the canvas's CSS size each frame (cheap when
|
|
224
|
+
// nothing changed). Returns false when the canvas has no size yet.
|
|
225
|
+
export function fitRendererToCanvas(renderer, camera, canvas) {
|
|
226
|
+
const width = canvas.clientWidth;
|
|
227
|
+
const height = canvas.clientHeight;
|
|
228
|
+
if (!width || !height) return false;
|
|
229
|
+
const pixelRatio = Math.min(window.devicePixelRatio || 1, 2);
|
|
230
|
+
if (renderer.getPixelRatio() !== pixelRatio) renderer.setPixelRatio(pixelRatio);
|
|
231
|
+
const size = renderer.getSize(new THREE.Vector2());
|
|
232
|
+
if (size.x !== width || size.y !== height) renderer.setSize(width, height, false);
|
|
233
|
+
const aspect = width / height;
|
|
234
|
+
if (camera.aspect !== aspect) {
|
|
235
|
+
camera.aspect = aspect;
|
|
236
|
+
camera.updateProjectionMatrix();
|
|
237
|
+
}
|
|
238
|
+
return true;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Apply a runtime camera spec (`scene.camera`, e.g. from the Camera behavior)
|
|
242
|
+
// to a PerspectiveCamera. Spec: target point + azimuth/elevation/distance
|
|
243
|
+
// orbit position + fov.
|
|
244
|
+
export function applyCameraSpec(camera, spec) {
|
|
245
|
+
const azimuth = degreesToRadians(spec.azimuth ?? 45);
|
|
246
|
+
const elevation = degreesToRadians(spec.elevation ?? 35);
|
|
247
|
+
const distance = spec.distance ?? 24;
|
|
248
|
+
const target = new THREE.Vector3(spec.targetX ?? 0, spec.targetY ?? 0, spec.targetZ ?? 0);
|
|
249
|
+
camera.position.set(
|
|
250
|
+
target.x + distance * Math.cos(elevation) * Math.sin(azimuth),
|
|
251
|
+
target.y + distance * Math.sin(elevation),
|
|
252
|
+
target.z + distance * Math.cos(elevation) * Math.cos(azimuth)
|
|
253
|
+
);
|
|
254
|
+
camera.lookAt(target);
|
|
255
|
+
const fov = spec.fov ?? 50;
|
|
256
|
+
if (camera.fov !== fov) {
|
|
257
|
+
camera.fov = fov;
|
|
258
|
+
camera.updateProjectionMatrix();
|
|
259
|
+
}
|
|
260
|
+
}
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
import React, { useRef, useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
faArrowsAlt,
|
|
4
|
+
faBars,
|
|
5
|
+
faChevronDown,
|
|
6
|
+
faChevronRight,
|
|
7
|
+
faChevronUp,
|
|
8
|
+
faClone,
|
|
9
|
+
faCube,
|
|
10
|
+
faExpandArrowsAlt,
|
|
11
|
+
faObjectGroup,
|
|
12
|
+
faPalette,
|
|
13
|
+
faPlay,
|
|
14
|
+
faPlus,
|
|
15
|
+
faRedo,
|
|
16
|
+
faStop,
|
|
17
|
+
faSyncAlt,
|
|
18
|
+
faTimes,
|
|
19
|
+
faTrash,
|
|
20
|
+
faUndo,
|
|
21
|
+
faVideo,
|
|
22
|
+
} from '@fortawesome/free-solid-svg-icons';
|
|
23
|
+
import styles from './ui.module.css';
|
|
24
|
+
export { styles };
|
|
25
|
+
export const theme = {
|
|
26
|
+
transparentChecker:
|
|
27
|
+
'repeating-linear-gradient(45deg, #050505, #050505 4px, #252525 4px, #252525 8px)',
|
|
28
|
+
};
|
|
29
|
+
export function cx(...parts) {
|
|
30
|
+
return parts.filter(Boolean).join(' ');
|
|
31
|
+
}
|
|
32
|
+
export function AppShell({ children }) {
|
|
33
|
+
return <div className={styles.appShell}>{children}</div>;
|
|
34
|
+
}
|
|
35
|
+
export function MainEditor({ children }) {
|
|
36
|
+
return <main className={styles.mainEditor}>{children}</main>;
|
|
37
|
+
}
|
|
38
|
+
export function EditorHeader({ title, subtitle, right, onToggleFiles, filesOpen }) {
|
|
39
|
+
const hasRight = !!right || !!onToggleFiles;
|
|
40
|
+
return (
|
|
41
|
+
<header className={styles.editorHeader}>
|
|
42
|
+
<div className={styles.editorHeaderLeft}>
|
|
43
|
+
<div>
|
|
44
|
+
<div className={styles.editorTitle}>{title}</div>
|
|
45
|
+
{subtitle ? <div className={styles.muted}>{subtitle}</div> : null}
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
48
|
+
{hasRight ? (
|
|
49
|
+
<div className={styles.editorHeaderRight}>
|
|
50
|
+
{right}
|
|
51
|
+
{onToggleFiles ? (
|
|
52
|
+
<button
|
|
53
|
+
type="button"
|
|
54
|
+
className={cx(styles.headerFilesButton, styles.mobileOnly)}
|
|
55
|
+
onClick={onToggleFiles}
|
|
56
|
+
aria-label="Files"
|
|
57
|
+
aria-pressed={filesOpen ? 'true' : 'false'}>
|
|
58
|
+
<Icon name="bars" />
|
|
59
|
+
</button>
|
|
60
|
+
) : null}
|
|
61
|
+
</div>
|
|
62
|
+
) : null}
|
|
63
|
+
</header>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
export function EditorBody({ children }) {
|
|
67
|
+
return <div className={styles.editorBody}>{children}</div>;
|
|
68
|
+
}
|
|
69
|
+
export function Button({ children, active = false, variant = '', className = '', ...props }) {
|
|
70
|
+
return (
|
|
71
|
+
<button
|
|
72
|
+
className={cx(
|
|
73
|
+
styles.button,
|
|
74
|
+
active && styles.buttonActive,
|
|
75
|
+
variant === 'primary' && styles.buttonPrimary,
|
|
76
|
+
variant === 'danger' && styles.buttonDanger,
|
|
77
|
+
className
|
|
78
|
+
)}
|
|
79
|
+
{...props}>
|
|
80
|
+
{children}
|
|
81
|
+
</button>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
export function IconButton({ icon, label, active = false, variant = '', ...props }) {
|
|
85
|
+
return (
|
|
86
|
+
<Button
|
|
87
|
+
active={active}
|
|
88
|
+
variant={variant}
|
|
89
|
+
className={styles.iconButton}
|
|
90
|
+
aria-label={label}
|
|
91
|
+
title={label}
|
|
92
|
+
{...props}>
|
|
93
|
+
<Icon name={icon} />
|
|
94
|
+
</Button>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
const icons = {
|
|
98
|
+
bars: faBars,
|
|
99
|
+
camera: faVideo,
|
|
100
|
+
cube: faCube,
|
|
101
|
+
move: faArrowsAlt,
|
|
102
|
+
rotate: faSyncAlt,
|
|
103
|
+
scale: faExpandArrowsAlt,
|
|
104
|
+
'chevron-down': faChevronDown,
|
|
105
|
+
'chevron-right': faChevronRight,
|
|
106
|
+
'chevron-up': faChevronUp,
|
|
107
|
+
clone: faClone,
|
|
108
|
+
'object-group': faObjectGroup,
|
|
109
|
+
palette: faPalette,
|
|
110
|
+
play: faPlay,
|
|
111
|
+
plus: faPlus,
|
|
112
|
+
redo: faRedo,
|
|
113
|
+
stop: faStop,
|
|
114
|
+
times: faTimes,
|
|
115
|
+
trash: faTrash,
|
|
116
|
+
undo: faUndo,
|
|
117
|
+
};
|
|
118
|
+
export function Icon({ name }) {
|
|
119
|
+
const def = icons[name];
|
|
120
|
+
if (!def) return null;
|
|
121
|
+
const [width, height, , , path] = def.icon;
|
|
122
|
+
const d = Array.isArray(path) ? path.join(' ') : path;
|
|
123
|
+
return (
|
|
124
|
+
<svg
|
|
125
|
+
viewBox={`0 0 ${width} ${height}`}
|
|
126
|
+
aria-hidden="true"
|
|
127
|
+
style={{ width: '1em', height: '1em', display: 'inline-block', fill: 'currentColor' }}>
|
|
128
|
+
<path d={d} />
|
|
129
|
+
</svg>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
export function Panel({ title, children }) {
|
|
133
|
+
return (
|
|
134
|
+
<section className={styles.panel}>
|
|
135
|
+
{title ? <div className={styles.panelHeader}>{title}</div> : null}
|
|
136
|
+
<div className={styles.panelBody}>{children}</div>
|
|
137
|
+
</section>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
export function FieldRow({ label, children }) {
|
|
141
|
+
return (
|
|
142
|
+
<label className={styles.fieldRow}>
|
|
143
|
+
<span className={styles.fieldLabel}>{label}</span>
|
|
144
|
+
{children}
|
|
145
|
+
</label>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
export function TextField({ label, value, onChange }) {
|
|
149
|
+
return (
|
|
150
|
+
<FieldRow label={label}>
|
|
151
|
+
<input
|
|
152
|
+
className={styles.input}
|
|
153
|
+
value={value ?? ''}
|
|
154
|
+
onChange={(event) => onChange(event.target.value)}
|
|
155
|
+
/>
|
|
156
|
+
</FieldRow>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
export function NumberField({ label, value, onChange, min, max, step = 1 }) {
|
|
160
|
+
const current = Number.isFinite(value) ? (value ?? 0) : 0;
|
|
161
|
+
const [draft, setDraft] = useState(String(current));
|
|
162
|
+
const editingRef = useRef(false);
|
|
163
|
+
const cancelRef = useRef(false);
|
|
164
|
+
// keep the draft in sync with the committed value while not editing
|
|
165
|
+
if (!editingRef.current && draft !== String(current)) {
|
|
166
|
+
setDraft(String(current));
|
|
167
|
+
}
|
|
168
|
+
function commit(raw) {
|
|
169
|
+
let next = Number(raw);
|
|
170
|
+
if (!Number.isFinite(next)) next = current;
|
|
171
|
+
if (min != null && next < min) next = min;
|
|
172
|
+
if (max != null && next > max) next = max;
|
|
173
|
+
setDraft(String(next));
|
|
174
|
+
onChange(next);
|
|
175
|
+
}
|
|
176
|
+
function stepBy(delta) {
|
|
177
|
+
let next = current + delta;
|
|
178
|
+
if (min != null && next < min) next = min;
|
|
179
|
+
if (max != null && next > max) next = max;
|
|
180
|
+
setDraft(String(next));
|
|
181
|
+
onChange(next);
|
|
182
|
+
}
|
|
183
|
+
return (
|
|
184
|
+
<FieldRow label={label}>
|
|
185
|
+
<div className={styles.numberField}>
|
|
186
|
+
<input
|
|
187
|
+
className={cx(styles.input, styles.numberInput)}
|
|
188
|
+
type="number"
|
|
189
|
+
min={min}
|
|
190
|
+
max={max}
|
|
191
|
+
step={step}
|
|
192
|
+
value={draft}
|
|
193
|
+
onFocus={() => {
|
|
194
|
+
editingRef.current = true;
|
|
195
|
+
}}
|
|
196
|
+
onChange={(event) => setDraft(event.target.value)}
|
|
197
|
+
onBlur={(event) => {
|
|
198
|
+
editingRef.current = false;
|
|
199
|
+
if (cancelRef.current) {
|
|
200
|
+
cancelRef.current = false;
|
|
201
|
+
setDraft(String(current));
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
commit(event.target.value);
|
|
205
|
+
}}
|
|
206
|
+
onKeyDown={(event) => {
|
|
207
|
+
if (event.key === 'Enter') {
|
|
208
|
+
event.preventDefault();
|
|
209
|
+
commit(event.currentTarget.value);
|
|
210
|
+
event.currentTarget.blur();
|
|
211
|
+
} else if (event.key === 'Escape') {
|
|
212
|
+
event.preventDefault();
|
|
213
|
+
cancelRef.current = true;
|
|
214
|
+
setDraft(String(current));
|
|
215
|
+
event.currentTarget.blur();
|
|
216
|
+
}
|
|
217
|
+
}}
|
|
218
|
+
/>
|
|
219
|
+
<div className={styles.numberSteppers}>
|
|
220
|
+
<button
|
|
221
|
+
type="button"
|
|
222
|
+
className={styles.numberStepper}
|
|
223
|
+
tabIndex={-1}
|
|
224
|
+
aria-label="Increment"
|
|
225
|
+
disabled={max != null && current >= max}
|
|
226
|
+
onClick={() => stepBy(step)}>
|
|
227
|
+
<Icon name="chevron-up" />
|
|
228
|
+
</button>
|
|
229
|
+
<button
|
|
230
|
+
type="button"
|
|
231
|
+
className={styles.numberStepper}
|
|
232
|
+
tabIndex={-1}
|
|
233
|
+
aria-label="Decrement"
|
|
234
|
+
disabled={min != null && current <= min}
|
|
235
|
+
onClick={() => stepBy(-step)}>
|
|
236
|
+
<Icon name="chevron-down" />
|
|
237
|
+
</button>
|
|
238
|
+
</div>
|
|
239
|
+
</div>
|
|
240
|
+
</FieldRow>
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
export function CheckboxField({ label, checked, onChange }) {
|
|
244
|
+
return (
|
|
245
|
+
<FieldRow label={label}>
|
|
246
|
+
<button
|
|
247
|
+
type="button"
|
|
248
|
+
className={cx(styles.toggle, checked && styles.toggleOn)}
|
|
249
|
+
role="switch"
|
|
250
|
+
aria-checked={!!checked}
|
|
251
|
+
onClick={() => onChange(!checked)}>
|
|
252
|
+
<span className={styles.toggleKnob} />
|
|
253
|
+
</button>
|
|
254
|
+
</FieldRow>
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
export function ColorField({ label, value, onChange }) {
|
|
258
|
+
const hex = normalizeHex(value);
|
|
259
|
+
const alpha = hex.length === 9 ? hex.slice(7) : '';
|
|
260
|
+
const rgb = hex.slice(0, 7);
|
|
261
|
+
return (
|
|
262
|
+
<FieldRow label={label}>
|
|
263
|
+
<input
|
|
264
|
+
className={styles.colorInput}
|
|
265
|
+
type="color"
|
|
266
|
+
value={rgb}
|
|
267
|
+
onChange={(event) => onChange(event.target.value + alpha)}
|
|
268
|
+
/>
|
|
269
|
+
</FieldRow>
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
function normalizeHex(value) {
|
|
273
|
+
const raw = (value ?? '').trim();
|
|
274
|
+
if (/^#[0-9a-fA-F]{3}$/.test(raw)) {
|
|
275
|
+
return '#' + raw.slice(1).replace(/./g, (c) => c + c);
|
|
276
|
+
}
|
|
277
|
+
if (/^#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$/.test(raw)) return raw;
|
|
278
|
+
return '#000000';
|
|
279
|
+
}
|
|
280
|
+
export function isHexColor(value) {
|
|
281
|
+
return (
|
|
282
|
+
typeof value === 'string' && /^#[0-9a-fA-F]{3}([0-9a-fA-F]{3}([0-9a-fA-F]{2})?)?$/.test(value)
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
export function useMobileSheet({ snap, baseClassName, onTransition }) {
|
|
286
|
+
const startY = useRef(0);
|
|
287
|
+
const lastDy = useRef(0);
|
|
288
|
+
const dragging = useRef(false);
|
|
289
|
+
function onPointerDown(event) {
|
|
290
|
+
startY.current = event.clientY;
|
|
291
|
+
lastDy.current = 0;
|
|
292
|
+
dragging.current = true;
|
|
293
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
294
|
+
}
|
|
295
|
+
function onPointerMove(event) {
|
|
296
|
+
if (!dragging.current) return;
|
|
297
|
+
lastDy.current = event.clientY - startY.current;
|
|
298
|
+
}
|
|
299
|
+
function endDrag() {
|
|
300
|
+
if (!dragging.current) return;
|
|
301
|
+
dragging.current = false;
|
|
302
|
+
const dy = lastDy.current;
|
|
303
|
+
lastDy.current = 0;
|
|
304
|
+
if (Math.abs(dy) < 6) onTransition('tap');
|
|
305
|
+
else if (dy > 30) onTransition('down');
|
|
306
|
+
else if (dy < -30) onTransition('up');
|
|
307
|
+
}
|
|
308
|
+
return {
|
|
309
|
+
rootProps: {
|
|
310
|
+
className: baseClassName,
|
|
311
|
+
'data-sheet-snap': snap,
|
|
312
|
+
},
|
|
313
|
+
grabProps: {
|
|
314
|
+
className: cx(styles.sheetGrab, styles.mobileOnly),
|
|
315
|
+
onPointerDown,
|
|
316
|
+
onPointerMove,
|
|
317
|
+
onPointerUp: endDrag,
|
|
318
|
+
onPointerCancel: endDrag,
|
|
319
|
+
},
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
export function SheetGrabHandle({ label, hint }) {
|
|
323
|
+
return (
|
|
324
|
+
<>
|
|
325
|
+
<div className={styles.sheetGrabBar} />
|
|
326
|
+
<div className={styles.sheetGrabLabelRow}>
|
|
327
|
+
<span className={styles.sheetGrabLabel}>{label}</span>
|
|
328
|
+
{hint ? <span className={styles.sheetGrabHint}>{hint}</span> : null}
|
|
329
|
+
</div>
|
|
330
|
+
</>
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
export function SelectField({ label, value, onChange, options }) {
|
|
334
|
+
return (
|
|
335
|
+
<FieldRow label={label}>
|
|
336
|
+
<select
|
|
337
|
+
className={styles.select}
|
|
338
|
+
value={value ?? ''}
|
|
339
|
+
onChange={(event) => onChange(event.target.value)}>
|
|
340
|
+
{options.map((option) => {
|
|
341
|
+
const value = typeof option === 'string' ? option : option.value;
|
|
342
|
+
const label = typeof option === 'string' ? option : option.label;
|
|
343
|
+
return (
|
|
344
|
+
<option key={value} value={value}>
|
|
345
|
+
{label}
|
|
346
|
+
</option>
|
|
347
|
+
);
|
|
348
|
+
})}
|
|
349
|
+
</select>
|
|
350
|
+
</FieldRow>
|
|
351
|
+
);
|
|
352
|
+
}
|