react-three-game 0.0.55 → 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 (68) hide show
  1. package/dist/index.d.ts +1 -1
  2. package/dist/index.js +1 -1
  3. package/dist/shared/ContactShadow.d.ts +8 -0
  4. package/dist/shared/ContactShadow.js +32 -0
  5. package/dist/shared/GameCanvas.js +1 -3
  6. package/dist/tools/assetviewer/page.js +36 -15
  7. package/dist/tools/dragdrop/DragDropLoader.js +17 -40
  8. package/dist/tools/dragdrop/modelLoader.d.ts +5 -0
  9. package/dist/tools/dragdrop/modelLoader.js +39 -0
  10. package/dist/tools/prefabeditor/Dropdown.d.ts +15 -0
  11. package/dist/tools/prefabeditor/Dropdown.js +82 -0
  12. package/dist/tools/prefabeditor/EditorContext.d.ts +5 -0
  13. package/dist/tools/prefabeditor/EditorTree.js +139 -70
  14. package/dist/tools/prefabeditor/EditorUI.js +5 -10
  15. package/dist/tools/prefabeditor/PrefabEditor.d.ts +1 -0
  16. package/dist/tools/prefabeditor/PrefabEditor.js +70 -3
  17. package/dist/tools/prefabeditor/PrefabRoot.d.ts +3 -0
  18. package/dist/tools/prefabeditor/PrefabRoot.js +136 -35
  19. package/dist/tools/prefabeditor/components/AmbientLightComponent.js +3 -7
  20. package/dist/tools/prefabeditor/components/CameraComponent.d.ts +3 -0
  21. package/dist/tools/prefabeditor/components/CameraComponent.js +25 -0
  22. package/dist/tools/prefabeditor/components/DirectionalLightComponent.js +2 -2
  23. package/dist/tools/prefabeditor/components/EnvironmentComponent.d.ts +3 -0
  24. package/dist/tools/prefabeditor/components/EnvironmentComponent.js +15 -0
  25. package/dist/tools/prefabeditor/components/GeometryComponent.js +46 -46
  26. package/dist/tools/prefabeditor/components/Input.d.ts +51 -1
  27. package/dist/tools/prefabeditor/components/Input.js +100 -47
  28. package/dist/tools/prefabeditor/components/MaterialComponent.d.ts +8 -2
  29. package/dist/tools/prefabeditor/components/MaterialComponent.js +129 -14
  30. package/dist/tools/prefabeditor/components/ModelComponent.js +44 -3
  31. package/dist/tools/prefabeditor/components/PhysicsComponent.js +16 -81
  32. package/dist/tools/prefabeditor/components/SpotLightComponent.js +6 -11
  33. package/dist/tools/prefabeditor/components/TextComponent.js +7 -53
  34. package/dist/tools/prefabeditor/components/TransformComponent.js +31 -19
  35. package/dist/tools/prefabeditor/components/index.js +5 -1
  36. package/dist/tools/prefabeditor/styles.d.ts +17 -4
  37. package/dist/tools/prefabeditor/styles.js +69 -32
  38. package/dist/tools/prefabeditor/utils.d.ts +8 -3
  39. package/dist/tools/prefabeditor/utils.js +92 -6
  40. package/package.json +1 -1
  41. package/react-three-game-skill/react-three-game/rules/LIGHTING.md +6 -0
  42. package/src/index.ts +7 -0
  43. package/src/shared/ContactShadow.tsx +74 -0
  44. package/src/shared/GameCanvas.tsx +0 -3
  45. package/src/tools/assetviewer/page.tsx +78 -46
  46. package/src/tools/dragdrop/DragDropLoader.tsx +7 -39
  47. package/src/tools/dragdrop/modelLoader.ts +36 -0
  48. package/src/tools/prefabeditor/Dropdown.tsx +112 -0
  49. package/src/tools/prefabeditor/EditorContext.tsx +5 -0
  50. package/src/tools/prefabeditor/EditorTree.tsx +237 -115
  51. package/src/tools/prefabeditor/EditorUI.tsx +6 -11
  52. package/src/tools/prefabeditor/PrefabEditor.tsx +77 -5
  53. package/src/tools/prefabeditor/PrefabRoot.tsx +228 -59
  54. package/src/tools/prefabeditor/components/AmbientLightComponent.tsx +5 -11
  55. package/src/tools/prefabeditor/components/CameraComponent.tsx +80 -0
  56. package/src/tools/prefabeditor/components/DirectionalLightComponent.tsx +2 -2
  57. package/src/tools/prefabeditor/components/EnvironmentComponent.tsx +47 -0
  58. package/src/tools/prefabeditor/components/GeometryComponent.tsx +69 -63
  59. package/src/tools/prefabeditor/components/Input.tsx +247 -53
  60. package/src/tools/prefabeditor/components/MaterialComponent.tsx +191 -20
  61. package/src/tools/prefabeditor/components/ModelComponent.tsx +52 -5
  62. package/src/tools/prefabeditor/components/PhysicsComponent.tsx +44 -85
  63. package/src/tools/prefabeditor/components/SpotLightComponent.tsx +14 -16
  64. package/src/tools/prefabeditor/components/TextComponent.tsx +58 -57
  65. package/src/tools/prefabeditor/components/TransformComponent.tsx +78 -20
  66. package/src/tools/prefabeditor/components/index.ts +5 -1
  67. package/src/tools/prefabeditor/styles.ts +71 -32
  68. package/src/tools/prefabeditor/utils.ts +96 -5
