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.
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/shared/GameCanvas.js +1 -3
- package/dist/tools/assetviewer/page.js +35 -14
- package/dist/tools/prefabeditor/Dropdown.d.ts +15 -0
- package/dist/tools/prefabeditor/Dropdown.js +82 -0
- package/dist/tools/prefabeditor/EditorContext.d.ts +5 -0
- package/dist/tools/prefabeditor/EditorTree.js +138 -56
- package/dist/tools/prefabeditor/EditorUI.js +1 -1
- package/dist/tools/prefabeditor/PrefabEditor.d.ts +1 -0
- package/dist/tools/prefabeditor/PrefabEditor.js +13 -2
- package/dist/tools/prefabeditor/PrefabRoot.d.ts +1 -0
- package/dist/tools/prefabeditor/PrefabRoot.js +120 -34
- package/dist/tools/prefabeditor/components/AmbientLightComponent.js +3 -7
- package/dist/tools/prefabeditor/components/CameraComponent.d.ts +3 -0
- package/dist/tools/prefabeditor/components/CameraComponent.js +25 -0
- package/dist/tools/prefabeditor/components/DirectionalLightComponent.js +2 -2
- package/dist/tools/prefabeditor/components/EnvironmentComponent.d.ts +3 -0
- package/dist/tools/prefabeditor/components/EnvironmentComponent.js +15 -0
- package/dist/tools/prefabeditor/components/GeometryComponent.js +46 -46
- package/dist/tools/prefabeditor/components/Input.d.ts +51 -1
- package/dist/tools/prefabeditor/components/Input.js +73 -21
- package/dist/tools/prefabeditor/components/MaterialComponent.d.ts +8 -2
- package/dist/tools/prefabeditor/components/MaterialComponent.js +122 -14
- package/dist/tools/prefabeditor/components/ModelComponent.js +44 -3
- package/dist/tools/prefabeditor/components/PhysicsComponent.js +16 -81
- package/dist/tools/prefabeditor/components/SpotLightComponent.js +4 -12
- package/dist/tools/prefabeditor/components/TextComponent.js +7 -53
- package/dist/tools/prefabeditor/components/TransformComponent.js +18 -8
- package/dist/tools/prefabeditor/components/index.js +5 -1
- package/dist/tools/prefabeditor/styles.d.ts +5 -2
- package/dist/tools/prefabeditor/styles.js +7 -3
- package/dist/tools/prefabeditor/utils.d.ts +4 -3
- package/dist/tools/prefabeditor/utils.js +53 -5
- package/package.json +1 -1
- package/src/index.ts +7 -0
- package/src/shared/GameCanvas.tsx +0 -3
- package/src/tools/assetviewer/page.tsx +77 -45
- package/src/tools/prefabeditor/Dropdown.tsx +112 -0
- package/src/tools/prefabeditor/EditorContext.tsx +5 -0
- package/src/tools/prefabeditor/EditorTree.tsx +234 -101
- package/src/tools/prefabeditor/EditorUI.tsx +1 -1
- package/src/tools/prefabeditor/PrefabEditor.tsx +17 -4
- package/src/tools/prefabeditor/PrefabRoot.tsx +208 -58
- package/src/tools/prefabeditor/components/AmbientLightComponent.tsx +5 -11
- package/src/tools/prefabeditor/components/CameraComponent.tsx +80 -0
- package/src/tools/prefabeditor/components/DirectionalLightComponent.tsx +2 -2
- package/src/tools/prefabeditor/components/EnvironmentComponent.tsx +47 -0
- package/src/tools/prefabeditor/components/GeometryComponent.tsx +69 -63
- package/src/tools/prefabeditor/components/Input.tsx +220 -27
- package/src/tools/prefabeditor/components/MaterialComponent.tsx +178 -16
- package/src/tools/prefabeditor/components/ModelComponent.tsx +51 -4
- package/src/tools/prefabeditor/components/PhysicsComponent.tsx +44 -85
- package/src/tools/prefabeditor/components/SpotLightComponent.tsx +11 -17
- package/src/tools/prefabeditor/components/TextComponent.tsx +58 -57
- package/src/tools/prefabeditor/components/TransformComponent.tsx +61 -9
- package/src/tools/prefabeditor/components/index.ts +5 -1
- package/src/tools/prefabeditor/styles.ts +7 -3
- 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
|
|
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
|
-
|
|
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
|
|
75
|
-
|
|
76
|
-
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
124
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
{
|
|
216
|
-
|
|
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 }}
|
|
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
|
-
<
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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={
|
|
416
|
+
onClose={close}
|
|
263
417
|
/>
|
|
264
418
|
)}
|
|
265
|
-
</
|
|
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: '
|
|
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={
|
|
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
|
-
|
|
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
|
|