react-three-game 0.0.56 → 0.0.57

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.
Files changed (59) hide show
  1. package/dist/index.d.ts +1 -1
  2. package/dist/index.js +1 -1
  3. package/dist/shared/GameCanvas.js +1 -3
  4. package/dist/tools/assetviewer/page.js +35 -14
  5. package/dist/tools/prefabeditor/Dropdown.d.ts +15 -0
  6. package/dist/tools/prefabeditor/Dropdown.js +82 -0
  7. package/dist/tools/prefabeditor/EditorContext.d.ts +5 -0
  8. package/dist/tools/prefabeditor/EditorTree.js +138 -56
  9. package/dist/tools/prefabeditor/EditorUI.js +1 -1
  10. package/dist/tools/prefabeditor/PrefabEditor.d.ts +1 -0
  11. package/dist/tools/prefabeditor/PrefabEditor.js +13 -2
  12. package/dist/tools/prefabeditor/PrefabRoot.d.ts +1 -0
  13. package/dist/tools/prefabeditor/PrefabRoot.js +120 -34
  14. package/dist/tools/prefabeditor/components/AmbientLightComponent.js +3 -7
  15. package/dist/tools/prefabeditor/components/CameraComponent.d.ts +3 -0
  16. package/dist/tools/prefabeditor/components/CameraComponent.js +25 -0
  17. package/dist/tools/prefabeditor/components/DirectionalLightComponent.js +2 -2
  18. package/dist/tools/prefabeditor/components/EnvironmentComponent.d.ts +3 -0
  19. package/dist/tools/prefabeditor/components/EnvironmentComponent.js +15 -0
  20. package/dist/tools/prefabeditor/components/GeometryComponent.js +46 -46
  21. package/dist/tools/prefabeditor/components/Input.d.ts +51 -1
  22. package/dist/tools/prefabeditor/components/Input.js +73 -21
  23. package/dist/tools/prefabeditor/components/MaterialComponent.d.ts +8 -2
  24. package/dist/tools/prefabeditor/components/MaterialComponent.js +122 -14
  25. package/dist/tools/prefabeditor/components/ModelComponent.js +44 -3
  26. package/dist/tools/prefabeditor/components/PhysicsComponent.js +16 -81
  27. package/dist/tools/prefabeditor/components/SpotLightComponent.js +4 -12
  28. package/dist/tools/prefabeditor/components/TextComponent.js +7 -53
  29. package/dist/tools/prefabeditor/components/TransformComponent.js +18 -8
  30. package/dist/tools/prefabeditor/components/index.js +5 -1
  31. package/dist/tools/prefabeditor/styles.d.ts +5 -2
  32. package/dist/tools/prefabeditor/styles.js +7 -3
  33. package/dist/tools/prefabeditor/utils.d.ts +4 -3
  34. package/dist/tools/prefabeditor/utils.js +53 -5
  35. package/package.json +1 -1
  36. package/src/index.ts +7 -0
  37. package/src/shared/GameCanvas.tsx +0 -3
  38. package/src/tools/assetviewer/page.tsx +77 -45
  39. package/src/tools/prefabeditor/Dropdown.tsx +112 -0
  40. package/src/tools/prefabeditor/EditorContext.tsx +5 -0
  41. package/src/tools/prefabeditor/EditorTree.tsx +234 -101
  42. package/src/tools/prefabeditor/EditorUI.tsx +1 -1
  43. package/src/tools/prefabeditor/PrefabEditor.tsx +17 -4
  44. package/src/tools/prefabeditor/PrefabRoot.tsx +208 -58
  45. package/src/tools/prefabeditor/components/AmbientLightComponent.tsx +5 -11
  46. package/src/tools/prefabeditor/components/CameraComponent.tsx +80 -0
  47. package/src/tools/prefabeditor/components/DirectionalLightComponent.tsx +2 -2
  48. package/src/tools/prefabeditor/components/EnvironmentComponent.tsx +47 -0
  49. package/src/tools/prefabeditor/components/GeometryComponent.tsx +69 -63
  50. package/src/tools/prefabeditor/components/Input.tsx +220 -27
  51. package/src/tools/prefabeditor/components/MaterialComponent.tsx +178 -16
  52. package/src/tools/prefabeditor/components/ModelComponent.tsx +51 -4
  53. package/src/tools/prefabeditor/components/PhysicsComponent.tsx +44 -85
  54. package/src/tools/prefabeditor/components/SpotLightComponent.tsx +11 -17
  55. package/src/tools/prefabeditor/components/TextComponent.tsx +58 -57
  56. package/src/tools/prefabeditor/components/TransformComponent.tsx +61 -9
  57. package/src/tools/prefabeditor/components/index.ts +5 -1
  58. package/src/tools/prefabeditor/styles.ts +7 -3
  59. package/src/tools/prefabeditor/utils.ts +55 -4
