react-three-game 0.0.57 → 0.0.58
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 +16 -3
- package/dist/tools/prefabeditor/EditorTree.js +24 -48
- package/dist/tools/prefabeditor/EditorTreeMenus.d.ts +33 -0
- package/dist/tools/prefabeditor/EditorTreeMenus.js +136 -0
- package/dist/tools/prefabeditor/components/CameraComponent.js +32 -12
- package/dist/tools/prefabeditor/components/DirectionalLightComponent.js +49 -23
- package/dist/tools/prefabeditor/components/MaterialComponent.d.ts +8 -0
- package/dist/tools/prefabeditor/components/MaterialComponent.js +11 -5
- package/dist/tools/prefabeditor/components/SpotLightComponent.js +34 -13
- package/package.json +1 -1
- package/react-three-game-skill/react-three-game/SKILL.md +4 -1
- package/src/tools/prefabeditor/EditorTree.tsx +56 -125
- package/src/tools/prefabeditor/EditorTreeMenus.tsx +307 -0
- package/src/tools/prefabeditor/components/CameraComponent.tsx +51 -14
- package/src/tools/prefabeditor/components/DirectionalLightComponent.tsx +59 -28
- package/src/tools/prefabeditor/components/MaterialComponent.tsx +18 -9
- package/src/tools/prefabeditor/components/SpotLightComponent.tsx +49 -18
|
@@ -1,33 +1,54 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
-
import { useRef, useEffect } from "react";
|
|
2
|
+
import { useRef, useEffect, useMemo, useState } from "react";
|
|
3
3
|
import { BooleanField, ColorField, FieldGroup, NumberField } from "./Input";
|
|
4
|
-
import { useHelper } from "@react-three/drei";
|
|
5
4
|
import { SpotLightHelper } from "three";
|
|
5
|
+
import { useFrame } from "@react-three/fiber";
|
|
6
|
+
const spotLightDefaults = {
|
|
7
|
+
color: '#ffffff',
|
|
8
|
+
intensity: 1,
|
|
9
|
+
angle: Math.PI / 6,
|
|
10
|
+
penumbra: 0.5,
|
|
11
|
+
distance: 100,
|
|
12
|
+
castShadow: true,
|
|
13
|
+
};
|
|
6
14
|
function SpotLightComponentEditor({ component, onUpdate }) {
|
|
7
|
-
|
|
15
|
+
const values = Object.assign(Object.assign({}, spotLightDefaults), component.properties);
|
|
16
|
+
return (_jsxs(FieldGroup, { children: [_jsx(ColorField, { name: "color", label: "Color", values: values, onChange: onUpdate }), _jsx(NumberField, { name: "intensity", label: "Intensity", values: values, onChange: onUpdate, min: 0, step: 0.1, fallback: 1 }), _jsx(NumberField, { name: "angle", label: "Angle", values: values, onChange: onUpdate, min: 0, max: Math.PI, step: 0.05, fallback: Math.PI / 6 }), _jsx(NumberField, { name: "penumbra", label: "Penumbra", values: values, onChange: onUpdate, min: 0, max: 1, step: 0.05, fallback: 0.5 }), _jsx(NumberField, { name: "distance", label: "Distance", values: values, onChange: onUpdate, min: 0, step: 1, fallback: 100 }), _jsx(BooleanField, { name: "castShadow", label: "Cast Shadow", values: values, onChange: onUpdate, fallback: true })] }));
|
|
8
17
|
}
|
|
9
18
|
function SpotLightView({ properties, editMode, isSelected }) {
|
|
10
|
-
|
|
11
|
-
const color =
|
|
12
|
-
const intensity =
|
|
13
|
-
const angle =
|
|
14
|
-
const penumbra =
|
|
15
|
-
const distance =
|
|
16
|
-
const castShadow =
|
|
19
|
+
const merged = Object.assign(Object.assign({}, spotLightDefaults), properties);
|
|
20
|
+
const color = merged.color;
|
|
21
|
+
const intensity = merged.intensity;
|
|
22
|
+
const angle = merged.angle;
|
|
23
|
+
const penumbra = merged.penumbra;
|
|
24
|
+
const distance = merged.distance;
|
|
25
|
+
const castShadow = merged.castShadow;
|
|
17
26
|
const spotLightRef = useRef(null);
|
|
18
27
|
const targetRef = useRef(null);
|
|
19
|
-
|
|
28
|
+
const [spotLight, setSpotLight] = useState(null);
|
|
29
|
+
const spotLightHelper = useMemo(() => spotLight ? new SpotLightHelper(spotLight, color) : null, [spotLight, color]);
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
return () => {
|
|
32
|
+
spotLightHelper === null || spotLightHelper === void 0 ? void 0 : spotLightHelper.dispose();
|
|
33
|
+
};
|
|
34
|
+
}, [spotLightHelper]);
|
|
20
35
|
useEffect(() => {
|
|
21
36
|
if (spotLightRef.current && targetRef.current) {
|
|
22
37
|
spotLightRef.current.target = targetRef.current;
|
|
38
|
+
setSpotLight(spotLightRef.current);
|
|
23
39
|
}
|
|
24
40
|
}, []);
|
|
25
|
-
|
|
41
|
+
useFrame(() => {
|
|
42
|
+
if (spotLightHelper && editMode && isSelected) {
|
|
43
|
+
spotLightHelper.update();
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
return (_jsxs(_Fragment, { children: [_jsx("spotLight", { ref: spotLightRef, color: color, intensity: intensity, angle: angle, penumbra: penumbra, distance: distance, castShadow: castShadow, "shadow-mapSize-width": 1024, "shadow-mapSize-height": 1024, "shadow-bias": -0.0001, "shadow-normalBias": 0.02 }), editMode && isSelected && spotLightHelper && (_jsx("primitive", { object: spotLightHelper })), _jsx("object3D", { ref: targetRef, position: [0, -5, 0] }), editMode && (_jsxs(_Fragment, { children: [_jsxs("mesh", { children: [_jsx("sphereGeometry", { args: [0.2, 8, 6] }), _jsx("meshBasicMaterial", { color: color, wireframe: true })] }), _jsxs("mesh", { position: [0, -5, 0], children: [_jsx("sphereGeometry", { args: [0.15, 8, 6] }), _jsx("meshBasicMaterial", { color: color, wireframe: true, opacity: 0.5, transparent: true })] })] }))] }));
|
|
26
47
|
}
|
|
27
48
|
const SpotLightComponent = {
|
|
28
49
|
name: 'SpotLight',
|
|
29
50
|
Editor: SpotLightComponentEditor,
|
|
30
51
|
View: SpotLightView,
|
|
31
|
-
defaultProperties:
|
|
52
|
+
defaultProperties: spotLightDefaults
|
|
32
53
|
};
|
|
33
54
|
export default SpotLightComponent;
|
package/package.json
CHANGED
|
@@ -81,12 +81,15 @@ Every game object follows this schema:
|
|
|
81
81
|
```typescript
|
|
82
82
|
interface GameObject {
|
|
83
83
|
id: string;
|
|
84
|
+
name?: string;
|
|
84
85
|
disabled?: boolean;
|
|
85
86
|
components?: Record<string, { type: string; properties: any }>;
|
|
86
87
|
children?: GameObject[];
|
|
87
88
|
}
|
|
88
89
|
```
|
|
89
90
|
|
|
91
|
+
`disabled` is the canonical visibility toggle. Transforms are local to the parent node.
|
|
92
|
+
|
|
90
93
|
### Prefab JSON Format
|
|
91
94
|
|
|
92
95
|
Scenes are defined as JSON prefabs with a root node containing children:
|
|
@@ -178,7 +181,7 @@ import { GameCanvas, PrefabRoot } from 'react-three-game';
|
|
|
178
181
|
</GameCanvas>
|
|
179
182
|
```
|
|
180
183
|
|
|
181
|
-
**PrefabEditor**: Managed scene with editor UI and play/pause controls for physics. Full authoring tool for level design and prototyping. Includes canvas, physics, transform gizmos, and inspector. Physics only runs in play mode. Can pass R3F components as children.
|
|
184
|
+
**PrefabEditor**: Managed scene with editor UI and play/pause controls for physics. Full authoring tool for level design and prototyping. Includes canvas, physics, transform gizmos, and inspector. Physics only runs in play mode. Can pass R3F components as children. Editor actions live under `Menu > File`, and exports under `Menu > Export`.
|
|
182
185
|
|
|
183
186
|
```jsx
|
|
184
187
|
import { PrefabEditor } from 'react-three-game';
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { Dispatch, SetStateAction, useState, MouseEvent } from 'react';
|
|
2
2
|
import { Prefab, GameObject } from "./types";
|
|
3
3
|
import { getComponent } from './components/ComponentRegistry';
|
|
4
|
-
import { base, colors, tree
|
|
5
|
-
import { findNode, findParent, deleteNode, cloneNode, updateNodeById
|
|
4
|
+
import { base, colors, tree } from './styles';
|
|
5
|
+
import { findNode, findParent, deleteNode, cloneNode, updateNodeById } from './utils';
|
|
6
6
|
import { useEditorContext } from './EditorContext';
|
|
7
7
|
import { Dropdown } from './Dropdown';
|
|
8
|
+
import { FileMenu, MenuTriggerButton, TreeContextMenu, TreeContextMenuState, TreeNodeMenu } from './EditorTreeMenus';
|
|
8
9
|
|
|
9
10
|
type DropPosition = 'before' | 'inside';
|
|
10
11
|
|
|
@@ -100,6 +101,7 @@ export default function EditorTree({
|
|
|
100
101
|
const [collapsedIds, setCollapsedIds] = useState<Set<string>>(new Set());
|
|
101
102
|
const [collapsed, setCollapsed] = useState(false);
|
|
102
103
|
const [searchQuery, setSearchQuery] = useState('');
|
|
104
|
+
const [contextMenu, setContextMenu] = useState<TreeContextMenuState>(null);
|
|
103
105
|
|
|
104
106
|
if (!prefabData || !setPrefabData) return null;
|
|
105
107
|
|
|
@@ -165,6 +167,30 @@ export default function EditorTree({
|
|
|
165
167
|
}));
|
|
166
168
|
};
|
|
167
169
|
|
|
170
|
+
const closeContextMenu = () => setContextMenu(null);
|
|
171
|
+
|
|
172
|
+
const openContextMenu = (nodeId: string, x: number, y: number) => {
|
|
173
|
+
setSelectedId(nodeId);
|
|
174
|
+
setContextMenu({ nodeId, x, y });
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const handleFocus = (nodeId: string) => {
|
|
178
|
+
setSelectedId(nodeId);
|
|
179
|
+
onFocusNode?.(nodeId);
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const renderTreeNodeMenu = (nodeId: string, isRoot: boolean, onClose: () => void) => (
|
|
183
|
+
<TreeNodeMenu
|
|
184
|
+
isRoot={isRoot}
|
|
185
|
+
nodeId={nodeId}
|
|
186
|
+
onAddChild={handleAddChild}
|
|
187
|
+
onFocus={handleFocus}
|
|
188
|
+
onDuplicate={isRoot ? undefined : handleDuplicate}
|
|
189
|
+
onDelete={isRoot ? undefined : handleDelete}
|
|
190
|
+
onClose={onClose}
|
|
191
|
+
/>
|
|
192
|
+
);
|
|
193
|
+
|
|
168
194
|
const handleDragStart = (e: React.DragEvent, id: string) => {
|
|
169
195
|
if (id === prefabData.root.id) return e.preventDefault();
|
|
170
196
|
e.dataTransfer.effectAllowed = "move";
|
|
@@ -240,6 +266,11 @@ export default function EditorTree({
|
|
|
240
266
|
}}
|
|
241
267
|
draggable={!isRoot}
|
|
242
268
|
onClick={(e) => { e.stopPropagation(); setSelectedId(node.id); }}
|
|
269
|
+
onContextMenu={(e) => {
|
|
270
|
+
e.preventDefault();
|
|
271
|
+
e.stopPropagation();
|
|
272
|
+
openContextMenu(node.id, e.clientX, e.clientY);
|
|
273
|
+
}}
|
|
243
274
|
onDragStart={(e) => handleDragStart(e, node.id)}
|
|
244
275
|
onDragEnd={() => { setDraggedId(null); setDropTarget(null); }}
|
|
245
276
|
onDragOver={(e) => handleDragOver(e, node.id, isRoot)}
|
|
@@ -269,8 +300,10 @@ export default function EditorTree({
|
|
|
269
300
|
<Dropdown
|
|
270
301
|
placement="bottom-end"
|
|
271
302
|
trigger={({ ref, toggle }) => (
|
|
272
|
-
<
|
|
273
|
-
|
|
303
|
+
<MenuTriggerButton
|
|
304
|
+
buttonRef={ref}
|
|
305
|
+
onToggle={toggle}
|
|
306
|
+
title="Node Actions"
|
|
274
307
|
style={{
|
|
275
308
|
background: 'none',
|
|
276
309
|
border: 'none',
|
|
@@ -280,32 +313,12 @@ export default function EditorTree({
|
|
|
280
313
|
opacity: 0.7,
|
|
281
314
|
color: 'inherit',
|
|
282
315
|
}}
|
|
283
|
-
onClick={(e) => {
|
|
284
|
-
e.stopPropagation();
|
|
285
|
-
toggle();
|
|
286
|
-
}}
|
|
287
|
-
title="Node Actions"
|
|
288
316
|
>
|
|
289
317
|
⋯
|
|
290
|
-
</
|
|
318
|
+
</MenuTriggerButton>
|
|
291
319
|
)}
|
|
292
320
|
>
|
|
293
|
-
{(close) => (
|
|
294
|
-
<div style={{ ...menu.container, position: 'static' }} onClick={(e) => e.stopPropagation()}>
|
|
295
|
-
<button style={menu.item} onClick={() => { handleAddChild(node.id); close(); }}>
|
|
296
|
-
Add Child
|
|
297
|
-
</button>
|
|
298
|
-
<button style={menu.item} onClick={() => { setSelectedId(node.id); onFocusNode?.(node.id); close(); }}>
|
|
299
|
-
Focus Camera
|
|
300
|
-
</button>
|
|
301
|
-
<button style={menu.item} onClick={() => { handleDuplicate(node.id); close(); }}>
|
|
302
|
-
Duplicate
|
|
303
|
-
</button>
|
|
304
|
-
<button style={{ ...menu.item, ...menu.danger }} onClick={() => { handleDelete(node.id); close(); }}>
|
|
305
|
-
Delete
|
|
306
|
-
</button>
|
|
307
|
-
</div>
|
|
308
|
-
)}
|
|
321
|
+
{(close) => renderTreeNodeMenu(node.id, false, close)}
|
|
309
322
|
</Dropdown>
|
|
310
323
|
<button
|
|
311
324
|
style={{
|
|
@@ -331,8 +344,10 @@ export default function EditorTree({
|
|
|
331
344
|
<Dropdown
|
|
332
345
|
placement="bottom-end"
|
|
333
346
|
trigger={({ ref, toggle }) => (
|
|
334
|
-
<
|
|
335
|
-
|
|
347
|
+
<MenuTriggerButton
|
|
348
|
+
buttonRef={ref}
|
|
349
|
+
onToggle={toggle}
|
|
350
|
+
title="Scene Actions"
|
|
336
351
|
style={{
|
|
337
352
|
background: 'none',
|
|
338
353
|
border: 'none',
|
|
@@ -342,26 +357,12 @@ export default function EditorTree({
|
|
|
342
357
|
opacity: 0.7,
|
|
343
358
|
color: 'inherit',
|
|
344
359
|
}}
|
|
345
|
-
onClick={(e) => {
|
|
346
|
-
e.stopPropagation();
|
|
347
|
-
toggle();
|
|
348
|
-
}}
|
|
349
|
-
title="Scene Actions"
|
|
350
360
|
>
|
|
351
361
|
⋯
|
|
352
|
-
</
|
|
362
|
+
</MenuTriggerButton>
|
|
353
363
|
)}
|
|
354
364
|
>
|
|
355
|
-
{(close) => (
|
|
356
|
-
<div style={{ ...menu.container, position: 'static' }} onClick={(e) => e.stopPropagation()}>
|
|
357
|
-
<button style={menu.item} onClick={() => { handleAddChild(node.id); close(); }}>
|
|
358
|
-
Add Child
|
|
359
|
-
</button>
|
|
360
|
-
<button style={menu.item} onClick={() => { setSelectedId(node.id); onFocusNode?.(node.id); close(); }}>
|
|
361
|
-
Focus Camera
|
|
362
|
-
</button>
|
|
363
|
-
</div>
|
|
364
|
-
)}
|
|
365
|
+
{(close) => renderTreeNodeMenu(node.id, true, close)}
|
|
365
366
|
</Dropdown>
|
|
366
367
|
)}
|
|
367
368
|
</div>
|
|
@@ -399,14 +400,14 @@ export default function EditorTree({
|
|
|
399
400
|
<Dropdown
|
|
400
401
|
placement="bottom-end"
|
|
401
402
|
trigger={({ ref, toggle }) => (
|
|
402
|
-
<
|
|
403
|
-
|
|
403
|
+
<MenuTriggerButton
|
|
404
|
+
buttonRef={ref}
|
|
405
|
+
onToggle={toggle}
|
|
406
|
+
title="Menu"
|
|
404
407
|
style={{ ...base.btn, padding: '2px 6px', fontSize: 10 }}
|
|
405
|
-
onClick={(e) => { e.stopPropagation(); toggle(); }}
|
|
406
|
-
title="File"
|
|
407
408
|
>
|
|
408
409
|
⋮
|
|
409
|
-
</
|
|
410
|
+
</MenuTriggerButton>
|
|
410
411
|
)}
|
|
411
412
|
>
|
|
412
413
|
{(close) => (
|
|
@@ -439,83 +440,13 @@ export default function EditorTree({
|
|
|
439
440
|
</>
|
|
440
441
|
)}
|
|
441
442
|
</div>
|
|
443
|
+
<TreeContextMenu
|
|
444
|
+
contextMenu={contextMenu}
|
|
445
|
+
onClose={closeContextMenu}
|
|
446
|
+
>
|
|
447
|
+
{(nodeId, close) => renderTreeNodeMenu(nodeId, nodeId === prefabData.root.id, close)}
|
|
448
|
+
</TreeContextMenu>
|
|
442
449
|
|
|
443
450
|
</>
|
|
444
451
|
);
|
|
445
452
|
}
|
|
446
|
-
|
|
447
|
-
function FileMenu({
|
|
448
|
-
prefabData,
|
|
449
|
-
setPrefabData,
|
|
450
|
-
onClose
|
|
451
|
-
}: {
|
|
452
|
-
prefabData: Prefab;
|
|
453
|
-
setPrefabData: Dispatch<SetStateAction<Prefab>>;
|
|
454
|
-
onClose: () => void;
|
|
455
|
-
}) {
|
|
456
|
-
const { onScreenshot, onExportGLB } = useEditorContext();
|
|
457
|
-
|
|
458
|
-
const handleLoad = async () => {
|
|
459
|
-
const loadedPrefab = await loadJson();
|
|
460
|
-
if (!loadedPrefab) return;
|
|
461
|
-
setPrefabData(loadedPrefab);
|
|
462
|
-
onClose();
|
|
463
|
-
};
|
|
464
|
-
|
|
465
|
-
const handleSave = () => {
|
|
466
|
-
saveJson(prefabData, "prefab");
|
|
467
|
-
onClose();
|
|
468
|
-
};
|
|
469
|
-
|
|
470
|
-
const handleLoadIntoScene = async () => {
|
|
471
|
-
const loadedPrefab = await loadJson();
|
|
472
|
-
if (!loadedPrefab) return;
|
|
473
|
-
|
|
474
|
-
setPrefabData(prev => ({
|
|
475
|
-
...prev,
|
|
476
|
-
root: updateNodeById(prev.root, prev.root.id, root => ({
|
|
477
|
-
...root,
|
|
478
|
-
children: [...(root.children ?? []), regenerateIds(loadedPrefab.root)]
|
|
479
|
-
}))
|
|
480
|
-
}));
|
|
481
|
-
onClose();
|
|
482
|
-
};
|
|
483
|
-
|
|
484
|
-
return (
|
|
485
|
-
<div
|
|
486
|
-
style={{ ...menu.container, position: 'static' }}
|
|
487
|
-
onClick={(e) => e.stopPropagation()}
|
|
488
|
-
>
|
|
489
|
-
<button
|
|
490
|
-
style={menu.item}
|
|
491
|
-
onClick={handleLoad}
|
|
492
|
-
>
|
|
493
|
-
📥 Load Prefab JSON
|
|
494
|
-
</button>
|
|
495
|
-
<button
|
|
496
|
-
style={menu.item}
|
|
497
|
-
onClick={handleSave}
|
|
498
|
-
>
|
|
499
|
-
💾 Save Prefab JSON
|
|
500
|
-
</button>
|
|
501
|
-
<button
|
|
502
|
-
style={menu.item}
|
|
503
|
-
onClick={handleLoadIntoScene}
|
|
504
|
-
>
|
|
505
|
-
📂 Load into Scene
|
|
506
|
-
</button>
|
|
507
|
-
<button
|
|
508
|
-
style={menu.item}
|
|
509
|
-
onClick={() => { onScreenshot?.(); onClose(); }}
|
|
510
|
-
>
|
|
511
|
-
📸 Screenshot
|
|
512
|
-
</button>
|
|
513
|
-
<button
|
|
514
|
-
style={menu.item}
|
|
515
|
-
onClick={() => { onExportGLB?.(); onClose(); }}
|
|
516
|
-
>
|
|
517
|
-
📦 Export GLB
|
|
518
|
-
</button>
|
|
519
|
-
</div>
|
|
520
|
-
);
|
|
521
|
-
}
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import { Dispatch, SetStateAction, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { createPortal } from 'react-dom';
|
|
3
|
+
import { Prefab } from './types';
|
|
4
|
+
import { menu } from './styles';
|
|
5
|
+
import { useEditorContext } from './EditorContext';
|
|
6
|
+
import { getComponent } from './components/ComponentRegistry';
|
|
7
|
+
import { loadJson, saveJson, regenerateIds, updateNodeById } from './utils';
|
|
8
|
+
|
|
9
|
+
export type TreeContextMenuState = { nodeId: string; x: number; y: number } | null;
|
|
10
|
+
|
|
11
|
+
function createEmptyPrefab(): Prefab {
|
|
12
|
+
return {
|
|
13
|
+
id: crypto.randomUUID(),
|
|
14
|
+
name: 'New Scene',
|
|
15
|
+
root: {
|
|
16
|
+
id: crypto.randomUUID(),
|
|
17
|
+
name: 'Scene',
|
|
18
|
+
components: {
|
|
19
|
+
transform: {
|
|
20
|
+
type: 'Transform',
|
|
21
|
+
properties: { ...getComponent('Transform')?.defaultProperties }
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
children: []
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function MenuPanel({
|
|
30
|
+
children,
|
|
31
|
+
style,
|
|
32
|
+
}: {
|
|
33
|
+
children: React.ReactNode;
|
|
34
|
+
style?: React.CSSProperties;
|
|
35
|
+
}) {
|
|
36
|
+
return (
|
|
37
|
+
<div style={{ ...menu.container, position: 'static', ...style }} onClick={(e) => e.stopPropagation()}>
|
|
38
|
+
{children}
|
|
39
|
+
</div>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function MenuItemButton({
|
|
44
|
+
children,
|
|
45
|
+
onClick,
|
|
46
|
+
danger = false,
|
|
47
|
+
style,
|
|
48
|
+
}: {
|
|
49
|
+
children: React.ReactNode;
|
|
50
|
+
onClick: () => void;
|
|
51
|
+
danger?: boolean;
|
|
52
|
+
style?: React.CSSProperties;
|
|
53
|
+
}) {
|
|
54
|
+
return (
|
|
55
|
+
<button
|
|
56
|
+
style={danger ? { ...menu.item, ...menu.danger, ...style } : { ...menu.item, ...style }}
|
|
57
|
+
onClick={onClick}
|
|
58
|
+
>
|
|
59
|
+
{children}
|
|
60
|
+
</button>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function MenuSubmenu({
|
|
65
|
+
label,
|
|
66
|
+
children,
|
|
67
|
+
}: {
|
|
68
|
+
label: string;
|
|
69
|
+
children: React.ReactNode;
|
|
70
|
+
}) {
|
|
71
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<div
|
|
75
|
+
style={{ position: 'relative' }}
|
|
76
|
+
onMouseEnter={() => setIsOpen(true)}
|
|
77
|
+
onMouseLeave={() => setIsOpen(false)}
|
|
78
|
+
>
|
|
79
|
+
<MenuItemButton
|
|
80
|
+
onClick={() => setIsOpen(open => !open)}
|
|
81
|
+
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}
|
|
82
|
+
>
|
|
83
|
+
<span>{label}</span>
|
|
84
|
+
<span aria-hidden="true">›</span>
|
|
85
|
+
</MenuItemButton>
|
|
86
|
+
{isOpen && (
|
|
87
|
+
<div
|
|
88
|
+
style={{
|
|
89
|
+
position: 'absolute',
|
|
90
|
+
top: 0,
|
|
91
|
+
left: '100%',
|
|
92
|
+
zIndex: 1,
|
|
93
|
+
}}
|
|
94
|
+
>
|
|
95
|
+
<MenuPanel>{children}</MenuPanel>
|
|
96
|
+
</div>
|
|
97
|
+
)}
|
|
98
|
+
</div>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function MenuTriggerButton({
|
|
103
|
+
buttonRef,
|
|
104
|
+
onToggle,
|
|
105
|
+
title,
|
|
106
|
+
style,
|
|
107
|
+
children,
|
|
108
|
+
}: {
|
|
109
|
+
buttonRef: React.RefObject<HTMLButtonElement | null>;
|
|
110
|
+
onToggle: () => void;
|
|
111
|
+
title: string;
|
|
112
|
+
style: React.CSSProperties;
|
|
113
|
+
children: React.ReactNode;
|
|
114
|
+
}) {
|
|
115
|
+
return (
|
|
116
|
+
<button
|
|
117
|
+
ref={buttonRef}
|
|
118
|
+
style={style}
|
|
119
|
+
onClick={(e) => {
|
|
120
|
+
e.stopPropagation();
|
|
121
|
+
onToggle();
|
|
122
|
+
}}
|
|
123
|
+
title={title}
|
|
124
|
+
>
|
|
125
|
+
{children}
|
|
126
|
+
</button>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function TreeNodeMenu({
|
|
131
|
+
isRoot,
|
|
132
|
+
nodeId,
|
|
133
|
+
onAddChild,
|
|
134
|
+
onFocus,
|
|
135
|
+
onDuplicate,
|
|
136
|
+
onDelete,
|
|
137
|
+
onClose,
|
|
138
|
+
}: {
|
|
139
|
+
isRoot: boolean;
|
|
140
|
+
nodeId: string;
|
|
141
|
+
onAddChild: (parentId: string) => void;
|
|
142
|
+
onFocus: (nodeId: string) => void;
|
|
143
|
+
onDuplicate?: (nodeId: string) => void;
|
|
144
|
+
onDelete?: (nodeId: string) => void;
|
|
145
|
+
onClose: () => void;
|
|
146
|
+
}) {
|
|
147
|
+
return (
|
|
148
|
+
<MenuPanel>
|
|
149
|
+
<MenuItemButton onClick={() => { onAddChild(nodeId); onClose(); }}>
|
|
150
|
+
Add Child
|
|
151
|
+
</MenuItemButton>
|
|
152
|
+
<MenuItemButton onClick={() => { onFocus(nodeId); onClose(); }}>
|
|
153
|
+
Focus Camera
|
|
154
|
+
</MenuItemButton>
|
|
155
|
+
{!isRoot && onDuplicate && (
|
|
156
|
+
<MenuItemButton onClick={() => { onDuplicate(nodeId); onClose(); }}>
|
|
157
|
+
Duplicate
|
|
158
|
+
</MenuItemButton>
|
|
159
|
+
)}
|
|
160
|
+
{!isRoot && onDelete && (
|
|
161
|
+
<MenuItemButton danger onClick={() => { onDelete(nodeId); onClose(); }}>
|
|
162
|
+
Delete
|
|
163
|
+
</MenuItemButton>
|
|
164
|
+
)}
|
|
165
|
+
</MenuPanel>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function TreeContextMenu({
|
|
170
|
+
contextMenu,
|
|
171
|
+
onClose,
|
|
172
|
+
children,
|
|
173
|
+
}: {
|
|
174
|
+
contextMenu: TreeContextMenuState;
|
|
175
|
+
onClose: () => void;
|
|
176
|
+
children: (nodeId: string, onClose: () => void) => React.ReactNode;
|
|
177
|
+
}) {
|
|
178
|
+
const panelRef = useRef<HTMLDivElement>(null);
|
|
179
|
+
const [position, setPosition] = useState<{ left: number; top: number } | null>(null);
|
|
180
|
+
|
|
181
|
+
useEffect(() => {
|
|
182
|
+
if (!contextMenu) return;
|
|
183
|
+
|
|
184
|
+
const handlePointerDown = (event: PointerEvent) => {
|
|
185
|
+
const target = event.target as Node | null;
|
|
186
|
+
if (!target) return;
|
|
187
|
+
if (panelRef.current?.contains(target)) return;
|
|
188
|
+
onClose();
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const handleKeyDown = (event: KeyboardEvent) => {
|
|
192
|
+
if (event.key === 'Escape') onClose();
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
document.addEventListener('pointerdown', handlePointerDown);
|
|
196
|
+
document.addEventListener('keydown', handleKeyDown);
|
|
197
|
+
|
|
198
|
+
return () => {
|
|
199
|
+
document.removeEventListener('pointerdown', handlePointerDown);
|
|
200
|
+
document.removeEventListener('keydown', handleKeyDown);
|
|
201
|
+
};
|
|
202
|
+
}, [contextMenu, onClose]);
|
|
203
|
+
|
|
204
|
+
useEffect(() => {
|
|
205
|
+
if (!contextMenu || !panelRef.current || typeof window === 'undefined') return;
|
|
206
|
+
|
|
207
|
+
const panelRect = panelRef.current.getBoundingClientRect();
|
|
208
|
+
const left = Math.max(8, Math.min(contextMenu.x, window.innerWidth - panelRect.width - 8));
|
|
209
|
+
const top = Math.max(8, Math.min(contextMenu.y, window.innerHeight - panelRect.height - 8));
|
|
210
|
+
setPosition({ left, top });
|
|
211
|
+
}, [contextMenu]);
|
|
212
|
+
|
|
213
|
+
useEffect(() => {
|
|
214
|
+
if (!contextMenu) {
|
|
215
|
+
setPosition(null);
|
|
216
|
+
}
|
|
217
|
+
}, [contextMenu]);
|
|
218
|
+
|
|
219
|
+
if (!contextMenu || typeof document === 'undefined') return null;
|
|
220
|
+
|
|
221
|
+
return createPortal(
|
|
222
|
+
<div
|
|
223
|
+
ref={panelRef}
|
|
224
|
+
style={{
|
|
225
|
+
position: 'fixed',
|
|
226
|
+
left: position?.left ?? contextMenu.x,
|
|
227
|
+
top: position?.top ?? contextMenu.y,
|
|
228
|
+
zIndex: 1000,
|
|
229
|
+
}}
|
|
230
|
+
onMouseLeave={onClose}
|
|
231
|
+
onContextMenu={(e) => e.preventDefault()}
|
|
232
|
+
>
|
|
233
|
+
{children(contextMenu.nodeId, onClose)}
|
|
234
|
+
</div>,
|
|
235
|
+
document.body
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function FileMenu({
|
|
240
|
+
prefabData,
|
|
241
|
+
setPrefabData,
|
|
242
|
+
onClose
|
|
243
|
+
}: {
|
|
244
|
+
prefabData: Prefab;
|
|
245
|
+
setPrefabData: Dispatch<SetStateAction<Prefab>>;
|
|
246
|
+
onClose: () => void;
|
|
247
|
+
}) {
|
|
248
|
+
const { onScreenshot, onExportGLB } = useEditorContext();
|
|
249
|
+
|
|
250
|
+
const handleNewScene = () => {
|
|
251
|
+
setPrefabData(createEmptyPrefab());
|
|
252
|
+
onClose();
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
const handleNewSceneFromPrefab = async () => {
|
|
256
|
+
const loadedPrefab = await loadJson();
|
|
257
|
+
if (!loadedPrefab) return;
|
|
258
|
+
setPrefabData(loadedPrefab);
|
|
259
|
+
onClose();
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
const handleSave = () => {
|
|
263
|
+
saveJson(prefabData, 'prefab');
|
|
264
|
+
onClose();
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
const handleLoadIntoScene = async () => {
|
|
268
|
+
const loadedPrefab = await loadJson();
|
|
269
|
+
if (!loadedPrefab) return;
|
|
270
|
+
|
|
271
|
+
setPrefabData(prev => ({
|
|
272
|
+
...prev,
|
|
273
|
+
root: updateNodeById(prev.root, prev.root.id, root => ({
|
|
274
|
+
...root,
|
|
275
|
+
children: [...(root.children ?? []), regenerateIds(loadedPrefab.root)]
|
|
276
|
+
}))
|
|
277
|
+
}));
|
|
278
|
+
onClose();
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
return (
|
|
282
|
+
<MenuPanel style={{ overflow: 'visible' }}>
|
|
283
|
+
<MenuSubmenu label="File">
|
|
284
|
+
<MenuItemButton onClick={handleNewScene}>
|
|
285
|
+
New Scene
|
|
286
|
+
</MenuItemButton>
|
|
287
|
+
<MenuItemButton onClick={handleNewSceneFromPrefab}>
|
|
288
|
+
New Scene from Prefab
|
|
289
|
+
</MenuItemButton>
|
|
290
|
+
<MenuItemButton onClick={handleLoadIntoScene}>
|
|
291
|
+
Load Prefab into Scene
|
|
292
|
+
</MenuItemButton>
|
|
293
|
+
<MenuItemButton onClick={handleSave}>
|
|
294
|
+
Save Prefab
|
|
295
|
+
</MenuItemButton>
|
|
296
|
+
</MenuSubmenu>
|
|
297
|
+
<MenuSubmenu label="Export">
|
|
298
|
+
<MenuItemButton onClick={() => { onExportGLB?.(); onClose(); }}>
|
|
299
|
+
GLB
|
|
300
|
+
</MenuItemButton>
|
|
301
|
+
<MenuItemButton onClick={() => { onScreenshot?.(); onClose(); }}>
|
|
302
|
+
PNG
|
|
303
|
+
</MenuItemButton>
|
|
304
|
+
</MenuSubmenu>
|
|
305
|
+
</MenuPanel>
|
|
306
|
+
);
|
|
307
|
+
}
|