react-three-game 0.0.16 → 0.0.17
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/README.md +88 -113
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/tools/prefabeditor/EditorTree.js +27 -15
- package/dist/tools/prefabeditor/EditorUI.js +2 -8
- package/dist/tools/prefabeditor/PrefabEditor.js +128 -59
- package/dist/tools/prefabeditor/PrefabRoot.js +34 -15
- package/dist/tools/prefabeditor/components/ComponentRegistry.d.ts +2 -0
- package/dist/tools/prefabeditor/components/RotatorComponent.d.ts +3 -0
- package/dist/tools/prefabeditor/components/RotatorComponent.js +42 -0
- package/dist/tools/prefabeditor/components/TransformComponent.js +28 -3
- package/package.json +7 -7
- package/src/index.ts +4 -0
- package/src/tools/prefabeditor/EditorTree.tsx +39 -16
- package/src/tools/prefabeditor/EditorUI.tsx +2 -27
- package/src/tools/prefabeditor/PrefabEditor.tsx +202 -86
- package/src/tools/prefabeditor/PrefabRoot.tsx +38 -19
- package/src/tools/prefabeditor/components/ComponentRegistry.ts +7 -1
- package/src/tools/prefabeditor/components/TransformComponent.tsx +69 -16
|
@@ -67,92 +67,12 @@ const PrefabEditor = ({ basePath, initialPrefab, onPrefabChange, children }: { b
|
|
|
67
67
|
</Physics>
|
|
68
68
|
</GameCanvas>
|
|
69
69
|
|
|
70
|
-
<
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
display: "flex",
|
|
77
|
-
alignItems: "center",
|
|
78
|
-
gap: 6,
|
|
79
|
-
padding: "2px 4px",
|
|
80
|
-
background: "rgba(0,0,0,0.55)",
|
|
81
|
-
border: "1px solid rgba(255,255,255,0.12)",
|
|
82
|
-
borderRadius: 4,
|
|
83
|
-
color: "rgba(255,255,255,0.9)",
|
|
84
|
-
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
|
|
85
|
-
fontSize: 11,
|
|
86
|
-
lineHeight: 1,
|
|
87
|
-
WebkitUserSelect: "none",
|
|
88
|
-
userSelect: "none",
|
|
89
|
-
}}
|
|
90
|
-
>
|
|
91
|
-
<button
|
|
92
|
-
style={{
|
|
93
|
-
padding: "2px 6px",
|
|
94
|
-
font: "inherit",
|
|
95
|
-
background: "transparent",
|
|
96
|
-
color: "inherit",
|
|
97
|
-
border: "1px solid rgba(255,255,255,0.18)",
|
|
98
|
-
borderRadius: 3,
|
|
99
|
-
cursor: "pointer",
|
|
100
|
-
}}
|
|
101
|
-
onClick={() => setEditMode(!editMode)}
|
|
102
|
-
onPointerEnter={(e) => {
|
|
103
|
-
(e.currentTarget as HTMLButtonElement).style.background = "rgba(255,255,255,0.08)";
|
|
104
|
-
}}
|
|
105
|
-
onPointerLeave={(e) => {
|
|
106
|
-
(e.currentTarget as HTMLButtonElement).style.background = "transparent";
|
|
107
|
-
}}
|
|
108
|
-
>
|
|
109
|
-
{editMode ? "▶" : "⏸"}
|
|
110
|
-
</button>
|
|
111
|
-
<span style={{ opacity: 0.35 }}>|</span>
|
|
112
|
-
<button
|
|
113
|
-
style={{
|
|
114
|
-
padding: "2px 6px",
|
|
115
|
-
font: "inherit",
|
|
116
|
-
background: "transparent",
|
|
117
|
-
color: "inherit",
|
|
118
|
-
border: "1px solid rgba(255,255,255,0.18)",
|
|
119
|
-
borderRadius: 3,
|
|
120
|
-
cursor: "pointer",
|
|
121
|
-
}}
|
|
122
|
-
onClick={async () => {
|
|
123
|
-
const prefab = await loadJson();
|
|
124
|
-
if (prefab) setLoadedPrefab(prefab);
|
|
125
|
-
}}
|
|
126
|
-
onPointerEnter={(e) => {
|
|
127
|
-
(e.currentTarget as HTMLButtonElement).style.background = "rgba(255,255,255,0.08)";
|
|
128
|
-
}}
|
|
129
|
-
onPointerLeave={(e) => {
|
|
130
|
-
(e.currentTarget as HTMLButtonElement).style.background = "transparent";
|
|
131
|
-
}}
|
|
132
|
-
>
|
|
133
|
-
📥
|
|
134
|
-
</button>
|
|
135
|
-
<button
|
|
136
|
-
style={{
|
|
137
|
-
padding: "2px 6px",
|
|
138
|
-
font: "inherit",
|
|
139
|
-
background: "transparent",
|
|
140
|
-
color: "inherit",
|
|
141
|
-
border: "1px solid rgba(255,255,255,0.18)",
|
|
142
|
-
borderRadius: 3,
|
|
143
|
-
cursor: "pointer",
|
|
144
|
-
}}
|
|
145
|
-
onClick={() => saveJson(loadedPrefab, "prefab")}
|
|
146
|
-
onPointerEnter={(e) => {
|
|
147
|
-
(e.currentTarget as HTMLButtonElement).style.background = "rgba(255,255,255,0.08)";
|
|
148
|
-
}}
|
|
149
|
-
onPointerLeave={(e) => {
|
|
150
|
-
(e.currentTarget as HTMLButtonElement).style.background = "transparent";
|
|
151
|
-
}}
|
|
152
|
-
>
|
|
153
|
-
💾
|
|
154
|
-
</button>
|
|
155
|
-
</div>
|
|
70
|
+
<SaveDataPanel
|
|
71
|
+
currentData={loadedPrefab}
|
|
72
|
+
onDataChange={updatePrefab}
|
|
73
|
+
editMode={editMode}
|
|
74
|
+
onEditModeChange={setEditMode}
|
|
75
|
+
/>
|
|
156
76
|
{editMode && <EditorUI
|
|
157
77
|
prefabData={loadedPrefab}
|
|
158
78
|
setPrefabData={updatePrefab}
|
|
@@ -165,6 +85,202 @@ const PrefabEditor = ({ basePath, initialPrefab, onPrefabChange, children }: { b
|
|
|
165
85
|
</>
|
|
166
86
|
}
|
|
167
87
|
|
|
88
|
+
const SaveDataPanel = ({
|
|
89
|
+
currentData,
|
|
90
|
+
onDataChange,
|
|
91
|
+
editMode,
|
|
92
|
+
onEditModeChange
|
|
93
|
+
}: {
|
|
94
|
+
currentData: Prefab;
|
|
95
|
+
onDataChange: (data: Prefab) => void;
|
|
96
|
+
editMode: boolean;
|
|
97
|
+
onEditModeChange: (mode: boolean) => void;
|
|
98
|
+
}) => {
|
|
99
|
+
const [history, setHistory] = useState<Prefab[]>([currentData]);
|
|
100
|
+
const [historyIndex, setHistoryIndex] = useState(0);
|
|
101
|
+
const throttleTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
102
|
+
const lastSavedDataRef = useRef<string>(JSON.stringify(currentData));
|
|
103
|
+
|
|
104
|
+
// Define undo/redo handlers
|
|
105
|
+
const handleUndo = () => {
|
|
106
|
+
if (historyIndex > 0) {
|
|
107
|
+
const newIndex = historyIndex - 1;
|
|
108
|
+
setHistoryIndex(newIndex);
|
|
109
|
+
lastSavedDataRef.current = JSON.stringify(history[newIndex]);
|
|
110
|
+
onDataChange(history[newIndex]);
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const handleRedo = () => {
|
|
115
|
+
if (historyIndex < history.length - 1) {
|
|
116
|
+
const newIndex = historyIndex + 1;
|
|
117
|
+
setHistoryIndex(newIndex);
|
|
118
|
+
lastSavedDataRef.current = JSON.stringify(history[newIndex]);
|
|
119
|
+
onDataChange(history[newIndex]);
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
// Keyboard shortcuts for undo/redo
|
|
124
|
+
useEffect(() => {
|
|
125
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
126
|
+
// Undo: Ctrl+Z (Cmd+Z on Mac)
|
|
127
|
+
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
|
|
128
|
+
e.preventDefault();
|
|
129
|
+
handleUndo();
|
|
130
|
+
}
|
|
131
|
+
// Redo: Ctrl+Shift+Z or Ctrl+Y (Cmd+Shift+Z or Cmd+Y on Mac)
|
|
132
|
+
else if ((e.ctrlKey || e.metaKey) && (e.shiftKey && e.key === 'z' || e.key === 'y')) {
|
|
133
|
+
e.preventDefault();
|
|
134
|
+
handleRedo();
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
window.addEventListener('keydown', handleKeyDown);
|
|
139
|
+
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
140
|
+
}, [historyIndex, history]);
|
|
141
|
+
|
|
142
|
+
// Throttled history update when currentData changes
|
|
143
|
+
useEffect(() => {
|
|
144
|
+
const currentDataStr = JSON.stringify(currentData);
|
|
145
|
+
|
|
146
|
+
// Skip if data hasn't actually changed
|
|
147
|
+
if (currentDataStr === lastSavedDataRef.current) {
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Clear existing throttle timeout
|
|
152
|
+
if (throttleTimeoutRef.current) {
|
|
153
|
+
clearTimeout(throttleTimeoutRef.current);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Set new throttled update
|
|
157
|
+
throttleTimeoutRef.current = setTimeout(() => {
|
|
158
|
+
lastSavedDataRef.current = currentDataStr;
|
|
159
|
+
|
|
160
|
+
setHistory(prev => {
|
|
161
|
+
// Slice history at current index (discard future states)
|
|
162
|
+
const newHistory = prev.slice(0, historyIndex + 1);
|
|
163
|
+
// Add new state
|
|
164
|
+
newHistory.push(currentData);
|
|
165
|
+
// Limit history size to 50 states
|
|
166
|
+
if (newHistory.length > 50) {
|
|
167
|
+
newHistory.shift();
|
|
168
|
+
return newHistory;
|
|
169
|
+
}
|
|
170
|
+
return newHistory;
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
setHistoryIndex(prev => {
|
|
174
|
+
const newHistory = history.slice(0, prev + 1);
|
|
175
|
+
newHistory.push(currentData);
|
|
176
|
+
return Math.min(newHistory.length - 1, 49);
|
|
177
|
+
});
|
|
178
|
+
}, 500); // 500ms throttle
|
|
179
|
+
|
|
180
|
+
return () => {
|
|
181
|
+
if (throttleTimeoutRef.current) {
|
|
182
|
+
clearTimeout(throttleTimeoutRef.current);
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
}, [currentData, historyIndex, history]);
|
|
186
|
+
|
|
187
|
+
const handleLoad = async () => {
|
|
188
|
+
const prefab = await loadJson();
|
|
189
|
+
if (prefab) {
|
|
190
|
+
onDataChange(prefab);
|
|
191
|
+
// Reset history when loading new file
|
|
192
|
+
setHistory([prefab]);
|
|
193
|
+
setHistoryIndex(0);
|
|
194
|
+
lastSavedDataRef.current = JSON.stringify(prefab);
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
const canUndo = historyIndex > 0;
|
|
199
|
+
const canRedo = historyIndex < history.length - 1;
|
|
200
|
+
|
|
201
|
+
return <div style={{
|
|
202
|
+
position: "absolute",
|
|
203
|
+
top: 8,
|
|
204
|
+
left: "50%",
|
|
205
|
+
transform: "translateX(-50%)",
|
|
206
|
+
display: "flex",
|
|
207
|
+
alignItems: "center",
|
|
208
|
+
gap: 6,
|
|
209
|
+
padding: "2px 4px",
|
|
210
|
+
background: "rgba(0,0,0,0.55)",
|
|
211
|
+
border: "1px solid rgba(255,255,255,0.12)",
|
|
212
|
+
borderRadius: 4,
|
|
213
|
+
color: "rgba(255,255,255,0.9)",
|
|
214
|
+
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
|
|
215
|
+
fontSize: 11,
|
|
216
|
+
lineHeight: 1,
|
|
217
|
+
WebkitUserSelect: "none",
|
|
218
|
+
userSelect: "none",
|
|
219
|
+
}}>
|
|
220
|
+
<PanelButton onClick={() => onEditModeChange(!editMode)}>
|
|
221
|
+
{editMode ? "▶" : "⏸"}
|
|
222
|
+
</PanelButton>
|
|
223
|
+
|
|
224
|
+
<span style={{ opacity: 0.35 }}>|</span>
|
|
225
|
+
|
|
226
|
+
<PanelButton onClick={handleUndo} disabled={!canUndo} title="Undo (Ctrl+Z)">
|
|
227
|
+
↶
|
|
228
|
+
</PanelButton>
|
|
229
|
+
|
|
230
|
+
<PanelButton onClick={handleRedo} disabled={!canRedo} title="Redo (Ctrl+Shift+Z)">
|
|
231
|
+
↷
|
|
232
|
+
</PanelButton>
|
|
233
|
+
|
|
234
|
+
<span style={{ opacity: 0.35 }}>|</span>
|
|
235
|
+
|
|
236
|
+
<PanelButton onClick={handleLoad} title="Load JSON">
|
|
237
|
+
📥
|
|
238
|
+
</PanelButton>
|
|
239
|
+
|
|
240
|
+
<PanelButton onClick={() => saveJson(currentData, "prefab")} title="Save JSON">
|
|
241
|
+
💾
|
|
242
|
+
</PanelButton>
|
|
243
|
+
</div>;
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
const PanelButton = ({
|
|
247
|
+
onClick,
|
|
248
|
+
disabled,
|
|
249
|
+
title,
|
|
250
|
+
children
|
|
251
|
+
}: {
|
|
252
|
+
onClick: () => void;
|
|
253
|
+
disabled?: boolean;
|
|
254
|
+
title?: string;
|
|
255
|
+
children: React.ReactNode;
|
|
256
|
+
}) => {
|
|
257
|
+
return <button
|
|
258
|
+
style={{
|
|
259
|
+
padding: "2px 6px",
|
|
260
|
+
font: "inherit",
|
|
261
|
+
background: "transparent",
|
|
262
|
+
color: disabled ? "rgba(255,255,255,0.3)" : "inherit",
|
|
263
|
+
border: "1px solid rgba(255,255,255,0.18)",
|
|
264
|
+
borderRadius: 3,
|
|
265
|
+
cursor: disabled ? "not-allowed" : "pointer",
|
|
266
|
+
opacity: disabled ? 0.5 : 1,
|
|
267
|
+
}}
|
|
268
|
+
onClick={onClick}
|
|
269
|
+
disabled={disabled}
|
|
270
|
+
title={title}
|
|
271
|
+
onPointerEnter={(e) => {
|
|
272
|
+
if (!disabled) {
|
|
273
|
+
(e.currentTarget as HTMLButtonElement).style.background = "rgba(255,255,255,0.08)";
|
|
274
|
+
}
|
|
275
|
+
}}
|
|
276
|
+
onPointerLeave={(e) => {
|
|
277
|
+
(e.currentTarget as HTMLButtonElement).style.background = "transparent";
|
|
278
|
+
}}
|
|
279
|
+
>
|
|
280
|
+
{children}
|
|
281
|
+
</button>;
|
|
282
|
+
};
|
|
283
|
+
|
|
168
284
|
const saveJson = (data: any, filename: string) => {
|
|
169
285
|
const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(data, null, 2));
|
|
170
286
|
const downloadAnchorNode = document.createElement('a');
|
|
@@ -311,7 +311,7 @@ function renderCoreNode(gameObject: GameObjectType, ctx: any, parentMatrix: Matr
|
|
|
311
311
|
|
|
312
312
|
const isModelAvailable = !!(modelComp && modelComp.properties && modelComp.properties.filename && ctx.loadedModels[modelComp.properties.filename]);
|
|
313
313
|
|
|
314
|
-
// Generic component views (exclude geometry/material/model)
|
|
314
|
+
// Generic component views (exclude geometry/material/model/transform/physics)
|
|
315
315
|
const contextProps = {
|
|
316
316
|
loadedModels: ctx.loadedModels,
|
|
317
317
|
loadedTextures: ctx.loadedTextures,
|
|
@@ -320,21 +320,37 @@ function renderCoreNode(gameObject: GameObjectType, ctx: any, parentMatrix: Matr
|
|
|
320
320
|
parentMatrix,
|
|
321
321
|
registerRef: ctx.registerRef,
|
|
322
322
|
};
|
|
323
|
-
|
|
324
|
-
|
|
323
|
+
|
|
324
|
+
// Separate wrapper components (that accept children) from leaf components
|
|
325
|
+
const wrapperComponents: Array<{ key: string; View: any; properties: any }> = [];
|
|
326
|
+
const leafComponents: React.ReactNode[] = [];
|
|
327
|
+
|
|
328
|
+
if (gameObject.components) {
|
|
329
|
+
Object.entries(gameObject.components)
|
|
325
330
|
.filter(([key]) => key !== 'geometry' && key !== 'material' && key !== 'model' && key !== 'transform' && key !== 'physics')
|
|
326
|
-
.
|
|
327
|
-
if (!comp || !comp.type) return
|
|
331
|
+
.forEach(([key, comp]) => {
|
|
332
|
+
if (!comp || !comp.type) return;
|
|
328
333
|
const def = getComponent(comp.type);
|
|
329
|
-
if (!def || !def.View) return
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
334
|
+
if (!def || !def.View) return;
|
|
335
|
+
|
|
336
|
+
// Check if the component View accepts children by checking function signature
|
|
337
|
+
// Components that wrap content should accept children prop
|
|
338
|
+
const viewString = def.View.toString();
|
|
339
|
+
if (viewString.includes('children')) {
|
|
340
|
+
wrapperComponents.push({ key, View: def.View, properties: comp.properties });
|
|
341
|
+
} else {
|
|
342
|
+
leafComponents.push(<def.View key={key} properties={comp.properties} {...contextProps} />);
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Build the core content (model or mesh)
|
|
348
|
+
let coreContent: React.ReactNode;
|
|
333
349
|
|
|
334
350
|
// If we have a model (non-instanced) render it as a primitive with material override
|
|
335
351
|
if (isModelAvailable) {
|
|
336
352
|
const modelObj = ctx.loadedModels[modelComp.properties.filename].clone();
|
|
337
|
-
|
|
353
|
+
coreContent = (
|
|
338
354
|
<primitive object={modelObj}>
|
|
339
355
|
{material && materialDef && materialDef.View && (
|
|
340
356
|
<materialDef.View
|
|
@@ -347,14 +363,12 @@ function renderCoreNode(gameObject: GameObjectType, ctx: any, parentMatrix: Matr
|
|
|
347
363
|
registerRef={ctx.registerRef}
|
|
348
364
|
/>
|
|
349
365
|
)}
|
|
350
|
-
{
|
|
366
|
+
{leafComponents}
|
|
351
367
|
</primitive>
|
|
352
368
|
);
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
if (geometry && geometryDef && geometryDef.View) {
|
|
357
|
-
return (
|
|
369
|
+
} else if (geometry && geometryDef && geometryDef.View) {
|
|
370
|
+
// Otherwise, if geometry present, render a mesh
|
|
371
|
+
coreContent = (
|
|
358
372
|
<mesh>
|
|
359
373
|
<geometryDef.View key="geometry" properties={geometry.properties} {...contextProps} />
|
|
360
374
|
{material && materialDef && materialDef.View && (
|
|
@@ -368,13 +382,18 @@ function renderCoreNode(gameObject: GameObjectType, ctx: any, parentMatrix: Matr
|
|
|
368
382
|
registerRef={ctx.registerRef}
|
|
369
383
|
/>
|
|
370
384
|
)}
|
|
371
|
-
{
|
|
385
|
+
{leafComponents}
|
|
372
386
|
</mesh>
|
|
373
387
|
);
|
|
388
|
+
} else {
|
|
389
|
+
// No geometry or model, just render leaf components
|
|
390
|
+
coreContent = <>{leafComponents}</>;
|
|
374
391
|
}
|
|
375
392
|
|
|
376
|
-
//
|
|
377
|
-
return
|
|
393
|
+
// Wrap core content with wrapper components (in order)
|
|
394
|
+
return wrapperComponents.reduce((content, { key, View, properties }) => {
|
|
395
|
+
return <View key={key} properties={properties} {...contextProps}>{content}</View>;
|
|
396
|
+
}, coreContent);
|
|
378
397
|
}
|
|
379
398
|
|
|
380
399
|
// Helper: wrap core content with physics component when necessary
|
|
@@ -2,7 +2,13 @@ import { FC } from "react";
|
|
|
2
2
|
|
|
3
3
|
export interface Component {
|
|
4
4
|
name: string;
|
|
5
|
-
Editor: FC<{
|
|
5
|
+
Editor: FC<{
|
|
6
|
+
component: any;
|
|
7
|
+
onUpdate: (newComp: any) => void;
|
|
8
|
+
basePath?: string;
|
|
9
|
+
transformMode?: "translate" | "rotate" | "scale";
|
|
10
|
+
setTransformMode?: (m: "translate" | "rotate" | "scale") => void;
|
|
11
|
+
}>;
|
|
6
12
|
defaultProperties: any;
|
|
7
13
|
// Allow View to accept extra props for special cases (like material)
|
|
8
14
|
View?: FC<any>;
|
|
@@ -1,8 +1,54 @@
|
|
|
1
1
|
|
|
2
2
|
import { Component } from "./ComponentRegistry";
|
|
3
3
|
|
|
4
|
-
function TransformComponentEditor({ component, onUpdate
|
|
4
|
+
function TransformComponentEditor({ component, onUpdate, transformMode, setTransformMode }: {
|
|
5
|
+
component: any;
|
|
6
|
+
onUpdate: (newComp: any) => void;
|
|
7
|
+
transformMode?: "translate" | "rotate" | "scale";
|
|
8
|
+
setTransformMode?: (m: "translate" | "rotate" | "scale") => void;
|
|
9
|
+
}) {
|
|
10
|
+
const s = {
|
|
11
|
+
button: {
|
|
12
|
+
padding: '2px 6px',
|
|
13
|
+
background: 'transparent',
|
|
14
|
+
color: 'rgba(255,255,255,0.9)',
|
|
15
|
+
border: '1px solid rgba(255,255,255,0.14)',
|
|
16
|
+
borderRadius: 4,
|
|
17
|
+
cursor: 'pointer',
|
|
18
|
+
font: 'inherit',
|
|
19
|
+
},
|
|
20
|
+
buttonActive: {
|
|
21
|
+
background: 'rgba(255,255,255,0.10)',
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
|
|
5
25
|
return <div className="flex flex-col">
|
|
26
|
+
{transformMode && setTransformMode && (
|
|
27
|
+
<div className="mb-2">
|
|
28
|
+
<label className="block text-[9px] text-cyan-400/60 uppercase tracking-wider mb-1">Transform Mode</label>
|
|
29
|
+
<div style={{ display: 'flex', gap: 6 }}>
|
|
30
|
+
{["translate", "rotate", "scale"].map(mode => (
|
|
31
|
+
<button
|
|
32
|
+
key={mode}
|
|
33
|
+
onClick={() => setTransformMode(mode as any)}
|
|
34
|
+
style={{
|
|
35
|
+
...s.button,
|
|
36
|
+
flex: 1,
|
|
37
|
+
...(transformMode === mode ? s.buttonActive : {}),
|
|
38
|
+
}}
|
|
39
|
+
onPointerEnter={(e) => {
|
|
40
|
+
if (transformMode !== mode) (e.currentTarget as HTMLButtonElement).style.background = 'rgba(255,255,255,0.08)';
|
|
41
|
+
}}
|
|
42
|
+
onPointerLeave={(e) => {
|
|
43
|
+
if (transformMode !== mode) (e.currentTarget as HTMLButtonElement).style.background = 'transparent';
|
|
44
|
+
}}
|
|
45
|
+
>
|
|
46
|
+
{mode}
|
|
47
|
+
</button>
|
|
48
|
+
))}
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
)}
|
|
6
52
|
<Vector3Input label="Position" value={component.properties.position} onChange={v => onUpdate({ position: v })} />
|
|
7
53
|
<Vector3Input label="Rotation" value={component.properties.rotation} onChange={v => onUpdate({ rotation: v })} />
|
|
8
54
|
<Vector3Input label="Scale" value={component.properties.scale} onChange={v => onUpdate({ scale: v })} />
|
|
@@ -29,21 +75,28 @@ export function Vector3Input({ label, value, onChange }: { label: string, value:
|
|
|
29
75
|
onChange(newValue);
|
|
30
76
|
};
|
|
31
77
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
78
|
+
const axes = [
|
|
79
|
+
{ key: 'x', color: 'red', index: 0 },
|
|
80
|
+
{ key: 'y', color: 'green', index: 1 },
|
|
81
|
+
{ key: 'z', color: 'blue', index: 2 }
|
|
82
|
+
] as const;
|
|
83
|
+
|
|
84
|
+
return <div className="mb-2">
|
|
85
|
+
<label className="block text-[9px] text-cyan-400/60 uppercase tracking-wider mb-1">{label}</label>
|
|
86
|
+
<div className="flex gap-1">
|
|
87
|
+
{axes.map(({ key, color, index }) => (
|
|
88
|
+
<div key={key} className="flex-1 flex items-center gap-1 bg-black/30 border border-cyan-500/20 rounded px-1.5 py-1 min-h-[32px]">
|
|
89
|
+
<span className={`text-xs font-bold text-${color}-400 w-3`}>{key.toUpperCase()}</span>
|
|
90
|
+
<input
|
|
91
|
+
className="flex-1 bg-transparent text-xs text-cyan-200 font-mono outline-none w-full min-w-0"
|
|
92
|
+
type="number"
|
|
93
|
+
step="0.1"
|
|
94
|
+
value={value[index].toFixed(2)}
|
|
95
|
+
onChange={e => handleChange(index, e.target.value)}
|
|
96
|
+
onFocus={e => e.target.select()}
|
|
97
|
+
/>
|
|
98
|
+
</div>
|
|
99
|
+
))}
|
|
47
100
|
</div>
|
|
48
101
|
</div>
|
|
49
102
|
}
|