@@ -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
  }
@@ -1,9 +1,79 @@
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, tree, menu } from './styles';
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) });
186
+ };
187
+
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);
117
192
  };
118
193
 
119
- const handleDrop = (e: React.DragEvent, targetId: string) => {
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,12 +372,7 @@ export default function EditorTree({
223
372
 
224
373
  return (
225
374
  <>
226
- <style>{`
227
- .tree-scroll::-webkit-scrollbar { width: 8px; height: 8px; }
228
- .tree-scroll::-webkit-scrollbar-track { background: transparent; }
229
- .tree-scroll::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.06); border-radius: 8px; }
230
- `}</style>
231
- <div style={{ ...tree.panel, width: collapsed ? 'auto' : 224 }} onClick={() => { setContextMenu(null); setFileMenuOpen(false); }}>
375
+ <div style={{ ...tree.panel, width: collapsed ? 'auto' : 224 }}>
232
376
  <div style={base.header}>
233
377
  <div style={{ display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }} onClick={() => setCollapsed(!collapsed)}>
234
378
  <span>{collapsed ? '▶' : '▼'}</span>
@@ -252,28 +396,33 @@ export default function EditorTree({
252
396
  >
253
397
 
254
398
  </button>
255
- <div style={{ position: 'relative' }}>
256
- <button
257
- style={{ ...base.btn, padding: '2px 6px', fontSize: 10 }}
258
- onClick={(e) => { e.stopPropagation(); setFileMenuOpen(!fileMenuOpen); }}
259
- title="File"
260
- >
261
-
262
- </button>
263
- {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) => (
264
413
  <FileMenu
265
414
  prefabData={prefabData}
266
415
  setPrefabData={setPrefabData}
267
- onClose={() => setFileMenuOpen(false)}
416
+ onClose={close}
268
417
  />
269
418
  )}
270
- </div>
419
+ </Dropdown>
271
420
  </div>
272
421
  )}
273
422
  </div>
274
423
  {!collapsed && (
275
424
  <>
276
- <div style={{ padding: '4px 4px', borderBottom: '1px solid rgba(255,255,255,0.1)' }}>
425
+ <div style={{ padding: '4px 4px', borderBottom: `1px solid ${colors.borderLight}` }}>
277
426
  <input
278
427
  type="text"
279
428
  placeholder="Search nodes..."
@@ -281,14 +430,8 @@ export default function EditorTree({
281
430
  onChange={(e) => setSearchQuery(e.target.value)}
282
431
  onClick={(e) => e.stopPropagation()}
283
432
  style={{
284
- width: '100%',
433
+ ...base.input,
285
434
  padding: '4px 8px',
286
- background: 'rgba(255,255,255,0.05)',
287
- border: '1px solid rgba(255,255,255,0.1)',
288
- borderRadius: 3,
289
- color: 'inherit',
290
- fontSize: 11,
291
- outline: 'none',
292
435
  }}
293
436
  />
294
437
  </div>
@@ -297,27 +440,6 @@ export default function EditorTree({
297
440
  )}
298
441
  </div>
299
442
 
300
- {contextMenu && (
301
- <div
302
- style={{ ...menu.container, top: contextMenu.y, left: contextMenu.x }}
303
- onClick={(e) => e.stopPropagation()}
304
- onPointerLeave={() => setContextMenu(null)}
305
- >
306
- <button style={menu.item} onClick={() => handleAddChild(contextMenu.nodeId)}>
307
- Add Child
308
- </button>
309
- {contextMenu.nodeId !== prefabData.root.id && (
310
- <>
311
- <button style={menu.item} onClick={() => handleDuplicate(contextMenu.nodeId)}>
312
- Duplicate
313
- </button>
314
- <button style={{ ...menu.item, ...menu.danger }} onClick={() => handleDelete(contextMenu.nodeId)}>
315
- Delete
316
- </button>
317
- </>
318
- )}
319
- </div>
320
- )}
321
443
  </>
322
444
  );
323
445
  }
@@ -361,7 +483,7 @@ function FileMenu({
361
483
 
362
484
  return (
363
485
  <div
364
- style={{ ...menu.container, top: 28, right: 0 }}
486
+ style={{ ...menu.container, position: 'static' }}
365
487
  onClick={(e) => e.stopPropagation()}
366
488
  >
367
489
  <button
@@ -2,7 +2,7 @@ import { Dispatch, SetStateAction, useState, useEffect } from 'react';
2
2
  import { Prefab, GameObject as GameObjectType } from "./types";
3
3
  import EditorTree from './EditorTree';
4
4
  import { getAllComponents } from './components/ComponentRegistry';
5
- import { base, inspector } from './styles';
5
+ import { base, colors, inspector, scrollbarCSS, componentCard } from './styles';
6
6
  import { findNode, updateNode, deleteNode } from './utils';
7
7
 
8
8
  function EditorUI({
@@ -45,12 +45,7 @@ function EditorUI({
45
45
  const selectedNode = selectedId && prefabData ? findNode(prefabData.root, selectedId) : null;
46
46
 
47
47
  return <>
48
- <style>{`
49
- .prefab-scroll::-webkit-scrollbar { width: 8px; height: 8px; }
50
- .prefab-scroll::-webkit-scrollbar-track { background: transparent; }
51
- .prefab-scroll::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.06); border-radius: 8px; }
52
- .prefab-scroll { scrollbar-width: thin; scrollbar-color: rgba(255,255,255,0.06) transparent; }
53
- `}</style>
48
+ <style>{scrollbarCSS}</style>
54
49
  <div style={inspector.panel}>
55
50
  <div style={base.header} onClick={() => setCollapsed(!collapsed)}>
56
51
  <span>Inspector</span>
@@ -102,11 +97,11 @@ function NodeInspector({
102
97
  if (!newAvailable.includes(addType)) setAddType(newAvailable[0] || "");
103
98
  }, [Object.keys(node.components || {}).join(',')]);
104
99
 
105
- return <div style={{ ...inspector.content, paddingRight: 2 }} className="prefab-scroll">
100
+ return <div style={inspector.content} className="prefab-scroll">
106
101
  {/* Node Name */}
107
102
  <div style={base.section}>
108
103
  <div style={{ display: "flex", marginBottom: 8, alignItems: 'center', gap: 8 }}>
109
- <div style={{ fontSize: 10, color: '#888', wordBreak: 'break-all', border: '1px solid rgba(255,255,255,0.1)', padding: '2px 4px', borderRadius: 4, flex: 1 }}>
104
+ <div style={{ fontSize: 10, color: colors.textDim, wordBreak: 'break-all', border: `1px solid ${colors.border}`, padding: '2px 6px', borderRadius: 3, flex: 1, fontFamily: 'monospace' }}>
110
105
  {node.id}
111
106
  </div>
112
107
  <button style={{ ...base.btn, ...base.btnDanger }} title="Delete Node" onClick={deleteNode}>❌</button>
@@ -131,12 +126,12 @@ function NodeInspector({
131
126
  {node.components && Object.entries(node.components).map(([key, comp]: [string, any]) => {
132
127
  if (!comp) return null;
133
128
  const def = ALL_COMPONENTS[comp.type];
134
- if (!def) return <div key={key} style={{ color: '#ff8888', fontSize: 11 }}>
129
+ if (!def) return <div key={key} style={{ color: colors.danger, fontSize: 11 }}>
135
130
  Unknown: {comp.type}
136
131
  </div>;
137
132
 
138
133
  return (
139
- <div key={key} style={{ marginBottom: 8, backgroundColor: 'rgba(255, 255, 255, 0.1)', padding: 8, borderRadius: 4 }}>
134
+ <div key={key} style={componentCard.container}>
140
135
  <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
141
136
  <div style={{ fontSize: 11, fontWeight: 500 }}>{key}</div>
142
137
  <button