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.
@@ -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
- return (_jsxs(FieldGroup, { children: [_jsx(ColorField, { name: "color", label: "Color", values: component.properties, onChange: onUpdate }), _jsx(NumberField, { name: "intensity", label: "Intensity", values: component.properties, onChange: onUpdate, min: 0, step: 0.1, fallback: 1 }), _jsx(NumberField, { name: "angle", label: "Angle", values: component.properties, onChange: onUpdate, min: 0, max: Math.PI, step: 0.05, fallback: Math.PI / 6 }), _jsx(NumberField, { name: "penumbra", label: "Penumbra", values: component.properties, onChange: onUpdate, min: 0, max: 1, step: 0.05, fallback: 0.5 }), _jsx(NumberField, { name: "distance", label: "Distance", values: component.properties, onChange: onUpdate, min: 0, step: 1, fallback: 100 }), _jsx(BooleanField, { name: "castShadow", label: "Cast Shadow", values: component.properties, onChange: onUpdate, fallback: true })] }));
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
- var _a, _b, _c, _d, _e, _f;
11
- const color = (_a = properties.color) !== null && _a !== void 0 ? _a : '#ffffff';
12
- const intensity = (_b = properties.intensity) !== null && _b !== void 0 ? _b : 1.0;
13
- const angle = (_c = properties.angle) !== null && _c !== void 0 ? _c : Math.PI / 6;
14
- const penumbra = (_d = properties.penumbra) !== null && _d !== void 0 ? _d : 0.5;
15
- const distance = (_e = properties.distance) !== null && _e !== void 0 ? _e : 100;
16
- const castShadow = (_f = properties.castShadow) !== null && _f !== void 0 ? _f : true;
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
- useHelper(editMode && isSelected ? spotLightRef : null, SpotLightHelper, color);
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
- 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 }), _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 })] })] }))] }));
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-three-game",
3
- "version": "0.0.57",
3
+ "version": "0.0.58",
4
4
  "description": "Batteries included React Three Fiber game engine",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.js",
@@ -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, menu } from './styles';
5
- import { findNode, findParent, deleteNode, cloneNode, updateNodeById, loadJson, saveJson, regenerateIds } from './utils';
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
- <button
273
- ref={ref}
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
- </button>
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
- <button
335
- ref={ref}
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
- </button>
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
- <button
403
- ref={ref}
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
- </button>
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
+ }