@@ -0,0 +1,112 @@
1
+ import { ReactNode, useEffect, useLayoutEffect, useRef, useState } from 'react';
2
+ import { createPortal } from 'react-dom';
3
+
4
+ type Placement = 'bottom-start' | 'bottom-end' | 'left-start' | 'right-start';
5
+
6
+ export function Dropdown({
7
+ trigger,
8
+ children,
9
+ placement = 'bottom-end',
10
+ offset = 6,
11
+ zIndex = 1000,
12
+ }: {
13
+ trigger: (props: { ref: React.RefObject<HTMLButtonElement | null>; isOpen: boolean; toggle: () => void; close: () => void; }) => ReactNode;
14
+ children: ReactNode | ((close: () => void) => ReactNode);
15
+ placement?: Placement;
16
+ offset?: number;
17
+ zIndex?: number;
18
+ }) {
19
+ const [isOpen, setIsOpen] = useState(false);
20
+ const [position, setPosition] = useState<{ left: number; top: number } | null>(null);
21
+ const triggerRef = useRef<HTMLButtonElement>(null);
22
+ const panelRef = useRef<HTMLDivElement>(null);
23
+
24
+ const close = () => setIsOpen(false);
25
+ const toggle = () => setIsOpen(prev => !prev);
26
+
27
+ useLayoutEffect(() => {
28
+ if (!isOpen || !triggerRef.current || !panelRef.current || typeof window === 'undefined') return;
29
+
30
+ const updatePosition = () => {
31
+ const triggerRect = triggerRef.current?.getBoundingClientRect();
32
+ const panelRect = panelRef.current?.getBoundingClientRect();
33
+ if (!triggerRect || !panelRect) return;
34
+
35
+ let left = triggerRect.left;
36
+ let top = triggerRect.bottom + offset;
37
+
38
+ if (placement === 'bottom-end') {
39
+ left = triggerRect.right - panelRect.width;
40
+ top = triggerRect.bottom + offset;
41
+ } else if (placement === 'bottom-start') {
42
+ left = triggerRect.left;
43
+ top = triggerRect.bottom + offset;
44
+ } else if (placement === 'left-start') {
45
+ left = triggerRect.left - panelRect.width - offset;
46
+ top = triggerRect.top;
47
+ } else if (placement === 'right-start') {
48
+ left = triggerRect.right + offset;
49
+ top = triggerRect.top;
50
+ }
51
+
52
+ left = Math.max(8, Math.min(left, window.innerWidth - panelRect.width - 8));
53
+ top = Math.max(8, Math.min(top, window.innerHeight - panelRect.height - 8));
54
+
55
+ setPosition({ left, top });
56
+ };
57
+
58
+ updatePosition();
59
+ window.addEventListener('resize', updatePosition);
60
+ window.addEventListener('scroll', updatePosition, true);
61
+
62
+ return () => {
63
+ window.removeEventListener('resize', updatePosition);
64
+ window.removeEventListener('scroll', updatePosition, true);
65
+ };
66
+ }, [isOpen, placement, offset]);
67
+
68
+ useEffect(() => {
69
+ if (!isOpen) return;
70
+
71
+ const handlePointerDown = (event: PointerEvent) => {
72
+ const target = event.target as Node | null;
73
+ if (!target) return;
74
+ if (triggerRef.current?.contains(target)) return;
75
+ if (panelRef.current?.contains(target)) return;
76
+ close();
77
+ };
78
+
79
+ const handleKeyDown = (event: KeyboardEvent) => {
80
+ if (event.key === 'Escape') close();
81
+ };
82
+
83
+ document.addEventListener('pointerdown', handlePointerDown);
84
+ document.addEventListener('keydown', handleKeyDown);
85
+
86
+ return () => {
87
+ document.removeEventListener('pointerdown', handlePointerDown);
88
+ document.removeEventListener('keydown', handleKeyDown);
89
+ };
90
+ }, [isOpen]);
91
+
92
+ return (
93
+ <>
94
+ {trigger({ ref: triggerRef, isOpen, toggle, close })}
95
+ {isOpen && typeof document !== 'undefined' && createPortal(
96
+ <div
97
+ ref={panelRef}
98
+ onMouseLeave={close}
99
+ style={{
100
+ position: 'fixed',
101
+ left: position?.left ?? -9999,
102
+ top: position?.top ?? -9999,
103
+ zIndex,
104
+ }}
105
+ >
106
+ {typeof children === 'function' ? children(close) : children}
107
+ </div>,
108
+ document.body
109
+ )}
110
+ </>
111
+ );
112
+ }
@@ -5,6 +5,11 @@ interface EditorContextType {
5
5
  setTransformMode: (mode: "translate" | "rotate" | "scale") => void;
6
6
  snapResolution: number;
7
7
  setSnapResolution: (resolution: number) => void;
8
+ positionSnap: number;
9
+ setPositionSnap: (resolution: number) => void;
10
+ rotationSnap: number;
11
+ setRotationSnap: (resolution: number) => void;
12
+ onFocusNode?: (nodeId: string) => void;
8
13
  onScreenshot?: () => void;
9
14
  onExportGLB?: () => void;
10
15
  }
