react-three-game 0.0.33 → 0.0.34
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/tools/prefabeditor/EditorTree.d.ts +7 -1
- package/dist/tools/prefabeditor/EditorTree.js +18 -29
- package/dist/tools/prefabeditor/EditorUI.d.ts +7 -1
- package/dist/tools/prefabeditor/EditorUI.js +4 -3
- package/dist/tools/prefabeditor/PrefabEditor.js +65 -86
- package/package.json +1 -1
- package/src/tools/prefabeditor/EditorTree.tsx +86 -39
- package/src/tools/prefabeditor/EditorUI.tsx +38 -5
- package/src/tools/prefabeditor/PrefabEditor.tsx +108 -148
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
import { Dispatch, SetStateAction } from 'react';
|
|
2
2
|
import { Prefab } from "./types";
|
|
3
|
-
export default function EditorTree({ prefabData, setPrefabData, selectedId, setSelectedId }: {
|
|
3
|
+
export default function EditorTree({ prefabData, setPrefabData, selectedId, setSelectedId, onSave, onLoad, onUndo, onRedo, canUndo, canRedo }: {
|
|
4
4
|
prefabData?: Prefab;
|
|
5
5
|
setPrefabData?: Dispatch<SetStateAction<Prefab>>;
|
|
6
6
|
selectedId: string | null;
|
|
7
7
|
setSelectedId: Dispatch<SetStateAction<string | null>>;
|
|
8
|
+
onSave?: () => void;
|
|
9
|
+
onLoad?: () => void;
|
|
10
|
+
onUndo?: () => void;
|
|
11
|
+
onRedo?: () => void;
|
|
12
|
+
canUndo?: boolean;
|
|
13
|
+
canRedo?: boolean;
|
|
8
14
|
}): import("react/jsx-runtime").JSX.Element | null;
|
|
@@ -3,11 +3,12 @@ import { useState } from 'react';
|
|
|
3
3
|
import { getComponent } from './components/ComponentRegistry';
|
|
4
4
|
import { base, tree, menu } from './styles';
|
|
5
5
|
import { findNode, findParent, deleteNode, cloneNode, updateNodeById } from './utils';
|
|
6
|
-
export default function EditorTree({ prefabData, setPrefabData, selectedId, setSelectedId }) {
|
|
6
|
+
export default function EditorTree({ prefabData, setPrefabData, selectedId, setSelectedId, onSave, onLoad, onUndo, onRedo, canUndo, canRedo }) {
|
|
7
7
|
const [contextMenu, setContextMenu] = useState(null);
|
|
8
8
|
const [draggedId, setDraggedId] = useState(null);
|
|
9
9
|
const [collapsedIds, setCollapsedIds] = useState(new Set());
|
|
10
10
|
const [collapsed, setCollapsed] = useState(false);
|
|
11
|
+
const [fileMenuOpen, setFileMenuOpen] = useState(false);
|
|
11
12
|
if (!prefabData || !setPrefabData)
|
|
12
13
|
return null;
|
|
13
14
|
const handleContextMenu = (e, nodeId) => {
|
|
@@ -23,22 +24,19 @@ export default function EditorTree({ prefabData, setPrefabData, selectedId, setS
|
|
|
23
24
|
return next;
|
|
24
25
|
});
|
|
25
26
|
};
|
|
26
|
-
// Actions
|
|
27
27
|
const handleAddChild = (parentId) => {
|
|
28
|
-
var _a;
|
|
29
|
-
const newNode = {
|
|
30
|
-
id: crypto.randomUUID(),
|
|
31
|
-
name: "New Node",
|
|
32
|
-
components: {
|
|
33
|
-
transform: {
|
|
34
|
-
type: "Transform",
|
|
35
|
-
properties: Object.assign({}, (_a = getComponent('Transform')) === null || _a === void 0 ? void 0 : _a.defaultProperties)
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
};
|
|
39
28
|
setPrefabData(prev => (Object.assign(Object.assign({}, prev), { root: updateNodeById(prev.root, parentId, parent => {
|
|
40
|
-
var _a;
|
|
41
|
-
return (Object.assign(Object.assign({}, parent), { children: [...((_a = parent.children) !== null && _a !== void 0 ? _a : []),
|
|
29
|
+
var _a, _b;
|
|
30
|
+
return (Object.assign(Object.assign({}, parent), { children: [...((_a = parent.children) !== null && _a !== void 0 ? _a : []), {
|
|
31
|
+
id: crypto.randomUUID(),
|
|
32
|
+
name: "New Node",
|
|
33
|
+
components: {
|
|
34
|
+
transform: {
|
|
35
|
+
type: "Transform",
|
|
36
|
+
properties: Object.assign({}, (_b = getComponent('Transform')) === null || _b === void 0 ? void 0 : _b.defaultProperties)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}] }));
|
|
42
40
|
}) })));
|
|
43
41
|
setContextMenu(null);
|
|
44
42
|
};
|
|
@@ -50,10 +48,9 @@ export default function EditorTree({ prefabData, setPrefabData, selectedId, setS
|
|
|
50
48
|
const parent = findParent(prev.root, nodeId);
|
|
51
49
|
if (!node || !parent)
|
|
52
50
|
return prev;
|
|
53
|
-
const clone = cloneNode(node);
|
|
54
51
|
return Object.assign(Object.assign({}, prev), { root: updateNodeById(prev.root, parent.id, p => {
|
|
55
52
|
var _a;
|
|
56
|
-
return (Object.assign(Object.assign({}, p), { children: [...((_a = p.children) !== null && _a !== void 0 ? _a : []),
|
|
53
|
+
return (Object.assign(Object.assign({}, p), { children: [...((_a = p.children) !== null && _a !== void 0 ? _a : []), cloneNode(node)] }));
|
|
57
54
|
}) });
|
|
58
55
|
});
|
|
59
56
|
setContextMenu(null);
|
|
@@ -66,12 +63,9 @@ export default function EditorTree({ prefabData, setPrefabData, selectedId, setS
|
|
|
66
63
|
setSelectedId(null);
|
|
67
64
|
setContextMenu(null);
|
|
68
65
|
};
|
|
69
|
-
// Drag and Drop
|
|
70
66
|
const handleDragStart = (e, id) => {
|
|
71
|
-
if (id === prefabData.root.id)
|
|
72
|
-
e.preventDefault();
|
|
73
|
-
return;
|
|
74
|
-
}
|
|
67
|
+
if (id === prefabData.root.id)
|
|
68
|
+
return e.preventDefault();
|
|
75
69
|
e.dataTransfer.effectAllowed = "move";
|
|
76
70
|
setDraggedId(id);
|
|
77
71
|
};
|
|
@@ -90,14 +84,9 @@ export default function EditorTree({ prefabData, setPrefabData, selectedId, setS
|
|
|
90
84
|
setPrefabData(prev => {
|
|
91
85
|
const draggedNode = findNode(prev.root, draggedId);
|
|
92
86
|
const oldParent = findParent(prev.root, draggedId);
|
|
93
|
-
if (!draggedNode || !oldParent)
|
|
94
|
-
return prev;
|
|
95
|
-
// Prevent dropping into own subtree
|
|
96
|
-
if (findNode(draggedNode, targetId))
|
|
87
|
+
if (!draggedNode || !oldParent || findNode(draggedNode, targetId))
|
|
97
88
|
return prev;
|
|
98
|
-
// 1. Remove from old parent
|
|
99
89
|
let root = updateNodeById(prev.root, oldParent.id, p => (Object.assign(Object.assign({}, p), { children: p.children.filter(c => c.id !== draggedId) })));
|
|
100
|
-
// 2. Add to new parent
|
|
101
90
|
root = updateNodeById(root, targetId, t => {
|
|
102
91
|
var _a;
|
|
103
92
|
return (Object.assign(Object.assign({}, t), { children: [...((_a = t.children) !== null && _a !== void 0 ? _a : []), draggedNode] }));
|
|
@@ -122,5 +111,5 @@ export default function EditorTree({ prefabData, setPrefabData, selectedId, setS
|
|
|
122
111
|
visibility: hasChildren ? 'visible' : 'hidden'
|
|
123
112
|
}, onClick: (e) => hasChildren && toggleCollapse(e, node.id), children: isCollapsed ? '▶' : '▼' }), !isRoot && _jsx("span", { style: { marginRight: 4, opacity: 0.4 }, children: "\u22EE\u22EE" }), _jsx("span", { style: { overflow: 'hidden', textOverflow: 'ellipsis' }, children: (_a = node.name) !== null && _a !== void 0 ? _a : node.id })] }), !isCollapsed && node.children && node.children.map(child => renderNode(child, depth + 1))] }, node.id));
|
|
124
113
|
};
|
|
125
|
-
return (_jsxs(_Fragment, { children: [_jsxs("div", { style: Object.assign(Object.assign({}, tree.panel), { width: collapsed ? 'auto' : 224 }), onClick: () => setContextMenu(null), children: [_jsxs("div", { style: base.header, onClick: () => setCollapsed(!collapsed), children: [_jsx("span", { children: "Scene" }), _jsx("
|
|
114
|
+
return (_jsxs(_Fragment, { children: [_jsxs("div", { style: Object.assign(Object.assign({}, tree.panel), { width: collapsed ? 'auto' : 224 }), onClick: () => { setContextMenu(null); setFileMenuOpen(false); }, children: [_jsxs("div", { style: base.header, children: [_jsxs("div", { style: { display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }, onClick: () => setCollapsed(!collapsed), children: [_jsx("span", { children: collapsed ? '▶' : '▼' }), _jsx("span", { children: "Scene" })] }), !collapsed && (_jsxs("div", { style: { display: 'flex', alignItems: 'center', gap: 4 }, children: [_jsx("button", { style: Object.assign(Object.assign({}, base.btn), { padding: '2px 6px', fontSize: 10, opacity: canUndo ? 1 : 0.4 }), onClick: (e) => { e.stopPropagation(); onUndo === null || onUndo === void 0 ? void 0 : onUndo(); }, disabled: !canUndo, title: "Undo", children: "\u21B6" }), _jsx("button", { style: Object.assign(Object.assign({}, base.btn), { padding: '2px 6px', fontSize: 10, opacity: canRedo ? 1 : 0.4 }), onClick: (e) => { e.stopPropagation(); onRedo === null || onRedo === void 0 ? void 0 : onRedo(); }, disabled: !canRedo, title: "Redo", children: "\u21B7" }), _jsxs("div", { style: { position: 'relative' }, children: [_jsx("button", { style: Object.assign(Object.assign({}, base.btn), { padding: '2px 6px', fontSize: 10 }), onClick: (e) => { e.stopPropagation(); setFileMenuOpen(!fileMenuOpen); }, title: "File", children: "\u22EE" }), fileMenuOpen && (_jsxs("div", { style: Object.assign(Object.assign({}, menu.container), { top: 28, right: 0 }), onClick: (e) => e.stopPropagation(), children: [_jsx("button", { style: menu.item, onClick: () => { onLoad === null || onLoad === void 0 ? void 0 : onLoad(); setFileMenuOpen(false); }, children: "\uD83D\uDCE5 Load" }), _jsx("button", { style: menu.item, onClick: () => { onSave === null || onSave === void 0 ? void 0 : onSave(); setFileMenuOpen(false); }, children: "\uD83D\uDCBE Save" })] }))] })] }))] }), !collapsed && _jsx("div", { style: tree.scroll, children: renderNode(prefabData.root) })] }), contextMenu && (_jsxs("div", { style: Object.assign(Object.assign({}, menu.container), { top: contextMenu.y, left: contextMenu.x }), onClick: (e) => e.stopPropagation(), onPointerLeave: () => setContextMenu(null), children: [_jsx("button", { style: menu.item, onClick: () => handleAddChild(contextMenu.nodeId), children: "Add Child" }), contextMenu.nodeId !== prefabData.root.id && (_jsxs(_Fragment, { children: [_jsx("button", { style: menu.item, onClick: () => handleDuplicate(contextMenu.nodeId), children: "Duplicate" }), _jsx("button", { style: Object.assign(Object.assign({}, menu.item), menu.danger), onClick: () => handleDelete(contextMenu.nodeId), children: "Delete" })] }))] }))] }));
|
|
126
115
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Dispatch, SetStateAction } from 'react';
|
|
2
2
|
import { Prefab } from "./types";
|
|
3
|
-
declare function EditorUI({ prefabData, setPrefabData, selectedId, setSelectedId, transformMode, setTransformMode, basePath }: {
|
|
3
|
+
declare function EditorUI({ prefabData, setPrefabData, selectedId, setSelectedId, transformMode, setTransformMode, basePath, onSave, onLoad, onUndo, onRedo, canUndo, canRedo }: {
|
|
4
4
|
prefabData?: Prefab;
|
|
5
5
|
setPrefabData?: Dispatch<SetStateAction<Prefab>>;
|
|
6
6
|
selectedId: string | null;
|
|
@@ -8,5 +8,11 @@ declare function EditorUI({ prefabData, setPrefabData, selectedId, setSelectedId
|
|
|
8
8
|
transformMode: "translate" | "rotate" | "scale";
|
|
9
9
|
setTransformMode: (m: "translate" | "rotate" | "scale") => void;
|
|
10
10
|
basePath?: string;
|
|
11
|
+
onSave?: () => void;
|
|
12
|
+
onLoad?: () => void;
|
|
13
|
+
onUndo?: () => void;
|
|
14
|
+
onRedo?: () => void;
|
|
15
|
+
canUndo?: boolean;
|
|
16
|
+
canRedo?: boolean;
|
|
11
17
|
}): import("react/jsx-runtime").JSX.Element;
|
|
12
18
|
export default EditorUI;
|
|
@@ -15,7 +15,7 @@ import EditorTree from './EditorTree';
|
|
|
15
15
|
import { getAllComponents } from './components/ComponentRegistry';
|
|
16
16
|
import { base, inspector } from './styles';
|
|
17
17
|
import { findNode, updateNode, deleteNode } from './utils';
|
|
18
|
-
function EditorUI({ prefabData, setPrefabData, selectedId, setSelectedId, transformMode, setTransformMode, basePath }) {
|
|
18
|
+
function EditorUI({ prefabData, setPrefabData, selectedId, setSelectedId, transformMode, setTransformMode, basePath, onSave, onLoad, onUndo, onRedo, canUndo, canRedo }) {
|
|
19
19
|
const [collapsed, setCollapsed] = useState(false);
|
|
20
20
|
const updateNodeHandler = (updater) => {
|
|
21
21
|
if (!prefabData || !setPrefabData || !selectedId)
|
|
@@ -29,11 +29,12 @@ function EditorUI({ prefabData, setPrefabData, selectedId, setSelectedId, transf
|
|
|
29
29
|
setSelectedId(null);
|
|
30
30
|
};
|
|
31
31
|
const selectedNode = selectedId && prefabData ? findNode(prefabData.root, selectedId) : null;
|
|
32
|
-
return _jsxs(_Fragment, { children: [_jsx("style", { children:
|
|
32
|
+
return _jsxs(_Fragment, { children: [_jsx("style", { children: `
|
|
33
|
+
.prefab-scroll::-webkit-scrollbar { width: 8px; height: 8px; }
|
|
33
34
|
.prefab-scroll::-webkit-scrollbar-track { background: transparent; }
|
|
34
35
|
.prefab-scroll::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.06); border-radius: 8px; }
|
|
35
36
|
.prefab-scroll { scrollbar-width: thin; scrollbar-color: rgba(255,255,255,0.06) transparent; }
|
|
36
|
-
` }), _jsxs("div", { style: inspector.panel, children: [_jsxs("div", { style: base.header, onClick: () => setCollapsed(!collapsed), children: [_jsx("span", { children: "Inspector" }), _jsx("span", { children: collapsed ? '◀' : '▼' })] }), !collapsed && selectedNode && (_jsx(NodeInspector, { node: selectedNode, updateNode: updateNodeHandler, deleteNode: deleteNodeHandler, transformMode: transformMode, setTransformMode: setTransformMode, basePath: basePath }))] }), _jsx("div", { style: { position: 'absolute', top: 8, left: 8, zIndex: 20 }, children: _jsx(EditorTree, { prefabData: prefabData, setPrefabData: setPrefabData, selectedId: selectedId, setSelectedId: setSelectedId }) })] });
|
|
37
|
+
` }), _jsxs("div", { style: inspector.panel, children: [_jsxs("div", { style: base.header, onClick: () => setCollapsed(!collapsed), children: [_jsx("span", { children: "Inspector" }), _jsx("span", { children: collapsed ? '◀' : '▼' })] }), !collapsed && selectedNode && (_jsx(NodeInspector, { node: selectedNode, updateNode: updateNodeHandler, deleteNode: deleteNodeHandler, transformMode: transformMode, setTransformMode: setTransformMode, basePath: basePath }))] }), _jsx("div", { style: { position: 'absolute', top: 8, left: 8, zIndex: 20 }, children: _jsx(EditorTree, { prefabData: prefabData, setPrefabData: setPrefabData, selectedId: selectedId, setSelectedId: setSelectedId, onSave: onSave, onLoad: onLoad, onUndo: onUndo, onRedo: onRedo, canUndo: canUndo, canRedo: canRedo }) })] });
|
|
37
38
|
}
|
|
38
39
|
function NodeInspector({ node, updateNode, deleteNode, transformMode, setTransformMode, basePath }) {
|
|
39
40
|
var _a;
|
|
@@ -15,65 +15,54 @@ import PrefabRoot from "./PrefabRoot";
|
|
|
15
15
|
import { Physics } from "@react-three/rapier";
|
|
16
16
|
import EditorUI from "./EditorUI";
|
|
17
17
|
import { base, toolbar } from "./styles";
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
type: "Transform",
|
|
28
|
-
properties: { position: [0, 0, 0], rotation: [0, 0, 0], scale: [1, 1, 1] }
|
|
29
|
-
}
|
|
18
|
+
const DEFAULT_PREFAB = {
|
|
19
|
+
id: "prefab-default",
|
|
20
|
+
name: "New Prefab",
|
|
21
|
+
root: {
|
|
22
|
+
id: "root",
|
|
23
|
+
components: {
|
|
24
|
+
transform: {
|
|
25
|
+
type: "Transform",
|
|
26
|
+
properties: { position: [0, 0, 0], rotation: [0, 0, 0], scale: [1, 1, 1] }
|
|
30
27
|
}
|
|
31
28
|
}
|
|
32
|
-
}
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
const PrefabEditor = ({ basePath, initialPrefab, onPrefabChange, children }) => {
|
|
32
|
+
const [editMode, setEditMode] = useState(true);
|
|
33
|
+
const [loadedPrefab, setLoadedPrefab] = useState(initialPrefab !== null && initialPrefab !== void 0 ? initialPrefab : DEFAULT_PREFAB);
|
|
33
34
|
const [selectedId, setSelectedId] = useState(null);
|
|
34
35
|
const [transformMode, setTransformMode] = useState("translate");
|
|
35
|
-
|
|
36
|
+
const [history, setHistory] = useState([loadedPrefab]);
|
|
37
|
+
const [historyIndex, setHistoryIndex] = useState(0);
|
|
38
|
+
const throttleRef = useRef(null);
|
|
39
|
+
const lastDataRef = useRef(JSON.stringify(loadedPrefab));
|
|
36
40
|
useEffect(() => {
|
|
37
|
-
if (initialPrefab)
|
|
41
|
+
if (initialPrefab)
|
|
38
42
|
setLoadedPrefab(initialPrefab);
|
|
39
|
-
}
|
|
40
43
|
}, [initialPrefab]);
|
|
41
|
-
// Wrapper to update prefab and notify parent
|
|
42
44
|
const updatePrefab = (newPrefab) => {
|
|
43
45
|
setLoadedPrefab(newPrefab);
|
|
44
46
|
const resolved = typeof newPrefab === 'function' ? newPrefab(loadedPrefab) : newPrefab;
|
|
45
47
|
onPrefabChange === null || onPrefabChange === void 0 ? void 0 : onPrefabChange(resolved);
|
|
46
48
|
};
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
const throttleRef = useRef(null);
|
|
53
|
-
const lastDataRef = useRef(JSON.stringify(currentData));
|
|
54
|
-
const undo = () => {
|
|
55
|
-
if (historyIndex > 0) {
|
|
56
|
-
const newIndex = historyIndex - 1;
|
|
57
|
-
setHistoryIndex(newIndex);
|
|
58
|
-
lastDataRef.current = JSON.stringify(history[newIndex]);
|
|
59
|
-
onDataChange(history[newIndex]);
|
|
60
|
-
}
|
|
61
|
-
};
|
|
62
|
-
const redo = () => {
|
|
63
|
-
if (historyIndex < history.length - 1) {
|
|
64
|
-
const newIndex = historyIndex + 1;
|
|
65
|
-
setHistoryIndex(newIndex);
|
|
66
|
-
lastDataRef.current = JSON.stringify(history[newIndex]);
|
|
67
|
-
onDataChange(history[newIndex]);
|
|
68
|
-
}
|
|
49
|
+
const applyHistory = (index) => {
|
|
50
|
+
setHistoryIndex(index);
|
|
51
|
+
lastDataRef.current = JSON.stringify(history[index]);
|
|
52
|
+
setLoadedPrefab(history[index]);
|
|
53
|
+
onPrefabChange === null || onPrefabChange === void 0 ? void 0 : onPrefabChange(history[index]);
|
|
69
54
|
};
|
|
55
|
+
const undo = () => historyIndex > 0 && applyHistory(historyIndex - 1);
|
|
56
|
+
const redo = () => historyIndex < history.length - 1 && applyHistory(historyIndex + 1);
|
|
70
57
|
useEffect(() => {
|
|
71
58
|
const handleKeyDown = (e) => {
|
|
72
|
-
if ((e.ctrlKey || e.metaKey)
|
|
59
|
+
if (!(e.ctrlKey || e.metaKey))
|
|
60
|
+
return;
|
|
61
|
+
if (e.key === 'z' && !e.shiftKey) {
|
|
73
62
|
e.preventDefault();
|
|
74
63
|
undo();
|
|
75
64
|
}
|
|
76
|
-
else if ((e.
|
|
65
|
+
else if ((e.shiftKey && e.key === 'z') || e.key === 'y') {
|
|
77
66
|
e.preventDefault();
|
|
78
67
|
redo();
|
|
79
68
|
}
|
|
@@ -82,7 +71,7 @@ const SaveDataPanel = ({ currentData, onDataChange, editMode, onEditModeChange }
|
|
|
82
71
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
83
72
|
}, [historyIndex, history]);
|
|
84
73
|
useEffect(() => {
|
|
85
|
-
const currentStr = JSON.stringify(
|
|
74
|
+
const currentStr = JSON.stringify(loadedPrefab);
|
|
86
75
|
if (currentStr === lastDataRef.current)
|
|
87
76
|
return;
|
|
88
77
|
if (throttleRef.current)
|
|
@@ -90,66 +79,56 @@ const SaveDataPanel = ({ currentData, onDataChange, editMode, onEditModeChange }
|
|
|
90
79
|
throttleRef.current = setTimeout(() => {
|
|
91
80
|
lastDataRef.current = currentStr;
|
|
92
81
|
setHistory(prev => {
|
|
93
|
-
const newHistory = [...prev.slice(0, historyIndex + 1),
|
|
82
|
+
const newHistory = [...prev.slice(0, historyIndex + 1), loadedPrefab];
|
|
94
83
|
return newHistory.length > 50 ? newHistory.slice(1) : newHistory;
|
|
95
84
|
});
|
|
96
85
|
setHistoryIndex(prev => Math.min(prev + 1, 49));
|
|
97
86
|
}, 500);
|
|
98
|
-
return () => {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
};
|
|
102
|
-
}, [currentData]);
|
|
87
|
+
return () => { if (throttleRef.current)
|
|
88
|
+
clearTimeout(throttleRef.current); };
|
|
89
|
+
}, [loadedPrefab]);
|
|
103
90
|
const handleLoad = () => __awaiter(void 0, void 0, void 0, function* () {
|
|
104
91
|
const prefab = yield loadJson();
|
|
105
92
|
if (prefab) {
|
|
106
|
-
|
|
93
|
+
setLoadedPrefab(prefab);
|
|
94
|
+
onPrefabChange === null || onPrefabChange === void 0 ? void 0 : onPrefabChange(prefab);
|
|
107
95
|
setHistory([prefab]);
|
|
108
96
|
setHistoryIndex(0);
|
|
109
97
|
lastDataRef.current = JSON.stringify(prefab);
|
|
110
98
|
}
|
|
111
99
|
});
|
|
112
|
-
|
|
113
|
-
const canRedo = historyIndex < history.length - 1;
|
|
114
|
-
return _jsxs("div", { style: toolbar.panel, children: [_jsx("button", { style: base.btn, onClick: () => onEditModeChange(!editMode), children: editMode ? "▶" : "⏸" }), _jsx("div", { style: toolbar.divider }), _jsx("button", { style: Object.assign(Object.assign({}, base.btn), (canUndo ? {} : toolbar.disabled)), onClick: undo, disabled: !canUndo, children: "\u21B6" }), _jsx("button", { style: Object.assign(Object.assign({}, base.btn), (canRedo ? {} : toolbar.disabled)), onClick: redo, disabled: !canRedo, children: "\u21B7" }), _jsx("div", { style: toolbar.divider }), _jsx("button", { style: base.btn, onClick: handleLoad, children: "\uD83D\uDCE5" }), _jsx("button", { style: base.btn, onClick: () => saveJson(currentData, "prefab"), children: "\uD83D\uDCBE" })] });
|
|
100
|
+
return _jsxs(_Fragment, { children: [_jsx(GameCanvas, { children: _jsxs(Physics, { paused: editMode, children: [_jsx("ambientLight", { intensity: 1.5 }), _jsx("gridHelper", { args: [10, 10], position: [0, -1, 0] }), _jsx(PrefabRoot, { data: loadedPrefab, editMode: editMode, onPrefabChange: updatePrefab, selectedId: selectedId, onSelect: setSelectedId, transformMode: transformMode, basePath: basePath }), children] }) }), _jsx("div", { style: toolbar.panel, children: _jsx("button", { style: base.btn, onClick: () => setEditMode(!editMode), children: editMode ? "▶" : "⏸" }) }), editMode && _jsx(EditorUI, { prefabData: loadedPrefab, setPrefabData: updatePrefab, selectedId: selectedId, setSelectedId: setSelectedId, transformMode: transformMode, setTransformMode: setTransformMode, basePath: basePath, onSave: () => saveJson(loadedPrefab, "prefab"), onLoad: handleLoad, onUndo: undo, onRedo: redo, canUndo: historyIndex > 0, canRedo: historyIndex < history.length - 1 })] });
|
|
115
101
|
};
|
|
116
102
|
const saveJson = (data, filename) => {
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
document.body.appendChild(downloadAnchorNode);
|
|
122
|
-
downloadAnchorNode.click();
|
|
123
|
-
downloadAnchorNode.remove();
|
|
103
|
+
const a = document.createElement('a');
|
|
104
|
+
a.href = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(data, null, 2));
|
|
105
|
+
a.download = `${filename || 'prefab'}.json`;
|
|
106
|
+
a.click();
|
|
124
107
|
};
|
|
125
|
-
const loadJson = () =>
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
108
|
+
const loadJson = () => new Promise(resolve => {
|
|
109
|
+
const input = document.createElement('input');
|
|
110
|
+
input.type = 'file';
|
|
111
|
+
input.accept = '.json,application/json';
|
|
112
|
+
input.onchange = e => {
|
|
113
|
+
var _a;
|
|
114
|
+
const file = (_a = e.target.files) === null || _a === void 0 ? void 0 : _a[0];
|
|
115
|
+
if (!file)
|
|
116
|
+
return resolve(undefined);
|
|
117
|
+
const reader = new FileReader();
|
|
118
|
+
reader.onload = e => {
|
|
131
119
|
var _a;
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
const json = JSON.parse(text);
|
|
142
|
-
resolve(json);
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
catch (err) {
|
|
146
|
-
console.error('Error parsing prefab JSON:', err);
|
|
147
|
-
resolve(undefined);
|
|
148
|
-
}
|
|
149
|
-
};
|
|
150
|
-
reader.readAsText(file);
|
|
120
|
+
try {
|
|
121
|
+
const text = (_a = e.target) === null || _a === void 0 ? void 0 : _a.result;
|
|
122
|
+
if (typeof text === 'string')
|
|
123
|
+
resolve(JSON.parse(text));
|
|
124
|
+
}
|
|
125
|
+
catch (err) {
|
|
126
|
+
console.error('Error parsing prefab JSON:', err);
|
|
127
|
+
resolve(undefined);
|
|
128
|
+
}
|
|
151
129
|
};
|
|
152
|
-
|
|
153
|
-
}
|
|
130
|
+
reader.readAsText(file);
|
|
131
|
+
};
|
|
132
|
+
input.click();
|
|
154
133
|
});
|
|
155
134
|
export default PrefabEditor;
|
package/package.json
CHANGED
|
@@ -4,16 +4,34 @@ import { getComponent } from './components/ComponentRegistry';
|
|
|
4
4
|
import { base, tree, menu } from './styles';
|
|
5
5
|
import { findNode, findParent, deleteNode, cloneNode, updateNodeById } from './utils';
|
|
6
6
|
|
|
7
|
-
export default function EditorTree({
|
|
7
|
+
export default function EditorTree({
|
|
8
|
+
prefabData,
|
|
9
|
+
setPrefabData,
|
|
10
|
+
selectedId,
|
|
11
|
+
setSelectedId,
|
|
12
|
+
onSave,
|
|
13
|
+
onLoad,
|
|
14
|
+
onUndo,
|
|
15
|
+
onRedo,
|
|
16
|
+
canUndo,
|
|
17
|
+
canRedo
|
|
18
|
+
}: {
|
|
8
19
|
prefabData?: Prefab;
|
|
9
20
|
setPrefabData?: Dispatch<SetStateAction<Prefab>>;
|
|
10
21
|
selectedId: string | null;
|
|
11
22
|
setSelectedId: Dispatch<SetStateAction<string | null>>;
|
|
23
|
+
onSave?: () => void;
|
|
24
|
+
onLoad?: () => void;
|
|
25
|
+
onUndo?: () => void;
|
|
26
|
+
onRedo?: () => void;
|
|
27
|
+
canUndo?: boolean;
|
|
28
|
+
canRedo?: boolean;
|
|
12
29
|
}) {
|
|
13
30
|
const [contextMenu, setContextMenu] = useState<{ x: number, y: number, nodeId: string } | null>(null);
|
|
14
31
|
const [draggedId, setDraggedId] = useState<string | null>(null);
|
|
15
32
|
const [collapsedIds, setCollapsedIds] = useState<Set<string>>(new Set());
|
|
16
33
|
const [collapsed, setCollapsed] = useState(false);
|
|
34
|
+
const [fileMenuOpen, setFileMenuOpen] = useState(false);
|
|
17
35
|
|
|
18
36
|
if (!prefabData || !setPrefabData) return null;
|
|
19
37
|
|
|
@@ -32,24 +50,21 @@ export default function EditorTree({ prefabData, setPrefabData, selectedId, setS
|
|
|
32
50
|
});
|
|
33
51
|
};
|
|
34
52
|
|
|
35
|
-
// Actions
|
|
36
53
|
const handleAddChild = (parentId: string) => {
|
|
37
|
-
const newNode: GameObject = {
|
|
38
|
-
id: crypto.randomUUID(),
|
|
39
|
-
name: "New Node",
|
|
40
|
-
components: {
|
|
41
|
-
transform: {
|
|
42
|
-
type: "Transform",
|
|
43
|
-
properties: { ...getComponent('Transform')?.defaultProperties }
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
};
|
|
47
|
-
|
|
48
54
|
setPrefabData(prev => ({
|
|
49
55
|
...prev,
|
|
50
56
|
root: updateNodeById(prev.root, parentId, parent => ({
|
|
51
57
|
...parent,
|
|
52
|
-
children: [...(parent.children ?? []),
|
|
58
|
+
children: [...(parent.children ?? []), {
|
|
59
|
+
id: crypto.randomUUID(),
|
|
60
|
+
name: "New Node",
|
|
61
|
+
components: {
|
|
62
|
+
transform: {
|
|
63
|
+
type: "Transform",
|
|
64
|
+
properties: { ...getComponent('Transform')?.defaultProperties }
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}]
|
|
53
68
|
}))
|
|
54
69
|
}));
|
|
55
70
|
setContextMenu(null);
|
|
@@ -57,41 +72,30 @@ export default function EditorTree({ prefabData, setPrefabData, selectedId, setS
|
|
|
57
72
|
|
|
58
73
|
const handleDuplicate = (nodeId: string) => {
|
|
59
74
|
if (nodeId === prefabData.root.id) return;
|
|
60
|
-
|
|
61
75
|
setPrefabData(prev => {
|
|
62
76
|
const node = findNode(prev.root, nodeId);
|
|
63
77
|
const parent = findParent(prev.root, nodeId);
|
|
64
78
|
if (!node || !parent) return prev;
|
|
65
|
-
|
|
66
|
-
const clone = cloneNode(node);
|
|
67
|
-
|
|
68
79
|
return {
|
|
69
80
|
...prev,
|
|
70
81
|
root: updateNodeById(prev.root, parent.id, p => ({
|
|
71
82
|
...p,
|
|
72
|
-
children: [...(p.children ?? []),
|
|
83
|
+
children: [...(p.children ?? []), cloneNode(node)]
|
|
73
84
|
}))
|
|
74
85
|
};
|
|
75
86
|
});
|
|
76
|
-
|
|
77
87
|
setContextMenu(null);
|
|
78
88
|
};
|
|
79
89
|
|
|
80
|
-
|
|
81
90
|
const handleDelete = (nodeId: string) => {
|
|
82
91
|
if (nodeId === prefabData.root.id) return;
|
|
83
|
-
|
|
84
92
|
setPrefabData(prev => ({ ...prev, root: deleteNode(prev.root, nodeId)! }));
|
|
85
93
|
if (selectedId === nodeId) setSelectedId(null);
|
|
86
94
|
setContextMenu(null);
|
|
87
95
|
};
|
|
88
96
|
|
|
89
|
-
// Drag and Drop
|
|
90
97
|
const handleDragStart = (e: React.DragEvent, id: string) => {
|
|
91
|
-
if (id === prefabData.root.id)
|
|
92
|
-
e.preventDefault();
|
|
93
|
-
return;
|
|
94
|
-
}
|
|
98
|
+
if (id === prefabData.root.id) return e.preventDefault();
|
|
95
99
|
e.dataTransfer.effectAllowed = "move";
|
|
96
100
|
setDraggedId(id);
|
|
97
101
|
};
|
|
@@ -106,22 +110,16 @@ export default function EditorTree({ prefabData, setPrefabData, selectedId, setS
|
|
|
106
110
|
const handleDrop = (e: React.DragEvent, targetId: string) => {
|
|
107
111
|
if (!draggedId || draggedId === targetId) return;
|
|
108
112
|
e.preventDefault();
|
|
109
|
-
|
|
110
113
|
setPrefabData(prev => {
|
|
111
114
|
const draggedNode = findNode(prev.root, draggedId);
|
|
112
115
|
const oldParent = findParent(prev.root, draggedId);
|
|
113
|
-
if (!draggedNode || !oldParent) return prev;
|
|
114
|
-
|
|
115
|
-
// Prevent dropping into own subtree
|
|
116
|
-
if (findNode(draggedNode, targetId)) return prev;
|
|
116
|
+
if (!draggedNode || !oldParent || findNode(draggedNode, targetId)) return prev;
|
|
117
117
|
|
|
118
|
-
// 1. Remove from old parent
|
|
119
118
|
let root = updateNodeById(prev.root, oldParent.id, p => ({
|
|
120
119
|
...p,
|
|
121
120
|
children: p.children!.filter(c => c.id !== draggedId)
|
|
122
121
|
}));
|
|
123
122
|
|
|
124
|
-
// 2. Add to new parent
|
|
125
123
|
root = updateNodeById(root, targetId, t => ({
|
|
126
124
|
...t,
|
|
127
125
|
children: [...(t.children ?? []), draggedNode]
|
|
@@ -129,7 +127,6 @@ export default function EditorTree({ prefabData, setPrefabData, selectedId, setS
|
|
|
129
127
|
|
|
130
128
|
return { ...prev, root };
|
|
131
129
|
});
|
|
132
|
-
|
|
133
130
|
setDraggedId(null);
|
|
134
131
|
};
|
|
135
132
|
|
|
@@ -182,10 +179,60 @@ export default function EditorTree({ prefabData, setPrefabData, selectedId, setS
|
|
|
182
179
|
|
|
183
180
|
return (
|
|
184
181
|
<>
|
|
185
|
-
<div style={{ ...tree.panel, width: collapsed ? 'auto' : 224 }} onClick={() => setContextMenu(null)}>
|
|
186
|
-
<div style={base.header}
|
|
187
|
-
<
|
|
188
|
-
|
|
182
|
+
<div style={{ ...tree.panel, width: collapsed ? 'auto' : 224 }} onClick={() => { setContextMenu(null); setFileMenuOpen(false); }}>
|
|
183
|
+
<div style={base.header}>
|
|
184
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }} onClick={() => setCollapsed(!collapsed)}>
|
|
185
|
+
<span>{collapsed ? '▶' : '▼'}</span>
|
|
186
|
+
<span>Scene</span>
|
|
187
|
+
</div>
|
|
188
|
+
{!collapsed && (
|
|
189
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
|
190
|
+
<button
|
|
191
|
+
style={{ ...base.btn, padding: '2px 6px', fontSize: 10, opacity: canUndo ? 1 : 0.4 }}
|
|
192
|
+
onClick={(e) => { e.stopPropagation(); onUndo?.(); }}
|
|
193
|
+
disabled={!canUndo}
|
|
194
|
+
title="Undo"
|
|
195
|
+
>
|
|
196
|
+
↶
|
|
197
|
+
</button>
|
|
198
|
+
<button
|
|
199
|
+
style={{ ...base.btn, padding: '2px 6px', fontSize: 10, opacity: canRedo ? 1 : 0.4 }}
|
|
200
|
+
onClick={(e) => { e.stopPropagation(); onRedo?.(); }}
|
|
201
|
+
disabled={!canRedo}
|
|
202
|
+
title="Redo"
|
|
203
|
+
>
|
|
204
|
+
↷
|
|
205
|
+
</button>
|
|
206
|
+
<div style={{ position: 'relative' }}>
|
|
207
|
+
<button
|
|
208
|
+
style={{ ...base.btn, padding: '2px 6px', fontSize: 10 }}
|
|
209
|
+
onClick={(e) => { e.stopPropagation(); setFileMenuOpen(!fileMenuOpen); }}
|
|
210
|
+
title="File"
|
|
211
|
+
>
|
|
212
|
+
⋮
|
|
213
|
+
</button>
|
|
214
|
+
{fileMenuOpen && (
|
|
215
|
+
<div
|
|
216
|
+
style={{ ...menu.container, top: 28, right: 0 }}
|
|
217
|
+
onClick={(e) => e.stopPropagation()}
|
|
218
|
+
>
|
|
219
|
+
<button
|
|
220
|
+
style={menu.item}
|
|
221
|
+
onClick={() => { onLoad?.(); setFileMenuOpen(false); }}
|
|
222
|
+
>
|
|
223
|
+
📥 Load
|
|
224
|
+
</button>
|
|
225
|
+
<button
|
|
226
|
+
style={menu.item}
|
|
227
|
+
onClick={() => { onSave?.(); setFileMenuOpen(false); }}
|
|
228
|
+
>
|
|
229
|
+
💾 Save
|
|
230
|
+
</button>
|
|
231
|
+
</div>
|
|
232
|
+
)}
|
|
233
|
+
</div>
|
|
234
|
+
</div>
|
|
235
|
+
)}
|
|
189
236
|
</div>
|
|
190
237
|
{!collapsed && <div style={tree.scroll}>{renderNode(prefabData.root)}</div>}
|
|
191
238
|
</div>
|
|
@@ -5,7 +5,21 @@ import { getAllComponents } from './components/ComponentRegistry';
|
|
|
5
5
|
import { base, inspector } from './styles';
|
|
6
6
|
import { findNode, updateNode, deleteNode } from './utils';
|
|
7
7
|
|
|
8
|
-
function EditorUI({
|
|
8
|
+
function EditorUI({
|
|
9
|
+
prefabData,
|
|
10
|
+
setPrefabData,
|
|
11
|
+
selectedId,
|
|
12
|
+
setSelectedId,
|
|
13
|
+
transformMode,
|
|
14
|
+
setTransformMode,
|
|
15
|
+
basePath,
|
|
16
|
+
onSave,
|
|
17
|
+
onLoad,
|
|
18
|
+
onUndo,
|
|
19
|
+
onRedo,
|
|
20
|
+
canUndo,
|
|
21
|
+
canRedo
|
|
22
|
+
}: {
|
|
9
23
|
prefabData?: Prefab;
|
|
10
24
|
setPrefabData?: Dispatch<SetStateAction<Prefab>>;
|
|
11
25
|
selectedId: string | null;
|
|
@@ -13,6 +27,12 @@ function EditorUI({ prefabData, setPrefabData, selectedId, setSelectedId, transf
|
|
|
13
27
|
transformMode: "translate" | "rotate" | "scale";
|
|
14
28
|
setTransformMode: (m: "translate" | "rotate" | "scale") => void;
|
|
15
29
|
basePath?: string;
|
|
30
|
+
onSave?: () => void;
|
|
31
|
+
onLoad?: () => void;
|
|
32
|
+
onUndo?: () => void;
|
|
33
|
+
onRedo?: () => void;
|
|
34
|
+
canUndo?: boolean;
|
|
35
|
+
canRedo?: boolean;
|
|
16
36
|
}) {
|
|
17
37
|
const [collapsed, setCollapsed] = useState(false);
|
|
18
38
|
|
|
@@ -33,11 +53,12 @@ function EditorUI({ prefabData, setPrefabData, selectedId, setSelectedId, transf
|
|
|
33
53
|
const selectedNode = selectedId && prefabData ? findNode(prefabData.root, selectedId) : null;
|
|
34
54
|
|
|
35
55
|
return <>
|
|
36
|
-
<style>{
|
|
56
|
+
<style>{`
|
|
57
|
+
.prefab-scroll::-webkit-scrollbar { width: 8px; height: 8px; }
|
|
37
58
|
.prefab-scroll::-webkit-scrollbar-track { background: transparent; }
|
|
38
59
|
.prefab-scroll::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.06); border-radius: 8px; }
|
|
39
60
|
.prefab-scroll { scrollbar-width: thin; scrollbar-color: rgba(255,255,255,0.06) transparent; }
|
|
40
|
-
`}</style>
|
|
61
|
+
`}</style>
|
|
41
62
|
<div style={inspector.panel}>
|
|
42
63
|
<div style={base.header} onClick={() => setCollapsed(!collapsed)}>
|
|
43
64
|
<span>Inspector</span>
|
|
@@ -51,7 +72,6 @@ function EditorUI({ prefabData, setPrefabData, selectedId, setSelectedId, transf
|
|
|
51
72
|
transformMode={transformMode}
|
|
52
73
|
setTransformMode={setTransformMode}
|
|
53
74
|
basePath={basePath}
|
|
54
|
-
// add class to make scrollbar gutter transparent via CSS above
|
|
55
75
|
/>
|
|
56
76
|
)}
|
|
57
77
|
</div>
|
|
@@ -61,13 +81,26 @@ function EditorUI({ prefabData, setPrefabData, selectedId, setSelectedId, transf
|
|
|
61
81
|
setPrefabData={setPrefabData}
|
|
62
82
|
selectedId={selectedId}
|
|
63
83
|
setSelectedId={setSelectedId}
|
|
84
|
+
onSave={onSave}
|
|
85
|
+
onLoad={onLoad}
|
|
86
|
+
onUndo={onUndo}
|
|
87
|
+
onRedo={onRedo}
|
|
88
|
+
canUndo={canUndo}
|
|
89
|
+
canRedo={canRedo}
|
|
64
90
|
/>
|
|
65
91
|
</div>
|
|
66
92
|
</>;
|
|
67
93
|
}
|
|
68
94
|
|
|
69
95
|
|
|
70
|
-
function NodeInspector({
|
|
96
|
+
function NodeInspector({
|
|
97
|
+
node,
|
|
98
|
+
updateNode,
|
|
99
|
+
deleteNode,
|
|
100
|
+
transformMode,
|
|
101
|
+
setTransformMode,
|
|
102
|
+
basePath
|
|
103
|
+
}: {
|
|
71
104
|
node: GameObjectType;
|
|
72
105
|
updateNode: (updater: (n: GameObjectType) => GameObjectType) => void;
|
|
73
106
|
deleteNode: () => void;
|
|
@@ -8,202 +8,162 @@ import { Physics } from "@react-three/rapier";
|
|
|
8
8
|
import EditorUI from "./EditorUI";
|
|
9
9
|
import { base, toolbar } from "./styles";
|
|
10
10
|
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
type: "Transform",
|
|
21
|
-
properties: { position: [0, 0, 0], rotation: [0, 0, 0], scale: [1, 1, 1] }
|
|
22
|
-
}
|
|
11
|
+
const DEFAULT_PREFAB: Prefab = {
|
|
12
|
+
id: "prefab-default",
|
|
13
|
+
name: "New Prefab",
|
|
14
|
+
root: {
|
|
15
|
+
id: "root",
|
|
16
|
+
components: {
|
|
17
|
+
transform: {
|
|
18
|
+
type: "Transform",
|
|
19
|
+
properties: { position: [0, 0, 0], rotation: [0, 0, 0], scale: [1, 1, 1] }
|
|
23
20
|
}
|
|
24
21
|
}
|
|
25
|
-
}
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const PrefabEditor = ({ basePath, initialPrefab, onPrefabChange, children }: {
|
|
26
|
+
basePath?: string;
|
|
27
|
+
initialPrefab?: Prefab;
|
|
28
|
+
onPrefabChange?: (prefab: Prefab) => void;
|
|
29
|
+
children?: React.ReactNode;
|
|
30
|
+
}) => {
|
|
31
|
+
const [editMode, setEditMode] = useState(true);
|
|
32
|
+
const [loadedPrefab, setLoadedPrefab] = useState<Prefab>(initialPrefab ?? DEFAULT_PREFAB);
|
|
26
33
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
27
34
|
const [transformMode, setTransformMode] = useState<"translate" | "rotate" | "scale">("translate");
|
|
35
|
+
const [history, setHistory] = useState<Prefab[]>([loadedPrefab]);
|
|
36
|
+
const [historyIndex, setHistoryIndex] = useState(0);
|
|
37
|
+
const throttleRef = useRef<NodeJS.Timeout | null>(null);
|
|
38
|
+
const lastDataRef = useRef(JSON.stringify(loadedPrefab));
|
|
28
39
|
|
|
29
|
-
// Sync internal state with external initialPrefab prop
|
|
30
40
|
useEffect(() => {
|
|
31
|
-
if (initialPrefab)
|
|
32
|
-
setLoadedPrefab(initialPrefab);
|
|
33
|
-
}
|
|
41
|
+
if (initialPrefab) setLoadedPrefab(initialPrefab);
|
|
34
42
|
}, [initialPrefab]);
|
|
35
43
|
|
|
36
|
-
// Wrapper to update prefab and notify parent
|
|
37
44
|
const updatePrefab = (newPrefab: Prefab | ((prev: Prefab) => Prefab)) => {
|
|
38
45
|
setLoadedPrefab(newPrefab);
|
|
39
46
|
const resolved = typeof newPrefab === 'function' ? newPrefab(loadedPrefab) : newPrefab;
|
|
40
47
|
onPrefabChange?.(resolved);
|
|
41
48
|
};
|
|
42
49
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
<PrefabRoot
|
|
49
|
-
data={loadedPrefab}
|
|
50
|
-
editMode={editMode}
|
|
51
|
-
onPrefabChange={updatePrefab}
|
|
52
|
-
selectedId={selectedId}
|
|
53
|
-
onSelect={setSelectedId}
|
|
54
|
-
transformMode={transformMode}
|
|
55
|
-
basePath={basePath}
|
|
56
|
-
/>
|
|
57
|
-
{children}
|
|
58
|
-
</Physics>
|
|
59
|
-
</GameCanvas>
|
|
60
|
-
|
|
61
|
-
<SaveDataPanel
|
|
62
|
-
currentData={loadedPrefab}
|
|
63
|
-
onDataChange={updatePrefab}
|
|
64
|
-
editMode={editMode}
|
|
65
|
-
onEditModeChange={setEditMode}
|
|
66
|
-
/>
|
|
67
|
-
{editMode && <EditorUI
|
|
68
|
-
prefabData={loadedPrefab}
|
|
69
|
-
setPrefabData={updatePrefab}
|
|
70
|
-
selectedId={selectedId}
|
|
71
|
-
setSelectedId={setSelectedId}
|
|
72
|
-
transformMode={transformMode}
|
|
73
|
-
setTransformMode={setTransformMode}
|
|
74
|
-
basePath={basePath}
|
|
75
|
-
/>}
|
|
76
|
-
</>
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
const SaveDataPanel = ({
|
|
80
|
-
currentData,
|
|
81
|
-
onDataChange,
|
|
82
|
-
editMode,
|
|
83
|
-
onEditModeChange
|
|
84
|
-
}: {
|
|
85
|
-
currentData: Prefab;
|
|
86
|
-
onDataChange: (data: Prefab) => void;
|
|
87
|
-
editMode: boolean;
|
|
88
|
-
onEditModeChange: (mode: boolean) => void;
|
|
89
|
-
}) => {
|
|
90
|
-
const [history, setHistory] = useState<Prefab[]>([currentData]);
|
|
91
|
-
const [historyIndex, setHistoryIndex] = useState(0);
|
|
92
|
-
const throttleRef = useRef<NodeJS.Timeout | null>(null);
|
|
93
|
-
const lastDataRef = useRef<string>(JSON.stringify(currentData));
|
|
94
|
-
|
|
95
|
-
const undo = () => {
|
|
96
|
-
if (historyIndex > 0) {
|
|
97
|
-
const newIndex = historyIndex - 1;
|
|
98
|
-
setHistoryIndex(newIndex);
|
|
99
|
-
lastDataRef.current = JSON.stringify(history[newIndex]);
|
|
100
|
-
onDataChange(history[newIndex]);
|
|
101
|
-
}
|
|
50
|
+
const applyHistory = (index: number) => {
|
|
51
|
+
setHistoryIndex(index);
|
|
52
|
+
lastDataRef.current = JSON.stringify(history[index]);
|
|
53
|
+
setLoadedPrefab(history[index]);
|
|
54
|
+
onPrefabChange?.(history[index]);
|
|
102
55
|
};
|
|
103
56
|
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
const newIndex = historyIndex + 1;
|
|
107
|
-
setHistoryIndex(newIndex);
|
|
108
|
-
lastDataRef.current = JSON.stringify(history[newIndex]);
|
|
109
|
-
onDataChange(history[newIndex]);
|
|
110
|
-
}
|
|
111
|
-
};
|
|
57
|
+
const undo = () => historyIndex > 0 && applyHistory(historyIndex - 1);
|
|
58
|
+
const redo = () => historyIndex < history.length - 1 && applyHistory(historyIndex + 1);
|
|
112
59
|
|
|
113
60
|
useEffect(() => {
|
|
114
61
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
115
|
-
if ((e.ctrlKey || e.metaKey)
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
} else if ((e.ctrlKey || e.metaKey) && (e.shiftKey && e.key === 'z' || e.key === 'y')) {
|
|
119
|
-
e.preventDefault();
|
|
120
|
-
redo();
|
|
121
|
-
}
|
|
62
|
+
if (!(e.ctrlKey || e.metaKey)) return;
|
|
63
|
+
if (e.key === 'z' && !e.shiftKey) { e.preventDefault(); undo(); }
|
|
64
|
+
else if ((e.shiftKey && e.key === 'z') || e.key === 'y') { e.preventDefault(); redo(); }
|
|
122
65
|
};
|
|
123
66
|
window.addEventListener('keydown', handleKeyDown);
|
|
124
67
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
125
68
|
}, [historyIndex, history]);
|
|
126
69
|
|
|
127
70
|
useEffect(() => {
|
|
128
|
-
const currentStr = JSON.stringify(
|
|
71
|
+
const currentStr = JSON.stringify(loadedPrefab);
|
|
129
72
|
if (currentStr === lastDataRef.current) return;
|
|
130
|
-
|
|
131
73
|
if (throttleRef.current) clearTimeout(throttleRef.current);
|
|
132
74
|
|
|
133
75
|
throttleRef.current = setTimeout(() => {
|
|
134
76
|
lastDataRef.current = currentStr;
|
|
135
77
|
setHistory(prev => {
|
|
136
|
-
const newHistory = [...prev.slice(0, historyIndex + 1),
|
|
78
|
+
const newHistory = [...prev.slice(0, historyIndex + 1), loadedPrefab];
|
|
137
79
|
return newHistory.length > 50 ? newHistory.slice(1) : newHistory;
|
|
138
80
|
});
|
|
139
81
|
setHistoryIndex(prev => Math.min(prev + 1, 49));
|
|
140
82
|
}, 500);
|
|
141
83
|
|
|
142
|
-
return () => {
|
|
143
|
-
|
|
144
|
-
};
|
|
145
|
-
}, [currentData]);
|
|
84
|
+
return () => { if (throttleRef.current) clearTimeout(throttleRef.current); };
|
|
85
|
+
}, [loadedPrefab]);
|
|
146
86
|
|
|
147
87
|
const handleLoad = async () => {
|
|
148
88
|
const prefab = await loadJson();
|
|
149
89
|
if (prefab) {
|
|
150
|
-
|
|
90
|
+
setLoadedPrefab(prefab);
|
|
91
|
+
onPrefabChange?.(prefab);
|
|
151
92
|
setHistory([prefab]);
|
|
152
93
|
setHistoryIndex(0);
|
|
153
94
|
lastDataRef.current = JSON.stringify(prefab);
|
|
154
95
|
}
|
|
155
96
|
};
|
|
156
97
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
}
|
|
98
|
+
return <>
|
|
99
|
+
<GameCanvas>
|
|
100
|
+
<Physics paused={editMode}>
|
|
101
|
+
<ambientLight intensity={1.5} />
|
|
102
|
+
<gridHelper args={[10, 10]} position={[0, -1, 0]} />
|
|
103
|
+
<PrefabRoot
|
|
104
|
+
data={loadedPrefab}
|
|
105
|
+
editMode={editMode}
|
|
106
|
+
onPrefabChange={updatePrefab}
|
|
107
|
+
selectedId={selectedId}
|
|
108
|
+
onSelect={setSelectedId}
|
|
109
|
+
transformMode={transformMode}
|
|
110
|
+
basePath={basePath}
|
|
111
|
+
/>
|
|
112
|
+
{children}
|
|
113
|
+
</Physics>
|
|
114
|
+
</GameCanvas>
|
|
115
|
+
|
|
116
|
+
<div style={toolbar.panel}>
|
|
117
|
+
<button style={base.btn} onClick={() => setEditMode(!editMode)}>
|
|
118
|
+
{editMode ? "▶" : "⏸"}
|
|
119
|
+
</button>
|
|
120
|
+
</div>
|
|
121
|
+
{editMode && <EditorUI
|
|
122
|
+
prefabData={loadedPrefab}
|
|
123
|
+
setPrefabData={updatePrefab}
|
|
124
|
+
selectedId={selectedId}
|
|
125
|
+
setSelectedId={setSelectedId}
|
|
126
|
+
transformMode={transformMode}
|
|
127
|
+
setTransformMode={setTransformMode}
|
|
128
|
+
basePath={basePath}
|
|
129
|
+
onSave={() => saveJson(loadedPrefab, "prefab")}
|
|
130
|
+
onLoad={handleLoad}
|
|
131
|
+
onUndo={undo}
|
|
132
|
+
onRedo={redo}
|
|
133
|
+
canUndo={historyIndex > 0}
|
|
134
|
+
canRedo={historyIndex < history.length - 1}
|
|
135
|
+
/>}
|
|
136
|
+
</>
|
|
137
|
+
}
|
|
172
138
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
downloadAnchorNode.click();
|
|
180
|
-
downloadAnchorNode.remove();
|
|
139
|
+
|
|
140
|
+
const saveJson = (data: Prefab, filename: string) => {
|
|
141
|
+
const a = document.createElement('a');
|
|
142
|
+
a.href = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(data, null, 2));
|
|
143
|
+
a.download = `${filename || 'prefab'}.json`;
|
|
144
|
+
a.click();
|
|
181
145
|
};
|
|
182
146
|
|
|
183
|
-
const loadJson =
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
} catch (err) {
|
|
200
|
-
console.error('Error parsing prefab JSON:', err);
|
|
201
|
-
resolve(undefined);
|
|
202
|
-
}
|
|
203
|
-
};
|
|
204
|
-
reader.readAsText(file);
|
|
147
|
+
const loadJson = () => new Promise<Prefab | undefined>(resolve => {
|
|
148
|
+
const input = document.createElement('input');
|
|
149
|
+
input.type = 'file';
|
|
150
|
+
input.accept = '.json,application/json';
|
|
151
|
+
input.onchange = e => {
|
|
152
|
+
const file = (e.target as HTMLInputElement).files?.[0];
|
|
153
|
+
if (!file) return resolve(undefined);
|
|
154
|
+
const reader = new FileReader();
|
|
155
|
+
reader.onload = e => {
|
|
156
|
+
try {
|
|
157
|
+
const text = e.target?.result;
|
|
158
|
+
if (typeof text === 'string') resolve(JSON.parse(text) as Prefab);
|
|
159
|
+
} catch (err) {
|
|
160
|
+
console.error('Error parsing prefab JSON:', err);
|
|
161
|
+
resolve(undefined);
|
|
162
|
+
}
|
|
205
163
|
};
|
|
206
|
-
|
|
207
|
-
}
|
|
208
|
-
|
|
164
|
+
reader.readAsText(file);
|
|
165
|
+
};
|
|
166
|
+
input.click();
|
|
167
|
+
});
|
|
168
|
+
|
|
209
169
|
export default PrefabEditor;
|