@jgengine/editor 0.14.0 → 0.15.0
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/CHANGELOG.md +500 -1
- package/dist/EditorApp.js +2 -2
- package/dist/EditorCameraDriver.d.ts +6 -1
- package/dist/EditorCameraDriver.js +25 -1
- package/dist/EditorChrome.js +2 -4
- package/dist/InspectorPanel.js +12 -1
- package/dist/ParentPickerMenu.js +2 -6
- package/dist/RuntimePlayBridge.js +10 -15
- package/dist/SchemaInspector.js +55 -29
- package/dist/TerrainPanel.js +3 -4
- package/dist/TerrainReadout.js +1 -3
- package/dist/agent/AgentPanel.js +7 -11
- package/dist/camera/orbitFraming.d.ts +62 -0
- package/dist/camera/orbitFraming.js +69 -0
- package/dist/chromeFields.js +9 -4
- package/dist/handlers/document.d.ts +1 -1
- package/dist/handlers/document.js +78 -9
- package/dist/mcp/fieldSpec.js +4 -2
- package/dist/mcp/tools.js +4 -2
- package/dist/session.d.ts +54 -15
- package/dist/shell/AnimationPanel.js +14 -20
- package/dist/shell/CommandPalette.js +1 -5
- package/dist/shell/HierarchyPanel.js +17 -26
- package/dist/shell/PlayModeBar.d.ts +7 -0
- package/dist/shell/PlayModeBar.js +12 -3
- package/dist/shell/SceneToolbar.js +9 -5
- package/dist/shell/fields.js +8 -8
- package/dist/shell/glbThumbnailCache.js +39 -8
- package/dist/shell/scatterCoverage.js +10 -1
- package/dist/useStoreSelector.d.ts +12 -0
- package/dist/useStoreSelector.js +23 -0
- package/package.json +5 -5
- package/skills/ce-handoff/SKILL.md +149 -0
- package/skills/jgengine/SKILL.md +3 -1
- package/skills/jgengine/api.md +17 -7
- package/skills/jgengine/capabilities.md +12 -0
- package/skills/jgengine/recipes/minimal-game.md +7 -4
- package/skills/jgengine-editor/api.md +16 -3
- package/skills/jgengine-editor/reference.md +44 -0
|
@@ -2,6 +2,20 @@ import { editorDocumentBounds, listEditorKinds, summarizeEditorSession, } from "
|
|
|
2
2
|
import { resolvePlaceAsset, toEditorMarker } from "@jgengine/core/world/placeAsset";
|
|
3
3
|
import { findSchemaPreset, getSceneKind, validateParams } from "@jgengine/core/scene/sceneKinds";
|
|
4
4
|
import { EDITOR_RUN_MODES } from "../session.js";
|
|
5
|
+
import { boundsCenter, frameDistanceForBounds } from "../camera/orbitFraming.js";
|
|
6
|
+
/** Collect the finite placement fields present on a camera request into a focus-target patch. */
|
|
7
|
+
function cameraPlacement(request) {
|
|
8
|
+
const placement = {};
|
|
9
|
+
if (typeof request.distance === "number" && Number.isFinite(request.distance))
|
|
10
|
+
placement.distance = request.distance;
|
|
11
|
+
if (typeof request.pitch === "number" && Number.isFinite(request.pitch))
|
|
12
|
+
placement.pitch = request.pitch;
|
|
13
|
+
if (typeof request.yaw === "number" && Number.isFinite(request.yaw))
|
|
14
|
+
placement.yaw = request.yaw;
|
|
15
|
+
if (typeof request.height === "number" && Number.isFinite(request.height))
|
|
16
|
+
placement.height = request.height;
|
|
17
|
+
return placement;
|
|
18
|
+
}
|
|
5
19
|
/** True when any of `ids` names a placeable object (marker/volume/path/note) in the document — used
|
|
6
20
|
* to reject batch mutations that would match nothing, so the caller hears an honest `ok:false`. */
|
|
7
21
|
function documentHasAnyId(doc, ids) {
|
|
@@ -134,6 +148,49 @@ export const documentHandlers = {
|
|
|
134
148
|
});
|
|
135
149
|
return { ok: true, result: ctx.session.getState().document.paths.find((p) => p.id === request.id) };
|
|
136
150
|
},
|
|
151
|
+
add_path: (ctx, request) => {
|
|
152
|
+
if (request.points.length < 2)
|
|
153
|
+
return { ok: false, error: "add_path needs at least 2 points" };
|
|
154
|
+
const kind = request.kind ?? "route";
|
|
155
|
+
const invalid = validateMetaForKind(kind, request.meta);
|
|
156
|
+
if (invalid !== null)
|
|
157
|
+
return { ok: false, error: invalid };
|
|
158
|
+
const path = {
|
|
159
|
+
id: request.id,
|
|
160
|
+
kind,
|
|
161
|
+
points: request.points.map((point) => ({ x: point.x, y: point.y ?? 0, z: point.z })),
|
|
162
|
+
...(request.width === undefined ? {} : { width: request.width }),
|
|
163
|
+
...(request.color === undefined ? {} : { color: request.color }),
|
|
164
|
+
...(request.label === undefined ? {} : { label: request.label }),
|
|
165
|
+
...(request.meta === undefined ? {} : { meta: request.meta }),
|
|
166
|
+
};
|
|
167
|
+
// addPath re-ids on a document-global collision, so read the created id back from the selection.
|
|
168
|
+
const { applied, state } = ctx.dispatchGuarded({ type: "addPath", path });
|
|
169
|
+
if (!applied)
|
|
170
|
+
return { ok: false, error: "add_path rejected: no effect" };
|
|
171
|
+
const createdId = state.selection[0] ?? request.id;
|
|
172
|
+
return { ok: true, result: state.document.paths.find((p) => p.id === createdId) };
|
|
173
|
+
},
|
|
174
|
+
add_marker: (ctx, request) => {
|
|
175
|
+
const invalid = validateMetaForKind(request.kind, request.meta);
|
|
176
|
+
if (invalid !== null)
|
|
177
|
+
return { ok: false, error: invalid };
|
|
178
|
+
const marker = {
|
|
179
|
+
id: request.id,
|
|
180
|
+
kind: request.kind,
|
|
181
|
+
position: { x: request.x, y: request.y ?? 0, z: request.z },
|
|
182
|
+
...(request.rotationY === undefined ? {} : { rotationY: request.rotationY }),
|
|
183
|
+
...(request.color === undefined ? {} : { color: request.color }),
|
|
184
|
+
...(request.label === undefined ? {} : { label: request.label }),
|
|
185
|
+
...(request.meta === undefined ? {} : { meta: request.meta }),
|
|
186
|
+
};
|
|
187
|
+
// addMarker re-ids on a document-global collision, so read the created id back from the selection.
|
|
188
|
+
const { applied, state } = ctx.dispatchGuarded({ type: "addMarker", marker });
|
|
189
|
+
if (!applied)
|
|
190
|
+
return { ok: false, error: "add_marker rejected: no effect" };
|
|
191
|
+
const createdId = state.selection[0] ?? request.id;
|
|
192
|
+
return { ok: true, result: state.document.markers.find((m) => m.id === createdId) };
|
|
193
|
+
},
|
|
137
194
|
set_marker: (ctx, request) => {
|
|
138
195
|
const marker = ctx.session.getState().document.markers.find((m) => m.id === request.id);
|
|
139
196
|
if (marker === undefined)
|
|
@@ -218,6 +275,7 @@ export const documentHandlers = {
|
|
|
218
275
|
return { ok: true, result: { selection: [] } };
|
|
219
276
|
},
|
|
220
277
|
camera_goto: (ctx, request) => {
|
|
278
|
+
const placement = cameraPlacement(request);
|
|
221
279
|
if (request.id !== undefined) {
|
|
222
280
|
const state = ctx.session.getState();
|
|
223
281
|
const marker = state.document.markers.find((m) => m.id === request.id);
|
|
@@ -225,26 +283,32 @@ export const documentHandlers = {
|
|
|
225
283
|
const point = marker?.position ?? volume?.center;
|
|
226
284
|
if (point === undefined)
|
|
227
285
|
return { ok: false, error: `id not found: ${request.id}` };
|
|
228
|
-
|
|
286
|
+
const target = { x: point.x, y: point.y, z: point.z, ...placement };
|
|
287
|
+
ctx.api.setFocusTarget(target);
|
|
229
288
|
ctx.session.dispatch({ type: "select", ids: [request.id] });
|
|
230
|
-
return { ok: true, result: { target
|
|
289
|
+
return { ok: true, result: { target } };
|
|
231
290
|
}
|
|
232
291
|
if (request.x === undefined || request.z === undefined) {
|
|
233
292
|
return { ok: false, error: "camera_goto requires id or x/z" };
|
|
234
293
|
}
|
|
235
|
-
const target = { x: request.x, y: request.y ?? 0, z: request.z };
|
|
294
|
+
const target = { x: request.x, y: request.y ?? 0, z: request.z, ...placement };
|
|
236
295
|
ctx.api.setFocusTarget(target);
|
|
237
296
|
return { ok: true, result: { target } };
|
|
238
297
|
},
|
|
239
|
-
camera_frame: (ctx) => {
|
|
298
|
+
camera_frame: (ctx, request) => {
|
|
240
299
|
const bounds = editorDocumentBounds(ctx.session.getState().document);
|
|
241
300
|
if (bounds === null)
|
|
242
301
|
return { ok: false, error: "document is empty" };
|
|
243
|
-
const
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
302
|
+
const center = boundsCenter(bounds);
|
|
303
|
+
const placement = cameraPlacement(request);
|
|
304
|
+
// An aerial is requested when the caller supplies any camera placement. Fit the region into the
|
|
305
|
+
// camera's FOV when no explicit distance is given, so one call frames a whole district from
|
|
306
|
+
// above instead of burying the camera at the last-used distance.
|
|
307
|
+
const wantsAerial = Object.keys(placement).length > 0;
|
|
308
|
+
const resolved = wantsAerial && placement.distance === undefined
|
|
309
|
+
? { ...placement, distance: frameDistanceForBounds(bounds, { fovDeg: 50 }) }
|
|
310
|
+
: placement;
|
|
311
|
+
const target = { ...center, ...resolved };
|
|
248
312
|
ctx.api.setFocusTarget(target);
|
|
249
313
|
return { ok: true, result: { target, bounds } };
|
|
250
314
|
},
|
|
@@ -259,6 +323,11 @@ export const documentHandlers = {
|
|
|
259
323
|
}),
|
|
260
324
|
export_document: (ctx) => ({ ok: true, result: { json: ctx.session.exportJson(true) } }),
|
|
261
325
|
import_document: (ctx, request) => {
|
|
326
|
+
// Name the expected param instead of letting a missing/mis-keyed value fall into JSON.parse as
|
|
327
|
+
// the literal string "undefined" and surface a cryptic "JSON Parse error: Unexpected identifier".
|
|
328
|
+
if (typeof request.json !== "string") {
|
|
329
|
+
return { ok: false, error: "import_document requires a `json` param — the document JSON text (as export_document returns in result.json)" };
|
|
330
|
+
}
|
|
262
331
|
ctx.session.dispatch({ type: "importJson", json: request.json });
|
|
263
332
|
return { ok: true, result: summarizeEditorSession(ctx.session.getState()) };
|
|
264
333
|
},
|
package/dist/mcp/fieldSpec.js
CHANGED
|
@@ -44,14 +44,16 @@ export const RPC_FIELD_SCHEMAS = {
|
|
|
44
44
|
set_transform: [req(s("id")), n("x"), n("y"), n("z"), n("rotationY")],
|
|
45
45
|
set_volume: [req(s("id")), n("radius"), n("height"), n("x"), n("y"), n("z")],
|
|
46
46
|
set_path: [req(s("id")), s("kind"), n("width"), s("color"), s("label"), o("meta")],
|
|
47
|
+
add_path: [req(s("id")), req(oa("points", [req(n("x")), n("y"), req(n("z"))])), s("kind"), n("width"), s("color"), s("label"), o("meta")],
|
|
48
|
+
add_marker: [req(s("id")), req(s("kind")), req(n("x")), n("y"), req(n("z")), s("color"), s("label"), n("rotationY"), o("meta")],
|
|
47
49
|
set_marker: [req(s("id")), s("kind"), s("color"), s("label"), n("rotationY"), o("meta")],
|
|
48
50
|
set_note: [req(s("id")), s("text"), o("meta")],
|
|
49
51
|
set_meta: [req(s("id")), req(o("patch"))],
|
|
50
52
|
apply_preset: [req(s("id")), req(s("preset"))],
|
|
51
53
|
select: [req(sa("ids"))],
|
|
52
54
|
clear_selection: [],
|
|
53
|
-
camera_goto: [s("id"), n("x"), n("y"), n("z")],
|
|
54
|
-
camera_frame: [],
|
|
55
|
+
camera_goto: [s("id"), n("x"), n("y"), n("z"), n("distance"), n("pitch"), n("yaw"), n("height")],
|
|
56
|
+
camera_frame: [n("distance"), n("pitch"), n("yaw"), n("height")],
|
|
55
57
|
scene_summary: [],
|
|
56
58
|
export_document: [],
|
|
57
59
|
import_document: [req(s("json"))],
|
package/dist/mcp/tools.js
CHANGED
|
@@ -22,13 +22,15 @@ const TOOL_DESCRIPTIONS = [
|
|
|
22
22
|
{ name: "set_transform", description: "Move a marker or volume center by id." },
|
|
23
23
|
{ name: "set_volume", description: "Patch a volume radius/height/center." },
|
|
24
24
|
{ name: "set_path", description: "Patch a path's kind/width/color/label and merge-patch its meta (studio sliders: scatter density, pole spacing, …). Validated against the kind schema." },
|
|
25
|
+
{ name: "add_path", description: "Author a new path/route into the scene document from an ordered list of ≥2 {x,z} (optional y) points — the headless one-shot for placing a route without an export/import roundtrip. kind defaults to `route`; width/color/label/meta are optional and meta is validated against the kind schema. Re-ids on a document-global id collision." },
|
|
26
|
+
{ name: "add_marker", description: "Author a new point marker into the scene document at x/z (optional y) — the headless one-shot for placing a logical marker (spawn, checkpoint, pickup, bounty, or a game's own custom kind) without an export/import roundtrip. kind is required; color/label/rotationY/meta are optional and meta is validated against the kind schema when registered. Re-ids on a document-global id collision." },
|
|
25
27
|
{ name: "set_marker", description: "Patch a marker's kind/color/label/rotationY and merge-patch its meta. Validated against the kind schema when registered." },
|
|
26
28
|
{ name: "set_note", description: "Patch a note's text and merge-patch its meta." },
|
|
27
29
|
{ name: "set_meta", description: "Merge-patch the meta bag of any document object (marker/volume/path/note) by id — the generic studio-slider primitive. Rejected if it violates the kind's param schema." },
|
|
28
30
|
{ name: "apply_preset", description: "Apply a named preset bundle from the object's kind schema (e.g. a city archetype like manhattan/ruralohio) as one meta patch, ready to tweak afterwards." },
|
|
29
31
|
{ name: "select", description: "Select editor object ids." },
|
|
30
|
-
{ name: "camera_goto", description: "Focus the editor camera on an id or world x/z." },
|
|
31
|
-
{ name: "camera_frame", description: "Frame the whole editor document." },
|
|
32
|
+
{ name: "camera_goto", description: "Focus the editor camera on an id or world x/z. Optional distance/pitch/yaw/height reposition the orbit camera (pitch is degrees above the horizon; 90 = straight-down aerial) so an aerial composes in one call." },
|
|
33
|
+
{ name: "camera_frame", description: "Frame the whole editor document. Optional distance/pitch/yaw/height compose an aerial; when pitch is set without distance the region is auto-fit into view so the camera never buries in terrain." },
|
|
32
34
|
{ name: "scene_summary", description: "Compact summary of the editor document and bounds." },
|
|
33
35
|
{ name: "export_document", description: "Export the editor document as JSON text." },
|
|
34
36
|
{ name: "import_document", description: "Replace the session document from JSON text." },
|
package/dist/session.d.ts
CHANGED
|
@@ -82,6 +82,30 @@ export type EditorBridgeRequest = {
|
|
|
82
82
|
color?: string;
|
|
83
83
|
label?: string;
|
|
84
84
|
meta?: Record<string, unknown>;
|
|
85
|
+
} | {
|
|
86
|
+
method: "add_path";
|
|
87
|
+
id: string;
|
|
88
|
+
points: {
|
|
89
|
+
x: number;
|
|
90
|
+
y?: number;
|
|
91
|
+
z: number;
|
|
92
|
+
}[];
|
|
93
|
+
kind?: string;
|
|
94
|
+
width?: number;
|
|
95
|
+
color?: string;
|
|
96
|
+
label?: string;
|
|
97
|
+
meta?: Record<string, unknown>;
|
|
98
|
+
} | {
|
|
99
|
+
method: "add_marker";
|
|
100
|
+
id: string;
|
|
101
|
+
kind: string;
|
|
102
|
+
x: number;
|
|
103
|
+
y?: number;
|
|
104
|
+
z: number;
|
|
105
|
+
color?: string;
|
|
106
|
+
label?: string;
|
|
107
|
+
rotationY?: number;
|
|
108
|
+
meta?: Record<string, unknown>;
|
|
85
109
|
} | {
|
|
86
110
|
method: "set_marker";
|
|
87
111
|
id: string;
|
|
@@ -114,8 +138,16 @@ export type EditorBridgeRequest = {
|
|
|
114
138
|
x?: number;
|
|
115
139
|
y?: number;
|
|
116
140
|
z?: number;
|
|
141
|
+
distance?: number;
|
|
142
|
+
pitch?: number;
|
|
143
|
+
yaw?: number;
|
|
144
|
+
height?: number;
|
|
117
145
|
} | {
|
|
118
146
|
method: "camera_frame";
|
|
147
|
+
distance?: number;
|
|
148
|
+
pitch?: number;
|
|
149
|
+
yaw?: number;
|
|
150
|
+
height?: number;
|
|
119
151
|
} | {
|
|
120
152
|
method: "scene_summary";
|
|
121
153
|
} | {
|
|
@@ -499,6 +531,25 @@ export interface EditorPerfSample {
|
|
|
499
531
|
avgMs: number;
|
|
500
532
|
}[];
|
|
501
533
|
}
|
|
534
|
+
/**
|
|
535
|
+
* Where the editor orbit camera looks, plus optional placement so a single `camera_goto`/
|
|
536
|
+
* `camera_frame` can compose an aerial. `x/y/z` is the orbit target (pan-only when that is all that
|
|
537
|
+
* is set — the historical behavior); `distance`, `pitch` (degrees above the horizon), `yaw`
|
|
538
|
+
* (degrees), and `height` reposition the camera around that target when provided.
|
|
539
|
+
*/
|
|
540
|
+
export interface EditorFocusTarget {
|
|
541
|
+
x: number;
|
|
542
|
+
y: number;
|
|
543
|
+
z: number;
|
|
544
|
+
/** Radial distance from the target to the camera, world units. */
|
|
545
|
+
distance?: number;
|
|
546
|
+
/** Elevation above the horizon, degrees (90 = straight-down aerial). */
|
|
547
|
+
pitch?: number;
|
|
548
|
+
/** Azimuth around the target, degrees. */
|
|
549
|
+
yaw?: number;
|
|
550
|
+
/** Explicit camera height above the target; overrides the pitch-derived height. */
|
|
551
|
+
height?: number;
|
|
552
|
+
}
|
|
502
553
|
/** The live editor's global control surface — session, visibility, camera focus, assets, mode, RPC. */
|
|
503
554
|
export interface EditorHostApi {
|
|
504
555
|
gameId: string;
|
|
@@ -508,21 +559,9 @@ export interface EditorHostApi {
|
|
|
508
559
|
getVisibility(): EditorKindVisibility;
|
|
509
560
|
setVisibility(next: EditorKindVisibility): void;
|
|
510
561
|
subscribeVisibility(listener: () => void): () => void;
|
|
511
|
-
getFocusTarget():
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
z: number;
|
|
515
|
-
} | null;
|
|
516
|
-
setFocusTarget(target: {
|
|
517
|
-
x: number;
|
|
518
|
-
y: number;
|
|
519
|
-
z: number;
|
|
520
|
-
} | null): void;
|
|
521
|
-
subscribeFocus(listener: (target: {
|
|
522
|
-
x: number;
|
|
523
|
-
y: number;
|
|
524
|
-
z: number;
|
|
525
|
-
} | null) => void): () => void;
|
|
562
|
+
getFocusTarget(): EditorFocusTarget | null;
|
|
563
|
+
setFocusTarget(target: EditorFocusTarget | null): void;
|
|
564
|
+
subscribeFocus(listener: (target: EditorFocusTarget | null) => void): () => void;
|
|
526
565
|
getAssets(): readonly EditorAssetInfo[];
|
|
527
566
|
setAssets(assets: readonly EditorAssetInfo[]): void;
|
|
528
567
|
getCatalogDefinitions(): readonly EditorCatalogDefinition[];
|
|
@@ -26,11 +26,8 @@ export function AnimationPanel({ session, api, ui, assets, }) {
|
|
|
26
26
|
// Default to clips only when the scene has rigged assets but no scrubbable path yet.
|
|
27
27
|
const paths = useStoreSelector(session, (state) => state.document.paths);
|
|
28
28
|
const [mode, setMode] = useState(() => rigged.length > 0 && listScrubbablePaths(paths).length === 0 ? "clips" : "path");
|
|
29
|
-
// Tear down the viewport preview
|
|
30
|
-
|
|
31
|
-
if (mode !== "clips" && ui.getState().clipPreview !== null)
|
|
32
|
-
ui.patch({ clipPreview: null });
|
|
33
|
-
}, [mode, ui]);
|
|
29
|
+
// Tear down the viewport preview when the panel unmounts; leaving clip mode tears down in the
|
|
30
|
+
// tab click below.
|
|
34
31
|
useEffect(() => () => {
|
|
35
32
|
if (ui.getState().clipPreview !== null)
|
|
36
33
|
ui.patch({ clipPreview: null });
|
|
@@ -38,7 +35,11 @@ export function AnimationPanel({ session, api, ui, assets, }) {
|
|
|
38
35
|
return (_jsxs("div", { className: "flex min-h-0 flex-1 flex-col", children: [_jsx("div", { className: "flex h-8 shrink-0 items-center gap-1.5 border-b border-white/[0.06] px-2", children: _jsx("div", { className: "flex items-center gap-0.5", role: "tablist", "aria-label": "Animation mode", children: [
|
|
39
36
|
["path", "Path flythrough"],
|
|
40
37
|
["clips", "Clip preview"],
|
|
41
|
-
].map(([id, label]) => (_jsx("button", { type: "button", role: "tab", "aria-selected": mode === id, className: `rounded-[5px] px-2 py-0.5 text-[10px] uppercase tracking-wider transition-colors ${FOCUS_RING} ${mode === id ? "bg-cyan-500/15 text-cyan-200" : "text-neutral-500 hover:text-neutral-300"}`, onClick: () =>
|
|
38
|
+
].map(([id, label]) => (_jsx("button", { type: "button", role: "tab", "aria-selected": mode === id, className: `rounded-[5px] px-2 py-0.5 text-[10px] uppercase tracking-wider transition-colors ${FOCUS_RING} ${mode === id ? "bg-cyan-500/15 text-cyan-200" : "text-neutral-500 hover:text-neutral-300"}`, onClick: () => {
|
|
39
|
+
if (id !== "clips" && ui.getState().clipPreview !== null)
|
|
40
|
+
ui.patch({ clipPreview: null });
|
|
41
|
+
setMode(id);
|
|
42
|
+
}, children: label }, id))) }) }), mode === "path" ? (_jsx(PathFlythrough, { session: session, api: api })) : (_jsx(ClipPreview, { session: session, ui: ui, rigged: rigged }))] }));
|
|
42
43
|
}
|
|
43
44
|
/** Clip-preview mode: pick a rigged asset (placed instance or catalog) and play its clips in the viewport. */
|
|
44
45
|
function ClipPreview({ session, ui, rigged, }) {
|
|
@@ -125,22 +126,15 @@ function PathFlythrough({ session, api }) {
|
|
|
125
126
|
const [playing, setPlaying] = useState(false);
|
|
126
127
|
const [loop, setLoop] = useState(true);
|
|
127
128
|
const playRef = useRef({ playing: false, loop: true, progress: 0, pathId: null });
|
|
129
|
+
// selectedId is raw intent; a removed path falls back to the first scrubbable one at read time.
|
|
128
130
|
const selected = scrubbable.find((entry) => entry.id === selectedId) ?? scrubbable[0] ?? null;
|
|
129
131
|
const selectedPath = selected === null ? undefined : paths.find((path) => path.id === selected.id);
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
}
|
|
136
|
-
useEffect(() => {
|
|
137
|
-
playRef.current = {
|
|
138
|
-
playing,
|
|
139
|
-
loop,
|
|
140
|
-
progress,
|
|
141
|
-
pathId: selected?.id ?? null,
|
|
142
|
-
};
|
|
143
|
-
}, [playing, loop, progress, selected?.id]);
|
|
132
|
+
playRef.current = {
|
|
133
|
+
playing,
|
|
134
|
+
loop,
|
|
135
|
+
progress,
|
|
136
|
+
pathId: selected?.id ?? null,
|
|
137
|
+
};
|
|
144
138
|
const applyProgress = useCallback((next, pathId) => {
|
|
145
139
|
const path = session.getState().document.paths.find((entry) => entry.id === pathId);
|
|
146
140
|
if (path === undefined)
|
|
@@ -13,13 +13,9 @@ export function CommandPalette({ commands, onClose, initialQuery = "", }) {
|
|
|
13
13
|
const [query, setQuery] = useState(initialQuery);
|
|
14
14
|
const [cursor, setCursor] = useState(0);
|
|
15
15
|
const [recentIds, setRecentIds] = useState(() => loadRecentCommandIds());
|
|
16
|
-
const inputRef = useRef(null);
|
|
17
16
|
const listRef = useRef(null);
|
|
18
17
|
const filtered = useMemo(() => filterPaletteCommands(commands, query, recentIds).slice(0, 40), [commands, query, recentIds]);
|
|
19
18
|
const clampedCursor = Math.min(cursor, Math.max(0, filtered.length - 1));
|
|
20
|
-
useEffect(() => {
|
|
21
|
-
inputRef.current?.focus();
|
|
22
|
-
}, []);
|
|
23
19
|
useEffect(() => {
|
|
24
20
|
const row = listRef.current?.children[clampedCursor];
|
|
25
21
|
if (row instanceof HTMLElement)
|
|
@@ -32,7 +28,7 @@ export function CommandPalette({ commands, onClose, initialQuery = "", }) {
|
|
|
32
28
|
onClose();
|
|
33
29
|
command.run();
|
|
34
30
|
};
|
|
35
|
-
return (_jsx("div", { className: "pointer-events-auto absolute inset-0 z-[70] flex items-start justify-center bg-black/50 pt-[12vh] backdrop-blur-[2px]", onClick: onClose, children: _jsxs("div", { role: "dialog", "aria-label": "Command palette", className: "w-[520px] max-w-[92vw] overflow-hidden rounded-[8px] border border-white/10 bg-[#14171d] shadow-2xl shadow-black/60", onClick: (event) => event.stopPropagation(), children: [_jsxs("div", { className: "flex items-center gap-2 border-b border-white/[0.07] px-3", children: [_jsx(Icon, { name: "search", size: 14, className: "shrink-0 text-neutral-500" }), _jsx("input", {
|
|
31
|
+
return (_jsx("div", { className: "pointer-events-auto absolute inset-0 z-[70] flex items-start justify-center bg-black/50 pt-[12vh] backdrop-blur-[2px]", onClick: onClose, children: _jsxs("div", { role: "dialog", "aria-label": "Command palette", className: "w-[520px] max-w-[92vw] overflow-hidden rounded-[8px] border border-white/10 bg-[#14171d] shadow-2xl shadow-black/60", onClick: (event) => event.stopPropagation(), children: [_jsxs("div", { className: "flex items-center gap-2 border-b border-white/[0.07] px-3", children: [_jsx(Icon, { name: "search", size: 14, className: "shrink-0 text-neutral-500" }), _jsx("input", { autoFocus: true, value: query, onChange: (event) => {
|
|
36
32
|
setQuery(event.target.value);
|
|
37
33
|
setCursor(0);
|
|
38
34
|
}, onKeyDown: (event) => {
|
|
@@ -44,10 +44,8 @@ export const HierarchyPanel = memo(function HierarchyPanel({ session, api, onAdd
|
|
|
44
44
|
const [query, setQuery] = useState("");
|
|
45
45
|
const [collapsedKinds, setCollapsedKinds] = useState({});
|
|
46
46
|
const [collapsedNodes, setCollapsedNodes] = useState({});
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
void visibilityTick;
|
|
50
|
-
const visibility = api.getVisibility();
|
|
47
|
+
const visibilityStore = useMemo(() => ({ getState: api.getVisibility, subscribe: api.subscribeVisibility }), [api]);
|
|
48
|
+
const visibility = useStoreSelector(visibilityStore, (v) => v);
|
|
51
49
|
const rows = useMemo(() => flattenOutliner(document, { view, query, collapsedKinds, collapsedNodes }), [document, view, query, collapsedKinds, collapsedNodes]);
|
|
52
50
|
const total = document.markers.length + document.volumes.length + document.paths.length + document.annotations.length;
|
|
53
51
|
const scrollRef = useRef(null);
|
|
@@ -79,15 +77,8 @@ export const HierarchyPanel = memo(function HierarchyPanel({ session, api, onAdd
|
|
|
79
77
|
const [activeId, setActiveId] = useState(null);
|
|
80
78
|
const [renamingId, setRenamingId] = useState(null);
|
|
81
79
|
const [renameDraft, setRenameDraft] = useState("");
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
if (activeId !== null && !navigableIds.includes(activeId))
|
|
85
|
-
setActiveId(navigableIds[0] ?? null);
|
|
86
|
-
}, [activeId, navigableIds]);
|
|
87
|
-
useEffect(() => {
|
|
88
|
-
if (renamingId !== null)
|
|
89
|
-
renameInputRef.current?.focus();
|
|
90
|
-
}, [renamingId]);
|
|
80
|
+
// Raw intent clamped at read time: a filtered-away active row falls back to the first row.
|
|
81
|
+
const effectiveActiveId = activeId === null ? null : navigableIds.includes(activeId) ? activeId : (navigableIds[0] ?? null);
|
|
91
82
|
const focusTree = () => {
|
|
92
83
|
scrollRef.current?.focus({ preventScroll: true });
|
|
93
84
|
};
|
|
@@ -129,7 +120,7 @@ export const HierarchyPanel = memo(function HierarchyPanel({ session, api, onAdd
|
|
|
129
120
|
const onListFocus = () => {
|
|
130
121
|
if (renamingId !== null)
|
|
131
122
|
return;
|
|
132
|
-
if (
|
|
123
|
+
if (effectiveActiveId !== null)
|
|
133
124
|
return;
|
|
134
125
|
const seed = selectedId !== undefined && navigableIds.includes(selectedId)
|
|
135
126
|
? selectedId
|
|
@@ -142,7 +133,7 @@ export const HierarchyPanel = memo(function HierarchyPanel({ session, api, onAdd
|
|
|
142
133
|
return;
|
|
143
134
|
if (navigableIds.length === 0)
|
|
144
135
|
return;
|
|
145
|
-
const currentIndex =
|
|
136
|
+
const currentIndex = effectiveActiveId === null ? -1 : navigableIds.indexOf(effectiveActiveId);
|
|
146
137
|
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
147
138
|
event.preventDefault();
|
|
148
139
|
const delta = event.key === "ArrowDown" ? 1 : -1;
|
|
@@ -164,17 +155,17 @@ export const HierarchyPanel = memo(function HierarchyPanel({ session, api, onAdd
|
|
|
164
155
|
activateId(navigableIds[navigableIds.length - 1], event.shiftKey);
|
|
165
156
|
return;
|
|
166
157
|
}
|
|
167
|
-
if (event.key === "Enter" &&
|
|
158
|
+
if (event.key === "Enter" && effectiveActiveId !== null) {
|
|
168
159
|
event.preventDefault();
|
|
169
|
-
api.handle({ method: "camera_goto", id:
|
|
160
|
+
api.handle({ method: "camera_goto", id: effectiveActiveId });
|
|
170
161
|
return;
|
|
171
162
|
}
|
|
172
|
-
if (event.key === "F2" &&
|
|
163
|
+
if (event.key === "F2" && effectiveActiveId !== null) {
|
|
173
164
|
event.preventDefault();
|
|
174
|
-
const row = rows.find((entry) => (entry.type === "kindItem" && entry.ids[0] ===
|
|
175
|
-
(entry.type === "treeItem" && entry.id ===
|
|
165
|
+
const row = rows.find((entry) => (entry.type === "kindItem" && entry.ids[0] === effectiveActiveId) ||
|
|
166
|
+
(entry.type === "treeItem" && entry.id === effectiveActiveId));
|
|
176
167
|
if (row !== undefined && (row.type === "kindItem" || row.type === "treeItem")) {
|
|
177
|
-
beginRename(
|
|
168
|
+
beginRename(effectiveActiveId, row.label);
|
|
178
169
|
}
|
|
179
170
|
}
|
|
180
171
|
};
|
|
@@ -245,7 +236,7 @@ export const HierarchyPanel = memo(function HierarchyPanel({ session, api, onAdd
|
|
|
245
236
|
return;
|
|
246
237
|
session.dispatch({ type: "setObjectFlags", ids: [...ids], patch: { locked: true } });
|
|
247
238
|
};
|
|
248
|
-
const activeDescendant = hierarchyActiveDescendantId(
|
|
239
|
+
const activeDescendant = hierarchyActiveDescendantId(effectiveActiveId, navigableIds);
|
|
249
240
|
return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "space-y-1.5 border-b border-white/[0.06] p-2", children: [_jsxs("div", { className: "flex items-center gap-1.5", children: [_jsxs("div", { className: "relative min-w-0 flex-1", children: [_jsx(Icon, { name: "search", size: 12, className: "pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 text-neutral-600" }), _jsx("input", { type: "search", value: query, onChange: (event) => setQuery(event.target.value), placeholder: "Search\u2026", "aria-label": "Search scene objects", className: `h-7 w-full pl-7 pr-2 ${INPUT_CLS}` })] }), _jsx(IconButton, { icon: "plus", label: "Add object\u2026", onClick: onAdd })] }), _jsxs("div", { className: "flex items-center gap-1.5", children: [_jsx(Segmented, { ariaLabel: "Outliner grouping", value: view, onChange: setView, options: [
|
|
250
241
|
{ value: "kind", label: "By kind", icon: "layers" },
|
|
251
242
|
{ value: "tree", label: "Tree", icon: "folder" },
|
|
@@ -273,7 +264,7 @@ export const HierarchyPanel = memo(function HierarchyPanel({ session, api, onAdd
|
|
|
273
264
|
const hidden = rowHidden(row.ids);
|
|
274
265
|
const collectionOnlyLock = locked &&
|
|
275
266
|
row.ids.every((id) => isEditorObjectCollectionLocked(document, id) && !isEditorObjectSelfLocked(document, id));
|
|
276
|
-
const active =
|
|
267
|
+
const active = effectiveActiveId === primaryId;
|
|
277
268
|
const renaming = renamingId === primaryId && row.ids.length === 1;
|
|
278
269
|
return (_jsxs("div", { id: hierarchyRowDomId(primaryId), role: "treeitem", "aria-selected": rowSelected, "aria-level": 2, style: { height: ROW_HEIGHT }, className: `group flex w-full items-center gap-0.5 rounded-[5px] pl-6 pr-0.5 text-[11px] transition-colors ${dropActive
|
|
279
270
|
? "bg-cyan-500/25 ring-1 ring-inset ring-cyan-300/50"
|
|
@@ -294,7 +285,7 @@ export const HierarchyPanel = memo(function HierarchyPanel({ session, api, onAdd
|
|
|
294
285
|
event.preventDefault();
|
|
295
286
|
setActiveId(primaryId);
|
|
296
287
|
onRowContextMenu?.({ clientX: event.clientX, clientY: event.clientY }, primaryId);
|
|
297
|
-
}, children: [_jsx(Icon, { name: kindIcon(row.kind), size: 13, className: `shrink-0 ${rowSelected || active ? "text-cyan-300" : "text-neutral-500"}` }), renaming ? (_jsx("input", {
|
|
288
|
+
}, children: [_jsx(Icon, { name: kindIcon(row.kind), size: 13, className: `shrink-0 ${rowSelected || active ? "text-cyan-300" : "text-neutral-500"}` }), renaming ? (_jsx("input", { autoFocus: true, value: renameDraft, onChange: (event) => setRenameDraft(event.target.value), onBlur: commitRename, onKeyDown: (event) => {
|
|
298
289
|
event.stopPropagation();
|
|
299
290
|
if (event.key === "Enter") {
|
|
300
291
|
event.preventDefault();
|
|
@@ -331,7 +322,7 @@ export const HierarchyPanel = memo(function HierarchyPanel({ session, api, onAdd
|
|
|
331
322
|
const hidden = isEditorObjectHidden(document, row.id);
|
|
332
323
|
const collectionOnlyLock = isEditorObjectCollectionLocked(document, row.id) && !isEditorObjectSelfLocked(document, row.id);
|
|
333
324
|
const dragging = draggingObjectId === row.id;
|
|
334
|
-
const active =
|
|
325
|
+
const active = effectiveActiveId === row.id;
|
|
335
326
|
const renaming = renamingId === row.id;
|
|
336
327
|
const expanded = row.hasChildren ? collapsedNodes[row.id] !== true : undefined;
|
|
337
328
|
return (_jsxs("div", { id: hierarchyRowDomId(row.id), role: "treeitem", "aria-selected": rowSelected, "aria-level": row.depth + 1, "aria-expanded": expanded, style: { height: ROW_HEIGHT, paddingLeft: `${row.depth * 12}px` }, className: `group flex items-center ${hidden ? "opacity-55" : ""}`, children: [row.hasChildren ? (_jsx("button", { type: "button", tabIndex: -1, "aria-label": collapsedNodes[row.id] === true ? "Expand children" : "Collapse children", className: `flex h-4 w-4 shrink-0 items-center justify-center text-neutral-500 transition-colors hover:text-neutral-200 ${FOCUS_RING}`, onMouseDown: preventFocusSteal, onClick: () => setCollapsedNodes((previous) => ({ ...previous, [row.id]: !(previous[row.id] === true) })), children: _jsx(Icon, { name: collapsedNodes[row.id] === true ? "chevronRight" : "chevronDown", size: 10 }) })) : (_jsx("span", { className: "w-4 shrink-0", "aria-hidden": "true" })), _jsxs("button", { type: "button", tabIndex: -1, draggable: true, className: `flex h-full min-w-0 flex-1 items-center gap-1.5 truncate rounded-[5px] px-1.5 text-left text-[11px] transition-colors ${FOCUS_RING} ${materialDrop || reparentDrop
|
|
@@ -364,7 +355,7 @@ export const HierarchyPanel = memo(function HierarchyPanel({ session, api, onAdd
|
|
|
364
355
|
if (commitMaterialDrop(event, [row.id]))
|
|
365
356
|
return;
|
|
366
357
|
commitReparentDrop(event, row.id);
|
|
367
|
-
}, children: [_jsx(Icon, { name: kindIcon(row.kind), size: 13, className: `shrink-0 ${rowSelected || active ? "text-cyan-300" : "text-neutral-500"}` }), renaming ? (_jsx("input", {
|
|
358
|
+
}, children: [_jsx(Icon, { name: kindIcon(row.kind), size: 13, className: `shrink-0 ${rowSelected || active ? "text-cyan-300" : "text-neutral-500"}` }), renaming ? (_jsx("input", { autoFocus: true, value: renameDraft, onChange: (event) => setRenameDraft(event.target.value), onBlur: commitRename, onKeyDown: (event) => {
|
|
368
359
|
event.stopPropagation();
|
|
369
360
|
if (event.key === "Enter") {
|
|
370
361
|
event.preventDefault();
|
|
@@ -1,4 +1,11 @@
|
|
|
1
|
+
import type { RuntimePlayControl } from "@jgengine/core/editor/index";
|
|
1
2
|
import type { EditorHostApi } from "../session.js";
|
|
3
|
+
/**
|
|
4
|
+
* Subscribe to the host runtime play-control (pause / pending steps) as an external store.
|
|
5
|
+
* Shared by the play-mode chrome pieces so none of them hand-rolls a `setState` mirror.
|
|
6
|
+
* @internal
|
|
7
|
+
*/
|
|
8
|
+
export declare function usePlayControl(api: EditorHostApi): RuntimePlayControl;
|
|
2
9
|
/**
|
|
3
10
|
* Slim shell chrome for Play mode — same app-bar language as edit mode so switching modes
|
|
4
11
|
* does not feel like an app swap. Pause / Resume / Step drive the existing runtime play
|
|
@@ -1,8 +1,18 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import {
|
|
2
|
+
import { useMemo } from "react";
|
|
3
|
+
import { useStoreSelector } from "../useStoreSelector.js";
|
|
3
4
|
import { Icon } from "./icons.js";
|
|
4
5
|
import { BORDER, FOCUS_RING } from "./theme.js";
|
|
5
6
|
import { IconButton, ToolbarDivider } from "./ui.js";
|
|
7
|
+
/**
|
|
8
|
+
* Subscribe to the host runtime play-control (pause / pending steps) as an external store.
|
|
9
|
+
* Shared by the play-mode chrome pieces so none of them hand-rolls a `setState` mirror.
|
|
10
|
+
* @internal
|
|
11
|
+
*/
|
|
12
|
+
export function usePlayControl(api) {
|
|
13
|
+
const store = useMemo(() => ({ getState: api.getPlayControl, subscribe: api.subscribePlayControl }), [api]);
|
|
14
|
+
return useStoreSelector(store, (play) => play);
|
|
15
|
+
}
|
|
6
16
|
/**
|
|
7
17
|
* Slim shell chrome for Play mode — same app-bar language as edit mode so switching modes
|
|
8
18
|
* does not feel like an app swap. Pause / Resume / Step drive the existing runtime play
|
|
@@ -10,8 +20,7 @@ import { IconButton, ToolbarDivider } from "./ui.js";
|
|
|
10
20
|
* @internal
|
|
11
21
|
*/
|
|
12
22
|
export function PlayModeBar({ gameId, api, onExit, }) {
|
|
13
|
-
const
|
|
14
|
-
useEffect(() => api.subscribePlayControl(setPlay), [api]);
|
|
23
|
+
const play = usePlayControl(api);
|
|
15
24
|
const pause = () => {
|
|
16
25
|
api.handle({ method: "runtime_pause" });
|
|
17
26
|
};
|
|
@@ -112,11 +112,15 @@ export function SceneToolbar({ tool, gizmoMode, gizmoSpace, gizmoPivot, snapMode
|
|
|
112
112
|
? "border-cyan-400/30 bg-cyan-500/15 text-cyan-100"
|
|
113
113
|
: "border-white/[0.07] bg-[#191d24] text-neutral-300 hover:bg-[#1f242d]"}`, children: [_jsx(Icon, { name: "camera", size: 12 }), cameraProjection === "perspective" ? "Persp" : "Ortho"] }), _jsx(ToolbarDivider, {}), _jsxs("div", { className: "relative", children: [_jsxs("button", { type: "button", onClick: () => setAddOpen((value) => !value), "aria-expanded": addOpen, className: `flex h-7 items-center gap-1.5 rounded-[5px] px-2.5 text-[12px] font-medium transition-colors ${FOCUS_RING} ${addOpen || placementActive
|
|
114
114
|
? "bg-cyan-600 text-white shadow-sm shadow-cyan-950/40"
|
|
115
|
-
: "border border-white/[0.07] bg-[#191d24] text-neutral-200 hover:bg-[#1f242d]"}`, children: [_jsx(Icon, { name: "plus", size: 12 }), "Add"] }), _jsxs(MenuShell, { open: addOpen, onClose: () => setAddOpen(false), children: [_jsx("div", { className: `px-2 pb-1 pt-1.5 ${MICRO_LABEL}`, children: "Markers" }), WELL_KNOWN_MARKER_KINDS.map((kind) => (_jsx("button", { type: "button", className: MENU_ITEM, onClick: () => pick({ tool: "marker", kind }), children: kind }, kind))), _jsx("div", { className: `px-2 pb-1 pt-2 ${MICRO_LABEL}`, children: "Volumes" }), ADD_VOLUME_ENTRIES.map((entry) => (_jsx("button", { type: "button", className: MENU_ITEM, onClick: () => pick(entry.tool), children: entry.label }, entry.label))),
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
115
|
+
: "border border-white/[0.07] bg-[#191d24] text-neutral-200 hover:bg-[#1f242d]"}`, children: [_jsx(Icon, { name: "plus", size: 12 }), "Add"] }), _jsxs(MenuShell, { open: addOpen, onClose: () => setAddOpen(false), children: [_jsx("div", { className: `px-2 pb-1 pt-1.5 ${MICRO_LABEL}`, children: "Markers" }), WELL_KNOWN_MARKER_KINDS.map((kind) => (_jsx("button", { type: "button", className: MENU_ITEM, onClick: () => pick({ tool: "marker", kind }), children: kind }, kind))), _jsx("div", { className: `px-2 pb-1 pt-2 ${MICRO_LABEL}`, children: "Volumes" }), ADD_VOLUME_ENTRIES.map((entry) => (_jsx("button", { type: "button", className: MENU_ITEM, onClick: () => pick(entry.tool), children: entry.label }, entry.label))), [...studioKinds.reduce((groups, definition) => {
|
|
116
|
+
const category = definition.addCategory ?? "Studios";
|
|
117
|
+
(groups.get(category) ?? groups.set(category, []).get(category)).push(definition);
|
|
118
|
+
return groups;
|
|
119
|
+
}, new Map())].map(([category, defs]) => (_jsxs("div", { children: [_jsx("div", { className: `px-2 pb-1 pt-2 ${MICRO_LABEL}`, children: category }), defs.map((definition) => (_jsxs("button", { type: "button", className: `${MENU_ITEM}`, style: { color: definition.accent ?? "#34d399" }, onClick: () => pick(definition.target === "path"
|
|
120
|
+
? { tool: "path", kind: definition.kind }
|
|
121
|
+
: definition.target === "volume"
|
|
122
|
+
? { tool: "volume", kind: definition.kind, shape: "box" }
|
|
123
|
+
: { tool: "marker", kind: definition.kind }), children: [definition.label, definition.target === "path" ? (definition.pathShape === "line" ? " (draw line)" : " (lasso)") : ""] }, definition.kind)))] }, category))), _jsx("div", { className: `px-2 pb-1 pt-2 ${MICRO_LABEL}`, children: "Paths & notes" }), _jsx("button", { type: "button", className: MENU_ITEM, onClick: () => pick({ tool: "path", kind: "route" }), children: "Draw path (route)" }), _jsx("button", { type: "button", className: MENU_ITEM, onClick: () => pick({ tool: "path", kind: "road" }), children: "Draw road" }), _jsx("button", { type: "button", className: MENU_ITEM, onClick: () => pick({ tool: "note" }), children: "Note" })] })] }), _jsx("div", { className: "ml-auto flex items-center gap-1", children: _jsxs("button", { type: "button", onClick: onOpenAssistant, className: `flex h-7 items-center gap-1.5 rounded-[5px] px-2.5 text-[12px] transition-colors ${FOCUS_RING} ${assistantOpen
|
|
120
124
|
? "border border-violet-400/40 bg-violet-500/20 text-violet-100"
|
|
121
125
|
: "border border-white/[0.07] bg-[#191d24] text-neutral-300 hover:bg-[#1f242d] hover:text-neutral-100"}`, title: "AI assistant \u2014 drives the scene through the same RPC and undo as the toolbar", children: [_jsx(Icon, { name: "sparkle", size: 13 }), "AI"] }) })] }));
|
|
122
126
|
}
|
package/dist/shell/fields.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import {
|
|
2
|
+
import { useRef, useState } from "react";
|
|
3
3
|
import { AXIS_COLORS, FOCUS_RING, INPUT_CLS, NUMERIC } from "./theme.js";
|
|
4
4
|
/** Pixels of pointer drag per `step` of value change when scrubbing an axis chip. */
|
|
5
5
|
const DRAG_PX_PER_STEP = 4;
|
|
@@ -15,14 +15,12 @@ function formatValue(value, precision) {
|
|
|
15
15
|
* values on change and re-normalizes its text on blur. Invalid text never commits.
|
|
16
16
|
*/
|
|
17
17
|
export function AxisNumberField({ axis, label, value, onCommit, step = 0.1, precision = 3, disabled = false, mixed = false, }) {
|
|
18
|
-
|
|
18
|
+
// `text` only exists while the input has focus; otherwise the shown value derives from props.
|
|
19
|
+
const [text, setText] = useState("");
|
|
19
20
|
const [editing, setEditing] = useState(false);
|
|
20
21
|
const inputRef = useRef(null);
|
|
21
22
|
const dragRef = useRef(null);
|
|
22
|
-
|
|
23
|
-
if (!editing)
|
|
24
|
-
setText(mixed ? "—" : formatValue(value, precision));
|
|
25
|
-
}, [value, precision, editing, mixed]);
|
|
23
|
+
const formatted = mixed ? "—" : formatValue(value, precision);
|
|
26
24
|
const commitText = (raw) => {
|
|
27
25
|
if (raw === "—" || raw.trim().length === 0)
|
|
28
26
|
return;
|
|
@@ -69,7 +67,10 @@ export function AxisNumberField({ axis, label, value, onCommit, step = 0.1, prec
|
|
|
69
67
|
}
|
|
70
68
|
};
|
|
71
69
|
const colors = axis !== undefined ? AXIS_COLORS[axis] : null;
|
|
72
|
-
return (_jsxs("label", { title: `Drag to adjust ${label} · Alt-click or focus input to type`, onPointerDown: onScrubPointerDown, onPointerMove: onScrubPointerMove, onPointerUp: onScrubPointerUp, className: `flex min-w-0 flex-1 cursor-ew-resize items-center overflow-hidden rounded-[5px] border border-white/[0.08] bg-black/30 focus-within:border-cyan-400/50 ${editing ? "cursor-text" : ""}`, children: [_jsxs("span", { className: `relative flex h-6.5 w-6 shrink-0 select-none items-center justify-center border-r border-white/[0.06] bg-white/[0.03] text-[10px] font-semibold uppercase ${colors !== null ? colors.text : "text-neutral-500"}`, children: [colors !== null ? _jsx("span", { className: `absolute inset-y-0 left-0 w-0.5 ${colors.bar}` }) : null, label] }), _jsx("input", { ref: inputRef, type: "text", inputMode: "decimal", value: text, disabled: disabled, "aria-label": label, onFocus: () =>
|
|
70
|
+
return (_jsxs("label", { title: `Drag to adjust ${label} · Alt-click or focus input to type`, onPointerDown: onScrubPointerDown, onPointerMove: onScrubPointerMove, onPointerUp: onScrubPointerUp, className: `flex min-w-0 flex-1 cursor-ew-resize items-center overflow-hidden rounded-[5px] border border-white/[0.08] bg-black/30 focus-within:border-cyan-400/50 ${editing ? "cursor-text" : ""}`, children: [_jsxs("span", { className: `relative flex h-6.5 w-6 shrink-0 select-none items-center justify-center border-r border-white/[0.06] bg-white/[0.03] text-[10px] font-semibold uppercase ${colors !== null ? colors.text : "text-neutral-500"}`, children: [colors !== null ? _jsx("span", { className: `absolute inset-y-0 left-0 w-0.5 ${colors.bar}` }) : null, label] }), _jsx("input", { ref: inputRef, type: "text", inputMode: "decimal", value: editing ? text : formatted, disabled: disabled, "aria-label": label, onFocus: () => {
|
|
71
|
+
setText(formatted);
|
|
72
|
+
setEditing(true);
|
|
73
|
+
}, onDoubleClick: (event) => {
|
|
73
74
|
event.stopPropagation();
|
|
74
75
|
event.currentTarget.select();
|
|
75
76
|
}, onChange: (event) => {
|
|
@@ -78,7 +79,6 @@ export function AxisNumberField({ axis, label, value, onCommit, step = 0.1, prec
|
|
|
78
79
|
}, onBlur: (event) => {
|
|
79
80
|
setEditing(false);
|
|
80
81
|
commitText(event.target.value);
|
|
81
|
-
setText(formatValue(Number.isFinite(Number(event.target.value)) ? Number(event.target.value) : value, precision));
|
|
82
82
|
}, onKeyDown: (event) => {
|
|
83
83
|
if (event.key === "Enter")
|
|
84
84
|
event.currentTarget.blur();
|