@@ -4,6 +4,76 @@ import { getComponent } from './components/ComponentRegistry';
4
4
  import { base, colors, tree, menu } from './styles';
5
5
  import { findNode, findParent, deleteNode, cloneNode, updateNodeById, loadJson, saveJson, regenerateIds } from './utils';
6
6
  import { useEditorContext } from './EditorContext';
7
+ import { Dropdown } from './Dropdown';
8
+
9
+ type DropPosition = 'before' | 'inside';
10
+
11
+ function moveNode(root: GameObject, draggedId: string, targetId: string, position: DropPosition): GameObject {
12
+ const draggedNode = findNode(root, draggedId);
13
+ const oldParent = findParent(root, draggedId);
14
+
15
+ if (!draggedNode || !oldParent || findNode(draggedNode, targetId)) {
16
+ return root;
17
+ }
18
+
19
+ if (position === 'before') {
20
+ const targetParent = findParent(root, targetId);
21
+ if (!targetParent?.children) return root;
22
+
23
+ if (targetParent.id === oldParent.id) {
24
+ const siblings = targetParent.children.filter(child => child.id !== draggedId);
25
+ const targetIndex = siblings.findIndex(child => child.id === targetId);
26
+ if (targetIndex === -1) return root;
27
+
28
+ siblings.splice(targetIndex, 0, draggedNode);
29
+ return updateNodeById(root, targetParent.id, parent => ({ ...parent, children: siblings }));
30
+ }
31
+
32
+ const rootWithoutDragged = updateNodeById(root, oldParent.id, parent => ({
33
+ ...parent,
34
+ children: (parent.children ?? []).filter(child => child.id !== draggedId)
35
+ }));
36
+
37
+ return updateNodeById(rootWithoutDragged, targetParent.id, parent => {
38
+ const children = [...(parent.children ?? [])];
39
+ const targetIndex = children.findIndex(child => child.id === targetId);
40
+ if (targetIndex === -1) return parent;
41
+
42
+ children.splice(targetIndex, 0, draggedNode);
43
+ return { ...parent, children };
44
+ });
45
+ }
46
+
47
+ const rootWithoutDragged = updateNodeById(root, oldParent.id, parent => ({
48
+ ...parent,
49
+ children: (parent.children ?? []).filter(child => child.id !== draggedId)
50
+ }));
51
+
52
+ return updateNodeById(rootWithoutDragged, targetId, target => ({
53
+ ...target,
54
+ children: [...(target.children ?? []), draggedNode]
55
+ }));
56
+ }
57
+
58
+ function duplicateNodeBelow(root: GameObject, nodeId: string): { root: GameObject; duplicatedId: string } | null {
59
+ const node = findNode(root, nodeId);
60
+ const parent = findParent(root, nodeId);
61
+ if (!node || !parent) return null;
62
+
63
+ const duplicate = cloneNode(node);
64
+ const nextRoot = updateNodeById(root, parent.id, currentParent => ({
65
+ ...currentParent,
66
+ children: (() => {
67
+ const children = [...(currentParent.children ?? [])];
68
+ const index = children.findIndex(child => child.id === nodeId);
69
+ if (index === -1) return [...children, duplicate];
70
+ children.splice(index + 1, 0, duplicate);
71
+ return children;
72
+ })()
73
+ }));
74
+
75
+ return { root: nextRoot, duplicatedId: duplicate.id };
76
+ }
7
77
 
