@scenoco-three/editor 0.4.0 → 0.5.4
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/LICENSE +21 -0
- package/README.md +23 -17
- package/app/cameraRig.ts +73 -12
- package/app/commands.ts +5 -1
- package/app/desktopControls.ts +13 -10
- package/app/document.ts +474 -427
- package/app/editLog.ts +127 -0
- package/app/editor.css +51 -0
- package/app/gizmo.ts +100 -6
- package/app/globals.d.ts +9 -0
- package/app/index.html +6 -122
- package/app/main.ts +491 -304
- package/app/meta.ts +24 -0
- package/app/picking.ts +47 -0
- package/app/session.ts +513 -308
- package/app/touchControls.ts +85 -62
- package/app/ui-react/AddMemberPopup.tsx +114 -0
- package/app/ui-react/BottomLauncher.tsx +15 -0
- package/app/ui-react/EditorUI.tsx +22 -0
- package/app/ui-react/FilePicker.tsx +80 -0
- package/app/ui-react/HeadBtn.tsx +21 -0
- package/app/ui-react/InspectorSheet.tsx +475 -0
- package/app/ui-react/NodeListSheet.tsx +56 -0
- package/app/ui-react/TopBar.tsx +137 -0
- package/app/ui-react/Tree.tsx +106 -0
- package/app/ui-react/cx.ts +4 -0
- package/app/ui-react/fields.tsx +407 -0
- package/app/ui-react/format.ts +30 -0
- package/app/ui-react/mount.tsx +31 -0
- package/app/ui-react/store.ts +80 -0
- package/app/ui-react/types.ts +88 -0
- package/app/ui-react/ui.module.css +250 -0
- package/app/ui-react/useSize.ts +20 -0
- package/app/values.ts +137 -0
- package/dist/cli/index.js +93 -70
- package/dist/index.d.ts +10 -3
- package/dist/index.js +9 -5
- package/dist/mcp.d.ts +28 -12
- package/dist/mcp.js +172 -131
- package/dist/protocol.d.ts +74 -65
- package/dist/protocol.js +17 -4
- package/dist/server.d.ts +106 -39
- package/dist/server.js +389 -361
- package/package.json +24 -9
- package/app/filePicker.ts +0 -64
- package/app/panels.ts +0 -553
- package/app/toolbar.ts +0 -131
- package/app/wsClient.ts +0 -92
- package/dist/args.d.ts +0 -8
- package/dist/args.js +0 -28
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { Object3D, Scene } from 'three';
|
|
2
|
+
import type { Engine } from '@scenoco-three/core';
|
|
3
|
+
import type { ProjectModel } from '@scenoco-three/compiler';
|
|
4
|
+
import type { SceneDocument } from '../document.js';
|
|
5
|
+
import type { GizmoMode, GizmoSpace } from '../gizmo.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The transport/gizmo/edit actions the top bar drives, plus a `state()` snapshot
|
|
9
|
+
* it polls to style itself. Structurally identical to what `main.ts` already
|
|
10
|
+
* builds — moved here from the old `toolbar.ts` so the React chrome owns the type.
|
|
11
|
+
*/
|
|
12
|
+
export interface ToolbarHandlers {
|
|
13
|
+
// transport
|
|
14
|
+
play(): void;
|
|
15
|
+
pause(): void;
|
|
16
|
+
step(): void;
|
|
17
|
+
stop(): void;
|
|
18
|
+
// gizmo + edit
|
|
19
|
+
getMode(): GizmoMode;
|
|
20
|
+
setMode(mode: GizmoMode): void;
|
|
21
|
+
/** Handles along the world's axes, or the object's own — Unity's Global/Local. */
|
|
22
|
+
getSpace(): GizmoSpace;
|
|
23
|
+
setSpace(space: GizmoSpace): void;
|
|
24
|
+
/** Whether the editor camera is showing the scene's screen-space UI. */
|
|
25
|
+
getShowUi(): boolean;
|
|
26
|
+
setShowUi(on: boolean): void;
|
|
27
|
+
undo(): void;
|
|
28
|
+
redo(): void;
|
|
29
|
+
save(): void;
|
|
30
|
+
/** Current state, re-read on each refresh. */
|
|
31
|
+
state(): {
|
|
32
|
+
mode: 'edit' | 'play';
|
|
33
|
+
playing: boolean;
|
|
34
|
+
canUndo: boolean;
|
|
35
|
+
canRedo: boolean;
|
|
36
|
+
dirty: boolean;
|
|
37
|
+
saving: boolean;
|
|
38
|
+
/** True when something the document owns is selected — the gizmo has a target. */
|
|
39
|
+
enabled: boolean;
|
|
40
|
+
/** A short-lived message — a save, or why one was refused. */
|
|
41
|
+
status?: string;
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Everything the React chrome needs from the host page (`main.ts`): live getters
|
|
47
|
+
* for the engine/document, the toolbar handlers, a selection callback (viewport
|
|
48
|
+
* highlight + framing), and a "focus the current selection" action.
|
|
49
|
+
*/
|
|
50
|
+
export interface EditorDeps {
|
|
51
|
+
getEngine: () => Engine;
|
|
52
|
+
getDoc: () => SceneDocument | undefined;
|
|
53
|
+
/** What the project's TypeScript declares — the inspector's and the picker's schema. */
|
|
54
|
+
getModel: () => ProjectModel | undefined;
|
|
55
|
+
/** The live scene, which the editor owns rather than `Engine` (it drives its own loop). */
|
|
56
|
+
getScene: () => Scene | null;
|
|
57
|
+
handlers: ToolbarHandlers;
|
|
58
|
+
/** Called whenever selection changes (from the tree or the viewport) — host highlights/frames it. */
|
|
59
|
+
onSelect: (node: Object3D | null) => void;
|
|
60
|
+
/** Reframe the camera on a node (the node-list "focus" action). */
|
|
61
|
+
focusSelected: (node: Object3D) => void;
|
|
62
|
+
/** Reparent a node under a new parent (tree drag) — records an undoable move. */
|
|
63
|
+
reparent: (node: Object3D, newParent: Object3D, index?: number) => void;
|
|
64
|
+
/** Copy a node's subtree to the clipboard. */
|
|
65
|
+
copy: (node: Object3D) => void;
|
|
66
|
+
/** Cut (copy + delete) a node's subtree. */
|
|
67
|
+
cut: (node: Object3D) => void;
|
|
68
|
+
/** Paste the clipboard subtree under `parent` (the scene root when null). */
|
|
69
|
+
paste: (parent: Object3D | null) => void;
|
|
70
|
+
/** Delete a node's subtree (undoable). */
|
|
71
|
+
remove: (node: Object3D) => void;
|
|
72
|
+
/** Rename (or set) a node's scene id. */
|
|
73
|
+
setId: (node: Object3D, id: string) => void;
|
|
74
|
+
/** Whether the clipboard holds a subtree (for Paste's enabled state). */
|
|
75
|
+
hasClipboard: () => boolean;
|
|
76
|
+
/** Every document in the project — what the open dialog lists. */
|
|
77
|
+
listDocuments: () => Promise<Array<{ kind: string; path: string; stem: string }>>;
|
|
78
|
+
/** Open one, by project-relative path. Rejects with why, which the dialog shows. */
|
|
79
|
+
openDocument: (path: string) => Promise<void>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The tiny imperative surface `main.ts` keeps a handle to (mirrors the old panels/toolbar). */
|
|
83
|
+
export interface EditorUIHandle {
|
|
84
|
+
/** Re-read the live engine/document into the tree, inspector, and top bar. */
|
|
85
|
+
refresh(): void;
|
|
86
|
+
/** Select a node from outside (a viewport pick) — syncs tree + inspector. */
|
|
87
|
+
select(node: Object3D | null): void;
|
|
88
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/* Editor chrome styles (CSS Module — class names are scoped/hashed, referenced as
|
|
2
|
+
`styles.x` from the TSX). Shares the dark theme vars from index.html. */
|
|
3
|
+
|
|
4
|
+
/* ── top bar ─────────────────────────────────────────────────────────────── */
|
|
5
|
+
.topbar {
|
|
6
|
+
position: fixed; top: 8px; left: 8px; right: 8px; margin: 0 auto;
|
|
7
|
+
width: max-content; max-width: calc(100% - 16px);
|
|
8
|
+
display: flex; align-items: center; gap: 2px;
|
|
9
|
+
padding: 5px; border-radius: 12px;
|
|
10
|
+
background: var(--panel); border: 1px solid var(--edge);
|
|
11
|
+
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.45);
|
|
12
|
+
backdrop-filter: blur(6px); pointer-events: auto;
|
|
13
|
+
overflow-x: auto; overflow-y: hidden; scrollbar-width: thin;
|
|
14
|
+
-webkit-overflow-scrolling: touch;
|
|
15
|
+
}
|
|
16
|
+
.topbar::-webkit-scrollbar { height: 0; }
|
|
17
|
+
|
|
18
|
+
.tbtn {
|
|
19
|
+
position: relative; flex: 0 0 auto;
|
|
20
|
+
width: 40px; height: 40px; display: flex; align-items: center; justify-content: center;
|
|
21
|
+
padding: 0; border: 1px solid transparent; border-radius: 9px;
|
|
22
|
+
background: transparent; color: #cfe0f2; cursor: pointer;
|
|
23
|
+
}
|
|
24
|
+
.tbtn svg { width: 21px; height: 21px; display: block; }
|
|
25
|
+
.tbtn:hover:not(:disabled) { background: rgba(74, 240, 224, 0.12); color: #eafdff; }
|
|
26
|
+
.tbtn.active { background: rgba(74, 240, 224, 0.2); border-color: var(--accent); color: #eafdff; }
|
|
27
|
+
.tbtn:disabled { opacity: 0.3; cursor: default; }
|
|
28
|
+
.tbtn.save.dirty { color: var(--accent); }
|
|
29
|
+
.tbtn.save.dirty::after {
|
|
30
|
+
content: ''; position: absolute; top: 7px; right: 7px;
|
|
31
|
+
width: 7px; height: 7px; border-radius: 50%; background: var(--accent);
|
|
32
|
+
}
|
|
33
|
+
.tbtn.save.saving { opacity: 0.5; }
|
|
34
|
+
.sep { flex: 0 0 auto; width: 1px; height: 24px; background: var(--line); margin: 0 3px; }
|
|
35
|
+
|
|
36
|
+
/* ── bottom launcher ─────────────────────────────────────────────────────── */
|
|
37
|
+
.launcher {
|
|
38
|
+
position: fixed; left: 12px; bottom: 12px; z-index: 6;
|
|
39
|
+
width: 48px; height: 48px; display: flex; align-items: center; justify-content: center;
|
|
40
|
+
padding: 0; border-radius: 14px; cursor: pointer; pointer-events: auto;
|
|
41
|
+
background: var(--panel); border: 1px solid var(--edge); color: #cfe0f2;
|
|
42
|
+
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.45);
|
|
43
|
+
backdrop-filter: blur(6px); box-shadow: 0 6px 22px rgba(0, 0, 0, 0.45);
|
|
44
|
+
}
|
|
45
|
+
.launcher:hover { color: #eafdff; border-color: var(--accent); }
|
|
46
|
+
.launcher svg { width: 24px; height: 24px; }
|
|
47
|
+
|
|
48
|
+
/* ── bottom sheets (node list + inspector) ───────────────────────────────── */
|
|
49
|
+
.sheet {
|
|
50
|
+
position: fixed; z-index: 7; display: flex; flex-direction: column;
|
|
51
|
+
background: var(--panel); border: 1px solid var(--edge);
|
|
52
|
+
backdrop-filter: blur(10px); pointer-events: auto;
|
|
53
|
+
box-shadow: 0 -8px 40px rgba(0, 0, 0, 0.55);
|
|
54
|
+
animation: sheet-in 0.18s ease;
|
|
55
|
+
}
|
|
56
|
+
@keyframes sheet-in { from { transform: translateY(12px); opacity: 0; } to { transform: none; opacity: 1; } }
|
|
57
|
+
|
|
58
|
+
/* Portrait / narrow: a bottom-docked sheet that sits a bit lower (leaves more of
|
|
59
|
+
the viewport visible above it); one at a time. */
|
|
60
|
+
.sheet {
|
|
61
|
+
left: 0; right: 0; bottom: 0;
|
|
62
|
+
height: min(50vh, 460px);
|
|
63
|
+
border-radius: 16px 16px 0 0;
|
|
64
|
+
padding-bottom: env(safe-area-inset-bottom, 0);
|
|
65
|
+
}
|
|
66
|
+
/* Landscape: full-height side columns (top→bottom) — node list left, inspector
|
|
67
|
+
right, both may coexist. */
|
|
68
|
+
@media (orientation: landscape) {
|
|
69
|
+
.sheet { top: 12px; bottom: 12px; height: auto; border-radius: 16px; width: min(400px, 44vw); }
|
|
70
|
+
.sheetNodes { left: 12px; right: auto; }
|
|
71
|
+
.sheetInspector { right: 12px; left: auto; }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.head {
|
|
75
|
+
flex: 0 0 auto; display: flex; align-items: center; gap: 8px;
|
|
76
|
+
padding: 9px 10px 9px 14px; border-bottom: 1px solid var(--line);
|
|
77
|
+
}
|
|
78
|
+
.title {
|
|
79
|
+
flex: 0 1 auto; min-width: 0; font: 700 11px/1 ui-monospace, monospace; color: #9fe7ff;
|
|
80
|
+
letter-spacing: 0.08em; text-transform: uppercase;
|
|
81
|
+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
82
|
+
}
|
|
83
|
+
/* Many actions on a narrow phone: let the row scroll horizontally rather than overflow. */
|
|
84
|
+
/* The button group sits against the right edge; `auto` only consumes spare
|
|
85
|
+
space, so a crowded header still lets the group shrink and scroll. */
|
|
86
|
+
.actions { display: flex; align-items: center; gap: 2px; margin-left: auto; min-width: 0; overflow-x: auto; scrollbar-width: none; }
|
|
87
|
+
.actions::-webkit-scrollbar { display: none; }
|
|
88
|
+
.headBtn {
|
|
89
|
+
flex: 0 0 auto;
|
|
90
|
+
width: 34px; height: 34px; display: flex; align-items: center; justify-content: center;
|
|
91
|
+
padding: 0; border: 1px solid transparent; border-radius: 8px;
|
|
92
|
+
background: transparent; color: #cfe0f2; cursor: pointer;
|
|
93
|
+
}
|
|
94
|
+
.headBtn svg { width: 18px; height: 18px; }
|
|
95
|
+
.headBtn:hover:not(:disabled) { background: rgba(74, 240, 224, 0.12); color: #eafdff; }
|
|
96
|
+
.headBtn:disabled { opacity: 0.32; cursor: default; }
|
|
97
|
+
.body { flex: 1 1 auto; overflow: auto; -webkit-overflow-scrolling: touch; }
|
|
98
|
+
|
|
99
|
+
/* ── tree (react-arborist rows) ──────────────────────────────────────────── */
|
|
100
|
+
.tree { padding: 4px 0; }
|
|
101
|
+
.node {
|
|
102
|
+
display: flex; align-items: center; gap: 4px; height: 100%;
|
|
103
|
+
padding-right: 8px; cursor: pointer; white-space: nowrap;
|
|
104
|
+
font: 12px/1.4 ui-monospace, monospace; color: #d6e2f0; border-radius: 6px;
|
|
105
|
+
}
|
|
106
|
+
.node:hover { background: rgba(74, 240, 224, 0.08); }
|
|
107
|
+
.node.sel { background: rgba(74, 240, 224, 0.18); color: #eafdff; }
|
|
108
|
+
.node.drop { background: rgba(74, 240, 224, 0.14); box-shadow: inset 0 0 0 1px var(--accent); }
|
|
109
|
+
.caret {
|
|
110
|
+
flex: 0 0 18px; width: 18px; height: 100%; display: flex; align-items: center; justify-content: center;
|
|
111
|
+
color: #6f93b8; user-select: none;
|
|
112
|
+
}
|
|
113
|
+
.caret svg { width: 14px; height: 14px; }
|
|
114
|
+
.nodeName { overflow: hidden; text-overflow: ellipsis; }
|
|
115
|
+
.nodeType { color: #6f93b8; margin-left: 6px; }
|
|
116
|
+
|
|
117
|
+
/* ── inspector rows + sections ───────────────────────────────────────────── */
|
|
118
|
+
.inspector { padding: 6px 12px 16px; font: 12px/1.5 ui-monospace, monospace; }
|
|
119
|
+
.section { margin-top: 4px; }
|
|
120
|
+
.secHead {
|
|
121
|
+
display: flex; align-items: center; flex-wrap: wrap; gap: 4px; margin: 10px 0 4px;
|
|
122
|
+
color: #9fe7ff; font-weight: 700; border-bottom: 1px solid var(--line);
|
|
123
|
+
padding-bottom: 3px; cursor: pointer; user-select: none;
|
|
124
|
+
text-transform: uppercase; letter-spacing: 0.04em; font-size: 11px;
|
|
125
|
+
}
|
|
126
|
+
.secHeadPlain { cursor: default; }
|
|
127
|
+
/* The registry description of a section's tag/component: a subtle, lower-case
|
|
128
|
+
caption beside the uppercase title. Truncates rather than wrapping so a long
|
|
129
|
+
description never pushes the rows down; the full text is in the row tooltips. */
|
|
130
|
+
.secDesc {
|
|
131
|
+
/* Own line, full width, wrapping: the description explains what the section is
|
|
132
|
+
for and is usually worth more than the property names under it. */
|
|
133
|
+
flex: 1 0 100%; margin: 2px 0 1px 20px;
|
|
134
|
+
color: #8a9db5; font-weight: 400; font-size: 10px; line-height: 1.45;
|
|
135
|
+
text-transform: none; letter-spacing: 0;
|
|
136
|
+
white-space: normal; overflow-wrap: anywhere;
|
|
137
|
+
}
|
|
138
|
+
.secCaret { flex: 0 0 14px; width: 14px; display: inline-flex; align-items: center; color: #6f93b8; }
|
|
139
|
+
.secCaret svg { width: 13px; height: 13px; }
|
|
140
|
+
.secBody { padding: 1px 0 2px; }
|
|
141
|
+
|
|
142
|
+
/* Per-component/attachment toolbar (move up / move down / delete). Pushed to the
|
|
143
|
+
right of the section header; the buttons stop propagation so a click doesn't
|
|
144
|
+
also fold the section. */
|
|
145
|
+
.secActions { margin-left: auto; display: inline-flex; align-items: center; gap: 1px; }
|
|
146
|
+
.memberBtn {
|
|
147
|
+
width: 22px; height: 22px; display: inline-flex; align-items: center; justify-content: center;
|
|
148
|
+
padding: 0; border: 0; border-radius: 5px;
|
|
149
|
+
background: transparent; color: #7d90a8; cursor: pointer;
|
|
150
|
+
}
|
|
151
|
+
.memberBtn:hover:not(:disabled) { background: rgba(74, 240, 224, 0.14); color: #eafdff; }
|
|
152
|
+
.memberBtn:disabled { opacity: 0.28; cursor: default; }
|
|
153
|
+
.memberDelete:hover:not(:disabled) { background: rgba(255, 120, 120, 0.16); color: #ff9b9b; }
|
|
154
|
+
.memberBtn svg { width: 13px; height: 13px; }
|
|
155
|
+
/* The identity row: an active tick and the id, on one line, as Unity's inspector opens. */
|
|
156
|
+
.identity { display: flex; align-items: center; gap: 8px; padding: 6px 0 8px; }
|
|
157
|
+
.identityId { flex: 1 1 auto; min-width: 0; text-align: left; font-weight: 700; color: #eafdff; }
|
|
158
|
+
|
|
159
|
+
.row { display: flex; justify-content: space-between; align-items: center; gap: 10px; min-height: 30px; padding: 2px 0; }
|
|
160
|
+
.k { color: #8fa6c4; flex: 0 0 auto; }
|
|
161
|
+
.v { color: #e7f0fb; text-align: right; word-break: break-word; display: flex; align-items: center; justify-content: flex-end; gap: 4px; }
|
|
162
|
+
.empty { color: #6f8099; padding: 16px 4px; }
|
|
163
|
+
|
|
164
|
+
/* An attribute that is NOT written in the source: the widget shows the value the
|
|
165
|
+
node will build with, dimmed, so "no attribute in the XML" reads differently
|
|
166
|
+
from "explicitly set to this". Typing in it writes the attribute. */
|
|
167
|
+
.unsetRow .k { color: #61708a; }
|
|
168
|
+
.unsetRow .input, .unsetRow .swatch, .unsetRow .toggle { opacity: 0.45; }
|
|
169
|
+
.unsetRow:focus-within .input, .unsetRow:focus-within .swatch, .unsetRow:focus-within .toggle { opacity: 1; }
|
|
170
|
+
|
|
171
|
+
/* Clears the attribute (removes it from the file). Only rendered when written. */
|
|
172
|
+
.clearBtn {
|
|
173
|
+
flex: 0 0 auto; width: 20px; height: 20px; margin-left: 2px;
|
|
174
|
+
display: inline-flex; align-items: center; justify-content: center;
|
|
175
|
+
padding: 0; border: 0; border-radius: 5px;
|
|
176
|
+
background: transparent; color: #6f8099; cursor: pointer;
|
|
177
|
+
}
|
|
178
|
+
.clearBtn:hover { background: rgba(255, 120, 120, 0.14); color: #ff9b9b; }
|
|
179
|
+
.clearBtn svg { width: 12px; height: 12px; }
|
|
180
|
+
|
|
181
|
+
/* A disabled/locked row: dimmed value + a small lock glyph (reason in the title). */
|
|
182
|
+
.locked { cursor: help; }
|
|
183
|
+
.locked .k { color: #6f8099; }
|
|
184
|
+
.locked .lockVal { color: #7c8ba3; }
|
|
185
|
+
.lockIcon { width: 12px; height: 12px; color: #6f8099; flex: 0 0 auto; opacity: 0.85; }
|
|
186
|
+
|
|
187
|
+
/* ── editable field widgets (16px font avoids iOS tap-zoom; roomy touch targets) ── */
|
|
188
|
+
.input {
|
|
189
|
+
background: #0e1219; color: #e7f0fb; border: 1px solid var(--line); border-radius: 5px;
|
|
190
|
+
padding: 4px 6px; font: 16px ui-monospace, monospace; min-height: 28px;
|
|
191
|
+
}
|
|
192
|
+
.input:focus { outline: none; border-color: var(--accent); }
|
|
193
|
+
.num { width: 64px; text-align: right; }
|
|
194
|
+
.axes { display: flex; gap: 4px; flex-wrap: wrap; justify-content: flex-end; }
|
|
195
|
+
.axis { display: flex; align-items: center; gap: 2px; }
|
|
196
|
+
.axisK { color: #6f93b8; font-size: 11px; width: 12px; text-align: center; }
|
|
197
|
+
.scrub { cursor: ew-resize; touch-action: none; }
|
|
198
|
+
.toggle { width: 22px; height: 22px; accent-color: var(--accent); }
|
|
199
|
+
.color { display: flex; align-items: center; gap: 5px; }
|
|
200
|
+
.swatch { width: 30px; height: 28px; padding: 0; border: 1px solid var(--line); border-radius: 5px; background: none; cursor: pointer; }
|
|
201
|
+
.hex { width: 78px; }
|
|
202
|
+
.select { min-width: 96px; cursor: pointer; }
|
|
203
|
+
|
|
204
|
+
/* ── add component / attachment picker ─────────────────────────────────────── */
|
|
205
|
+
/* Centred modal over a dimming backdrop. Above the sheets (z 7) so it is never
|
|
206
|
+
occluded, and capped in height so a long registry scrolls instead of overflowing. */
|
|
207
|
+
.modalBackdrop {
|
|
208
|
+
position: fixed; inset: 0; z-index: 9;
|
|
209
|
+
display: flex; align-items: center; justify-content: center;
|
|
210
|
+
background: rgba(4, 8, 14, 0.55); backdrop-filter: blur(2px);
|
|
211
|
+
pointer-events: auto;
|
|
212
|
+
}
|
|
213
|
+
.modal {
|
|
214
|
+
width: min(460px, calc(100vw - 32px)); max-height: min(70vh, 560px);
|
|
215
|
+
display: flex; flex-direction: column;
|
|
216
|
+
background: var(--panel); border: 1px solid var(--edge); border-radius: 12px;
|
|
217
|
+
box-shadow: 0 18px 60px rgba(0, 0, 0, 0.6);
|
|
218
|
+
overflow: hidden;
|
|
219
|
+
}
|
|
220
|
+
.modalHead {
|
|
221
|
+
flex: 0 0 auto; display: flex; align-items: center; gap: 8px;
|
|
222
|
+
padding: 10px 10px 10px 14px; border-bottom: 1px solid var(--line);
|
|
223
|
+
}
|
|
224
|
+
.modalHead .clearBtn { margin-left: auto; }
|
|
225
|
+
.modalFilter { flex: 0 0 auto; margin: 10px 12px 6px; }
|
|
226
|
+
.modalList { flex: 1 1 auto; overflow-y: auto; padding: 2px 6px 8px; }
|
|
227
|
+
.modalItem {
|
|
228
|
+
width: 100%; display: flex; flex-direction: column; gap: 2px; align-items: flex-start;
|
|
229
|
+
padding: 7px 8px; border: 0; border-radius: 6px;
|
|
230
|
+
background: transparent; color: #e7f0fb; text-align: left; cursor: pointer;
|
|
231
|
+
font: inherit;
|
|
232
|
+
}
|
|
233
|
+
.modalItem:hover { background: rgba(74, 240, 224, 0.12); }
|
|
234
|
+
.modalItemTag { font-weight: 700; color: #9fe7ff; }
|
|
235
|
+
.modalItemDesc { color: #8a9db5; font-size: 10px; line-height: 1.45; }
|
|
236
|
+
/* The kind chip on a file-picker row — `scene` / `prefab` beside the name. */
|
|
237
|
+
.pickKind {
|
|
238
|
+
margin-left: 8px; padding: 1px 6px; border-radius: 999px;
|
|
239
|
+
background: rgba(74, 240, 224, 0.12); color: #7fd8cf;
|
|
240
|
+
font-size: 9px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/* The "+ Add …" row that opens the picker, one per area. */
|
|
244
|
+
.addRow {
|
|
245
|
+
width: 100%; display: flex; align-items: center; justify-content: center; gap: 6px;
|
|
246
|
+
margin: 6px 0 2px; padding: 5px 0; border: 1px dashed var(--line); border-radius: 6px;
|
|
247
|
+
background: transparent; color: #7d90a8; cursor: pointer; font: inherit; font-size: 11px;
|
|
248
|
+
}
|
|
249
|
+
.addRow:hover { border-color: var(--accent); color: #eafdff; }
|
|
250
|
+
.addRow svg { width: 13px; height: 13px; }
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { useLayoutEffect, useRef, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Track an element's content size via ResizeObserver — react-arborist needs
|
|
5
|
+
* explicit pixel width/height, so we measure its (flex) container and feed them in.
|
|
6
|
+
*/
|
|
7
|
+
export function useSize<T extends HTMLElement>(): readonly [React.RefObject<T>, { width: number; height: number }] {
|
|
8
|
+
const ref = useRef<T>(null);
|
|
9
|
+
const [size, setSize] = useState({ width: 0, height: 0 });
|
|
10
|
+
useLayoutEffect(() => {
|
|
11
|
+
const el = ref.current;
|
|
12
|
+
if (!el) return;
|
|
13
|
+
const measure = (): void => setSize({ width: el.clientWidth, height: el.clientHeight });
|
|
14
|
+
const ro = new ResizeObserver(measure);
|
|
15
|
+
ro.observe(el);
|
|
16
|
+
measure();
|
|
17
|
+
return () => ro.disconnect();
|
|
18
|
+
}, []);
|
|
19
|
+
return [ref, size] as const;
|
|
20
|
+
}
|
package/app/values.ts
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import type { PropInfo, ValueShape } from '@scenoco-three/compiler';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A value as the inspector's widgets handle it: a number, a flag, some text, or a list of axes.
|
|
5
|
+
*
|
|
6
|
+
* The document stores attribute **text**, and the widgets — the number scrubber, the vector row,
|
|
7
|
+
* the colour swatch — work in typed values. This is the pair of functions between them, and it
|
|
8
|
+
* speaks the DSL's own grammar in both directions: `x: 1; y: 2` for a vector, `#rrggbb` for a
|
|
9
|
+
* colour, degrees for a rotation. So what a widget writes is what a person would have written.
|
|
10
|
+
*/
|
|
11
|
+
export type Scalar = number | boolean | string | number[];
|
|
12
|
+
|
|
13
|
+
/** Attribute text → a value a widget can edit, or undefined when there is nothing to show. */
|
|
14
|
+
export function parseValue(shape: ValueShape, text: string | undefined): Scalar | undefined {
|
|
15
|
+
if (text === undefined) return undefined;
|
|
16
|
+
switch (shape) {
|
|
17
|
+
case 'number': {
|
|
18
|
+
const n = Number(text.trim());
|
|
19
|
+
return Number.isFinite(n) ? n : 0;
|
|
20
|
+
}
|
|
21
|
+
case 'boolean':
|
|
22
|
+
return text.trim() === 'true';
|
|
23
|
+
case 'color':
|
|
24
|
+
return colorToNumber(text);
|
|
25
|
+
case 'vector2':
|
|
26
|
+
return axes(text, 2);
|
|
27
|
+
case 'vector3':
|
|
28
|
+
case 'euler':
|
|
29
|
+
return axes(text, 3);
|
|
30
|
+
case 'vector4':
|
|
31
|
+
return axes(text, 4);
|
|
32
|
+
default:
|
|
33
|
+
return text;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A value from a widget → the text the document gets. */
|
|
38
|
+
export function formatValue(shape: ValueShape, value: Scalar): string {
|
|
39
|
+
switch (shape) {
|
|
40
|
+
case 'number':
|
|
41
|
+
return String(round(Number(value)));
|
|
42
|
+
case 'boolean':
|
|
43
|
+
return value ? 'true' : 'false';
|
|
44
|
+
case 'color':
|
|
45
|
+
return `#${Math.max(0, Math.round(Number(value))).toString(16).padStart(6, '0').slice(-6)}`;
|
|
46
|
+
case 'vector2':
|
|
47
|
+
return named(value as number[], ['x', 'y']);
|
|
48
|
+
case 'vector3':
|
|
49
|
+
case 'euler':
|
|
50
|
+
return named(value as number[], ['x', 'y', 'z']);
|
|
51
|
+
case 'vector4':
|
|
52
|
+
return named(value as number[], ['x', 'y', 'z', 'w']);
|
|
53
|
+
default:
|
|
54
|
+
return String(value);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Something to render for an attribute the document does not write.
|
|
60
|
+
*
|
|
61
|
+
* The row still has to draw, and setting it is the whole point of it being there — so an unwritten
|
|
62
|
+
* optional shows its declared default when it has one, and a neutral value when it does not.
|
|
63
|
+
*/
|
|
64
|
+
export function shownValue(prop: PropInfo, written: string | undefined): Scalar {
|
|
65
|
+
return parseValue(prop.shape, written) ?? parseValue(prop.shape, prop.default?.replace(/^['"]|['"]$/g, '')) ?? neutral(prop.shape);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function neutral(shape: ValueShape): Scalar {
|
|
69
|
+
switch (shape) {
|
|
70
|
+
case 'boolean':
|
|
71
|
+
return false;
|
|
72
|
+
case 'string':
|
|
73
|
+
case 'enum':
|
|
74
|
+
case 'nodeRef':
|
|
75
|
+
case 'assetRef':
|
|
76
|
+
return '';
|
|
77
|
+
case 'vector2':
|
|
78
|
+
return [0, 0];
|
|
79
|
+
case 'vector3':
|
|
80
|
+
case 'euler':
|
|
81
|
+
return [0, 0, 0];
|
|
82
|
+
case 'vector4':
|
|
83
|
+
return [0, 0, 0, 0];
|
|
84
|
+
default:
|
|
85
|
+
return 0;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Which widget renders a shape, or `null` for one no widget can edit. */
|
|
90
|
+
export function widgetFor(prop: PropInfo): ValueShape | null {
|
|
91
|
+
switch (prop.shape) {
|
|
92
|
+
case 'number':
|
|
93
|
+
case 'boolean':
|
|
94
|
+
case 'string':
|
|
95
|
+
case 'color':
|
|
96
|
+
case 'vector2':
|
|
97
|
+
case 'vector3':
|
|
98
|
+
case 'vector4':
|
|
99
|
+
case 'euler':
|
|
100
|
+
case 'enum':
|
|
101
|
+
case 'nodeRef':
|
|
102
|
+
case 'assetRef':
|
|
103
|
+
return prop.shape;
|
|
104
|
+
default:
|
|
105
|
+
return null; // an array, or something the DSL cannot express
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function axes(text: string, arity: number): number[] {
|
|
110
|
+
const out = new Array<number>(arity).fill(0);
|
|
111
|
+
const names = ['x', 'y', 'z', 'w'];
|
|
112
|
+
// The one spelling a document uses. A value that is not in it reads as zeroes, which is what
|
|
113
|
+
// the compiler would have rejected anyway — the widget then writes it back correctly.
|
|
114
|
+
for (const part of text.split(';')) {
|
|
115
|
+
const [axis, value] = part.split(':');
|
|
116
|
+
const index = names.indexOf((axis ?? '').trim());
|
|
117
|
+
const n = Number((value ?? '').trim());
|
|
118
|
+
if (index !== -1 && index < arity && Number.isFinite(n)) out[index] = n;
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function named(values: number[], names: string[]): string {
|
|
124
|
+
return names.map((name, i) => `${name}: ${round(values[i] ?? 0)}`).join('; ');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function colorToNumber(text: string): number {
|
|
128
|
+
const hex = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.exec(text.trim());
|
|
129
|
+
if (!hex) return 0;
|
|
130
|
+
const digits = hex[1]!;
|
|
131
|
+
return parseInt(digits.length === 3 ? digits.replace(/./g, (c) => c + c) : digits, 16);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Three decimals: a scrubbed number should not write `0.30000000000000004` into someone's file. */
|
|
135
|
+
function round(n: number): number {
|
|
136
|
+
return Math.round(n * 1000) / 1000;
|
|
137
|
+
}
|