8
78
  export default function EditorTree({
9
79
  prefabData,
@@ -24,21 +94,15 @@ export default function EditorTree({
24
94
  canUndo?: boolean;
25
95
  canRedo?: boolean;
26
96
  }) {
27
- const [contextMenu, setContextMenu] = useState<{ x: number, y: number, nodeId: string } | null>(null);
97
+ const { onFocusNode } = useEditorContext();
28
98
  const [draggedId, setDraggedId] = useState<string | null>(null);
99
+ const [dropTarget, setDropTarget] = useState<{ id: string; position: DropPosition } | null>(null);
29
100
  const [collapsedIds, setCollapsedIds] = useState<Set<string>>(new Set());
30
101
  const [collapsed, setCollapsed] = useState(false);
31
- const [fileMenuOpen, setFileMenuOpen] = useState(false);
32
102
  const [searchQuery, setSearchQuery] = useState('');
33
103
 
34
104
  if (!prefabData || !setPrefabData) return null;
35
105
 
36
- const handleContextMenu = (e: MouseEvent, nodeId: string) => {
37
- e.preventDefault();
38
- e.stopPropagation();
39
- setContextMenu({ x: e.clientX, y: e.clientY, nodeId });
40
- };
41
-
42
106
  const toggleCollapse = (e: MouseEvent, id: string) => {
43
107
  e.stopPropagation();
44
108
  setCollapsedIds(prev => {
@@ -49,47 +113,46 @@ export default function EditorTree({
49
113
  };
50
114
 
51
115
  const handleAddChild = (parentId: string) => {
116
+ const newNode = {
117
+ id: crypto.randomUUID(),
118
+ name: "New Node",
119
+ components: {
120
+ transform: {
121
+ type: "Transform",
122
+ properties: { ...getComponent('Transform')?.defaultProperties }
123
+ }
124
+ }
125
+ };
126
+
52
127
  setPrefabData(prev => ({
53
128
  ...prev,
54
129
  root: updateNodeById(prev.root, parentId, parent => ({
55
130
  ...parent,
56
- children: [...(parent.children ?? []), {
57
- id: crypto.randomUUID(),
58
- name: "New Node",
59
- components: {
60
- transform: {
61
- type: "Transform",
62
- properties: { ...getComponent('Transform')?.defaultProperties }
63
- }
64
- }
65
- }]
131
+ children: [...(parent.children ?? []), newNode]
66
132
  }))
67
133
  }));
68
- setContextMenu(null);
134
+ setSelectedId(newNode.id);
69
135
  };
70
136
 
71
137
  const handleDuplicate = (nodeId: string) => {
72
138
  if (nodeId === prefabData.root.id) return;
73
139
  setPrefabData(prev => {
74
- const node = findNode(prev.root, nodeId);
75
- const parent = findParent(prev.root, nodeId);
76
- if (!node || !parent) return prev;
140
+ const result = duplicateNodeBelow(prev.root, nodeId);
141
+ if (!result) return prev;
142
+
143
+ setSelectedId(result.duplicatedId);
144
+
77
145
  return {
78
146
  ...prev,
79
- root: updateNodeById(prev.root, parent.id, p => ({
80
- ...p,
81
- children: [...(p.children ?? []), cloneNode(node)]
82
- }))
147
+ root: result.root
83
148
  };
84
149
  });
85
- setContextMenu(null);
86
150
  };
87
151
 
88
152
  const handleDelete = (nodeId: string) => {
89
153
  if (nodeId === prefabData.root.id) return;
90
154
  setPrefabData(prev => ({ ...prev, root: deleteNode(prev.root, nodeId)! }));
91
155
  if (selectedId === nodeId) setSelectedId(null);
92
- setContextMenu(null);
93
156
  };
94
157
 
95
158
  const handleToggleDisabled = (nodeId: string) => {
@@ -100,7 +163,6 @@ export default function EditorTree({
100
163
  disabled: !node.disabled
101
164
  }))
102
165
  }));
103
- setContextMenu(null);
104
166
  };
105
167
 
106
168
  const handleDragStart = (e: React.DragEvent, id: string) => {
@@ -109,34 +171,36 @@ export default function EditorTree({
109
171
  setDraggedId(id);
110
172
  };
111
173
 
112
- const handleDragOver = (e: React.DragEvent, targetId: string) => {
174
+ const getDropPosition = (e: React.DragEvent<HTMLDivElement>, isRoot: boolean): DropPosition => {
175
+ if (isRoot) return 'inside';
176
+ const rect = e.currentTarget.getBoundingClientRect();
177
+ return e.clientY <= rect.top + rect.height * 0.35 ? 'before' : 'inside';
178
+ };
179
+
180
+ const handleDragOver = (e: React.DragEvent<HTMLDivElement>, targetId: string, isRoot: boolean) => {
113
181
  if (!draggedId || draggedId === targetId) return;
114
182
  const draggedNode = findNode(prefabData.root, draggedId);
115
183
  if (draggedNode && findNode(draggedNode, targetId)) return;
116
184
  e.preventDefault();
185
+ setDropTarget({ id: targetId, position: getDropPosition(e, isRoot) });
117
186
  };
118
187
 
119
- const handleDrop = (e: React.DragEvent, targetId: string) => {
188
+ const handleDragLeave = (e: React.DragEvent<HTMLDivElement>, targetId: string) => {
189
+ const relatedTarget = e.relatedTarget;
190
+ if (relatedTarget instanceof Node && e.currentTarget.contains(relatedTarget)) return;
191
+ setDropTarget(current => current?.id === targetId ? null : current);
192
+ };
193
+
194
+ const handleDrop = (e: React.DragEvent<HTMLDivElement>, targetId: string, isRoot: boolean) => {
120
195
  if (!draggedId || draggedId === targetId) return;
121
196
  e.preventDefault();
197
+ const dropPosition = getDropPosition(e, isRoot);
122
198
  setPrefabData(prev => {
123
- const draggedNode = findNode(prev.root, draggedId);
124
- const oldParent = findParent(prev.root, draggedId);
125
- if (!draggedNode || !oldParent || findNode(draggedNode, targetId)) return prev;
126
-
127
- let root = updateNodeById(prev.root, oldParent.id, p => ({
128
- ...p,
129
- children: p.children!.filter(c => c.id !== draggedId)
130
- }));
131
-
132
- root = updateNodeById(root, targetId, t => ({
133
- ...t,
134
- children: [...(t.children ?? []), draggedNode]
135
- }));
136
-
137
- return { ...prev, root };
199
+ const root = moveNode(prev.root, draggedId, targetId, dropPosition);
200
+ return root === prev.root ? prev : { ...prev, root };
138
201
  });
139
202
  setDraggedId(null);
203
+ setDropTarget(null);
140
204
  };
141
205
 
142
206
 
@@ -156,6 +220,9 @@ export default function EditorTree({
156
220
  const isCollapsed = collapsedIds.has(node.id);
157
221
  const hasChildren = node.children && node.children.length > 0;
158
222
  const isRoot = node.id === prefabData.root.id;
223
+ const isDropTarget = dropTarget?.id === node.id;
224
+ const showDropBefore = isDropTarget && dropTarget?.position === 'before';
225
+ const showDropInside = isDropTarget && dropTarget?.position === 'inside';
159
226
 
160
227
  return (
161
228
  <div key={node.id}>
@@ -168,14 +235,16 @@ export default function EditorTree({
168
235
  display: 'flex',
169
236
  alignItems: 'center',
170
237
  justifyContent: 'space-between',
238
+ borderTop: showDropBefore ? `2px solid ${colors.accent}` : undefined,
239
+ boxShadow: showDropInside ? `inset 0 0 0 1px ${colors.accentBorder}` : undefined,
171
240
  }}
172
241
  draggable={!isRoot}
173
242
  onClick={(e) => { e.stopPropagation(); setSelectedId(node.id); }}
174
- onContextMenu={(e) => handleContextMenu(e, node.id)}
175
243
  onDragStart={(e) => handleDragStart(e, node.id)}
176
- onDragEnd={() => setDraggedId(null)}
177
- onDragOver={(e) => handleDragOver(e, node.id)}
178
- onDrop={(e) => handleDrop(e, node.id)}
244
+ onDragEnd={() => { setDraggedId(null); setDropTarget(null); }}
245
+ onDragOver={(e) => handleDragOver(e, node.id, isRoot)}
246
+ onDragLeave={(e) => handleDragLeave(e, node.id)}
247
+ onDrop={(e) => handleDrop(e, node.id, isRoot)}
179
248
  >
180
249
  <div style={{ display: 'flex', alignItems: 'center', flex: 1, minWidth: 0 }}>
181
250
  <span
@@ -196,24 +265,104 @@ export default function EditorTree({
196
265
  </span>
197
266
  </div>
198
267
  {!isRoot && (
199
- <button
200
- style={{
201
- background: 'none',
202
- border: 'none',
203
- cursor: 'pointer',
204
- padding: '0 4px',
205
- fontSize: 14,
206
- opacity: node.disabled ? 0.5 : 0.7,
207
- color: 'inherit',
208
- }}
209
- onClick={(e) => {
210
- e.stopPropagation();
211
- handleToggleDisabled(node.id);
212
- }}
213
- title={node.disabled ? 'Enable' : 'Disable'}
268
+ <div style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
269
+ <Dropdown
270
+ placement="bottom-end"
271
+ trigger={({ ref, toggle }) => (
272
+ <button
273
+ ref={ref}
274
+ style={{
275
+ background: 'none',
276
+ border: 'none',
277
+ cursor: 'pointer',
278
+ padding: '0 4px',
279
+ fontSize: 14,
280
+ opacity: 0.7,
281
+ color: 'inherit',
282
+ }}
283
+ onClick={(e) => {
284
+ e.stopPropagation();
285
+ toggle();
286
+ }}
287
+ title="Node Actions"
288
+ >
289
+
290
+ </button>
291
+ )}
292
+ >
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
+ )}
309
+ </Dropdown>
310
+ <button
311
+ style={{
312
+ background: 'none',
313
+ border: 'none',
314
+ cursor: 'pointer',
315
+ padding: '0 4px',
316
+ fontSize: 14,
317
+ opacity: node.disabled ? 0.5 : 0.7,
318
+ color: 'inherit',
319
+ }}
320
+ onClick={(e) => {
321
+ e.stopPropagation();
322
+ handleToggleDisabled(node.id);
323
+ }}
324
+ title={node.disabled ? 'Enable' : 'Disable'}
325
+ >
326
+ {node.disabled ? '◎' : '◉'}
327
+ </button>
328
+ </div>
329
+ )}
330
+ {isRoot && (
331
+ <Dropdown
332
+ placement="bottom-end"
333
+ trigger={({ ref, toggle }) => (
334
+ <button
335
+ ref={ref}
336
+ style={{
337
+ background: 'none',
338
+ border: 'none',
339
+ cursor: 'pointer',
340
+ padding: '0 4px',
341
+ fontSize: 14,
342
+ opacity: 0.7,
343
+ color: 'inherit',
344
+ }}
345
+ onClick={(e) => {
346
+ e.stopPropagation();
347
+ toggle();
348
+ }}
349
+ title="Scene Actions"
350
+ >
351
+
352
+ </button>
353
+ )}
214
354
  >
215
- {node.disabled ? '◎' : '◉'}
216
- </button>
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
+ </Dropdown>
217
366
  )}
218
367
  </div>
219
368
  {!isCollapsed && node.children && node.children.map(child => renderNode(child, depth + 1))}
@@ -223,7 +372,7 @@ export default function EditorTree({
223
372
 
224
373
  return (
225
374
  <>
226
- <div style={{ ...tree.panel, width: collapsed ? 'auto' : 224 }} onClick={() => { setContextMenu(null); setFileMenuOpen(false); }}>
375
+ <div style={{ ...tree.panel, width: collapsed ? 'auto' : 224 }}>
227
376
  <div style={base.header}>
228
377
  <div style={{ display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }} onClick={() => setCollapsed(!collapsed)}>
229
378
  <span>{collapsed ? '▶' : '▼'}</span>
@@ -247,22 +396,27 @@ export default function EditorTree({
247
396
  >
248
397
 
249
398
  </button>
250
- <div style={{ position: 'relative' }}>
251
- <button
252
- style={{ ...base.btn, padding: '2px 6px', fontSize: 10 }}
253
- onClick={(e) => { e.stopPropagation(); setFileMenuOpen(!fileMenuOpen); }}
254
- title="File"
255
- >
256
-
257
- </button>
258
- {fileMenuOpen && (
399
+ <Dropdown
400
+ placement="bottom-end"
401
+ trigger={({ ref, toggle }) => (
402
+ <button
403
+ ref={ref}
404
+ style={{ ...base.btn, padding: '2px 6px', fontSize: 10 }}
405
+ onClick={(e) => { e.stopPropagation(); toggle(); }}
406
+ title="File"
407
+ >
408
+
409
+ </button>
410
+ )}
411
+ >
412
+ {(close) => (
259
413
  <FileMenu
260
414
  prefabData={prefabData}
261
415
  setPrefabData={setPrefabData}
262
- onClose={() => setFileMenuOpen(false)}
416
+ onClose={close}
263
417
  />
264
418
  )}
265
- </div>
419
+ </Dropdown>
266
420
  </div>
267
421
  )}
268
422
  </div>
@@ -286,27 +440,6 @@ export default function EditorTree({
286
440
  )}
287
441
  </div>
288
442
 
289
- {contextMenu && (
290
- <div
291
- style={{ ...menu.container, top: contextMenu.y, left: contextMenu.x }}
292
- onClick={(e) => e.stopPropagation()}
293
- onPointerLeave={() => setContextMenu(null)}
294
- >
295
- <button style={menu.item} onClick={() => handleAddChild(contextMenu.nodeId)}>
296
- Add Child
297
- </button>
298
- {contextMenu.nodeId !== prefabData.root.id && (
299
- <>
300
- <button style={menu.item} onClick={() => handleDuplicate(contextMenu.nodeId)}>
301
- Duplicate
302
- </button>
303
- <button style={{ ...menu.item, ...menu.danger }} onClick={() => handleDelete(contextMenu.nodeId)}>
304
- Delete
305
- </button>
306
- </>
307
- )}
308
- </div>
309
- )}
310
443
  </>
311
444
  );
312
445
  }
@@ -350,7 +483,7 @@ function FileMenu({
350
483
 
351
484
  return (
352
485
  <div
353
- style={{ ...menu.container, position: 'absolute', top: 28, right: 0 }}
486
+ style={{ ...menu.container, position: 'static' }}
354
487
  onClick={(e) => e.stopPropagation()}
355
488
  >
356
489
  <button
@@ -97,7 +97,7 @@ function NodeInspector({
97
97
  if (!newAvailable.includes(addType)) setAddType(newAvailable[0] || "");
98
98
  }, [Object.keys(node.components || {}).join(',')]);
99
99
 
100
- return <div style={{ ...inspector.content, paddingRight: 2 }} className="prefab-scroll">
100
+ return <div style={inspector.content} className="prefab-scroll">
101
101
  {/* Node Name */}
102
102
  <div style={base.section}>
103
103
  <div style={{ display: "flex", marginBottom: 8, alignItems: 'center', gap: 8 }}>
@@ -36,13 +36,16 @@ const PrefabEditor = forwardRef<PrefabEditorRef, {
36
36
  initialPrefab?: Prefab;
37
37
  physics?: boolean;
38
38
  onPrefabChange?: (prefab: Prefab) => void;
39
+ uiPlugins?: React.ReactNode[] | React.ReactNode;
39
40
  children?: React.ReactNode;
40
- }>(({ basePath, initialPrefab, physics = true, onPrefabChange, children }, ref) => {
41
+ }>(({ basePath, initialPrefab, physics = true, onPrefabChange, uiPlugins, children }, ref) => {
41
42
  const [editMode, setEditMode] = useState(true);
42
43
  const [loadedPrefab, setLoadedPrefab] = useState<Prefab>(initialPrefab ?? DEFAULT_PREFAB);
43
44
  const [selectedId, setSelectedId] = useState<string | null>(null);
44
45
  const [transformMode, setTransformMode] = useState<"translate" | "rotate" | "scale">("translate");
45
46
  const [snapResolution, setSnapResolution] = useState(0);
47
+ const [positionSnap, setPositionSnap] = useState(0.5);
48
+ const [rotationSnap, setRotationSnap] = useState(Math.PI / 4);
46
49
  const [history, setHistory] = useState<Prefab[]>([loadedPrefab]);
47
50
  const [historyIndex, setHistoryIndex] = useState(0);
48
51
  const throttleRef = useRef<NodeJS.Timeout | null>(null);
@@ -121,6 +124,10 @@ const PrefabEditor = forwardRef<PrefabEditorRef, {
121
124
  });
122
125
  };
123
126
 
127
+ const handleFocusNode = (nodeId: string) => {
128
+ prefabRootRef.current?.focusNode(nodeId);
129
+ };
130
+
124
131
  useEffect(() => {
125
132
  const canvas = document.querySelector('canvas');
126
133
  if (canvas) canvasRef.current = canvas;
@@ -214,10 +221,15 @@ const PrefabEditor = forwardRef<PrefabEditorRef, {
214
221
  setTransformMode,
215
222
  snapResolution,
216
223
  setSnapResolution,
224
+ positionSnap,
225
+ setPositionSnap,
226
+ rotationSnap,
227
+ setRotationSnap,
228
+ onFocusNode: handleFocusNode,
217
229
  onScreenshot: handleScreenshot,
218
230
  onExportGLB: handleExportGLB
219
231
  }}>
220
- <GameCanvas>
232
+ <GameCanvas camera={{ position: [0, 5, 15] }}>
221
233
  {physics ? (
222
234
  <Physics debug={editMode} paused={editMode}>
223
235
  {content}
@@ -229,8 +241,9 @@ const PrefabEditor = forwardRef<PrefabEditorRef, {
229
241
  <button style={base.btn} onClick={() => setEditMode(!editMode)}>
230
242
  {editMode ? "▶" : "⏸"}
231
243
  </button>
244
+ {uiPlugins}
232
245
  </div>
233
- {editMode && <EditorUI
246
+ <EditorUI
234
247
  prefabData={loadedPrefab}
235
248
  setPrefabData={updatePrefab}
236
249
  selectedId={selectedId}
@@ -240,7 +253,7 @@ const PrefabEditor = forwardRef<PrefabEditorRef, {
240
253
  onRedo={redo}
241
254
  canUndo={historyIndex > 0}
242
255
  canRedo={historyIndex < history.length - 1}
243
- />}
256
+ />
244
257
  </EditorContext.Provider>
245
258
  });
246
259