react-three-game 0.0.17 → 0.0.19
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/.github/copilot-instructions.md +54 -183
- package/README.md +69 -214
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -0
- package/dist/tools/prefabeditor/EditorTree.d.ts +2 -4
- package/dist/tools/prefabeditor/EditorTree.js +20 -194
- package/dist/tools/prefabeditor/EditorUI.js +43 -224
- package/dist/tools/prefabeditor/InstanceProvider.d.ts +4 -4
- package/dist/tools/prefabeditor/InstanceProvider.js +21 -13
- package/dist/tools/prefabeditor/PrefabEditor.js +33 -99
- package/dist/tools/prefabeditor/PrefabRoot.d.ts +0 -1
- package/dist/tools/prefabeditor/PrefabRoot.js +33 -50
- package/dist/tools/prefabeditor/components/DirectionalLightComponent.d.ts +3 -0
- package/dist/tools/prefabeditor/components/DirectionalLightComponent.js +102 -0
- package/dist/tools/prefabeditor/components/ModelComponent.js +12 -4
- package/dist/tools/prefabeditor/components/SpotLightComponent.js +10 -5
- package/dist/tools/prefabeditor/components/index.js +2 -0
- package/dist/tools/prefabeditor/hooks/useModelLoader.d.ts +10 -0
- package/dist/tools/prefabeditor/hooks/useModelLoader.js +40 -0
- package/dist/tools/prefabeditor/styles.d.ts +1809 -0
- package/dist/tools/prefabeditor/styles.js +168 -0
- package/dist/tools/prefabeditor/types.d.ts +3 -14
- package/dist/tools/prefabeditor/types.js +0 -1
- package/dist/tools/prefabeditor/utils.d.ts +19 -0
- package/dist/tools/prefabeditor/utils.js +72 -0
- package/package.json +3 -3
- package/src/index.ts +5 -1
- package/src/tools/prefabeditor/EditorTree.tsx +38 -270
- package/src/tools/prefabeditor/EditorUI.tsx +105 -322
- package/src/tools/prefabeditor/InstanceProvider.tsx +43 -32
- package/src/tools/prefabeditor/PrefabEditor.tsx +40 -151
- package/src/tools/prefabeditor/PrefabRoot.tsx +41 -73
- package/src/tools/prefabeditor/components/DirectionalLightComponent.tsx +317 -0
- package/src/tools/prefabeditor/components/ModelComponent.tsx +14 -4
- package/src/tools/prefabeditor/components/SpotLightComponent.tsx +27 -7
- package/src/tools/prefabeditor/components/index.ts +2 -0
- package/src/tools/prefabeditor/styles.ts +195 -0
- package/src/tools/prefabeditor/types.ts +4 -12
- package/src/tools/prefabeditor/utils.ts +80 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import React, { createContext, useContext, useMemo, useRef, useState, useEffect, useCallback } from "react";
|
|
2
2
|
import { Merged } from '@react-three/drei';
|
|
3
|
-
import * as THREE from 'three';
|
|
4
3
|
import { InstancedRigidBodies } from "@react-three/rapier";
|
|
4
|
+
import { Mesh, Matrix4, Object3D, Group } from "three";
|
|
5
5
|
|
|
6
6
|
// --- Types ---
|
|
7
7
|
export type InstanceData = {
|
|
@@ -13,7 +13,8 @@ export type InstanceData = {
|
|
|
13
13
|
physics?: { type: 'dynamic' | 'fixed' };
|
|
14
14
|
};
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
// Helper functions for comparison
|
|
17
|
+
function arrayEquals(a: number[], b: number[]): boolean {
|
|
17
18
|
if (a === b) return true;
|
|
18
19
|
if (a.length !== b.length) return false;
|
|
19
20
|
for (let i = 0; i < a.length; i++) {
|
|
@@ -22,7 +23,7 @@ function arrayEquals(a: number[], b: number[]) {
|
|
|
22
23
|
return true;
|
|
23
24
|
}
|
|
24
25
|
|
|
25
|
-
function instanceEquals(a: InstanceData, b: InstanceData) {
|
|
26
|
+
function instanceEquals(a: InstanceData, b: InstanceData): boolean {
|
|
26
27
|
return a.id === b.id &&
|
|
27
28
|
a.meshPath === b.meshPath &&
|
|
28
29
|
arrayEquals(a.position, b.position) &&
|
|
@@ -36,7 +37,7 @@ type GameInstanceContextType = {
|
|
|
36
37
|
addInstance: (instance: InstanceData) => void;
|
|
37
38
|
removeInstance: (id: string) => void;
|
|
38
39
|
instances: InstanceData[];
|
|
39
|
-
meshes: Record<string,
|
|
40
|
+
meshes: Record<string, Mesh>;
|
|
40
41
|
instancesMap?: Record<string, React.ComponentType<any>>;
|
|
41
42
|
modelParts?: Record<string, number>;
|
|
42
43
|
};
|
|
@@ -44,13 +45,14 @@ const GameInstanceContext = createContext<GameInstanceContextType | null>(null);
|
|
|
44
45
|
|
|
45
46
|
export function GameInstanceProvider({
|
|
46
47
|
children,
|
|
47
|
-
models
|
|
48
|
-
|
|
48
|
+
models,
|
|
49
|
+
onSelect,
|
|
50
|
+
registerRef
|
|
49
51
|
}: {
|
|
50
52
|
children: React.ReactNode,
|
|
51
|
-
models: { [filename: string]:
|
|
53
|
+
models: { [filename: string]: Object3D },
|
|
52
54
|
onSelect?: (id: string | null) => void,
|
|
53
|
-
registerRef?: (id: string, obj:
|
|
55
|
+
registerRef?: (id: string, obj: Object3D | null) => void,
|
|
54
56
|
}) {
|
|
55
57
|
const [instances, setInstances] = useState<InstanceData[]>([]);
|
|
56
58
|
|
|
@@ -58,6 +60,7 @@ export function GameInstanceProvider({
|
|
|
58
60
|
setInstances(prev => {
|
|
59
61
|
const idx = prev.findIndex(i => i.id === instance.id);
|
|
60
62
|
if (idx !== -1) {
|
|
63
|
+
// Update existing if changed
|
|
61
64
|
if (instanceEquals(prev[idx], instance)) {
|
|
62
65
|
return prev;
|
|
63
66
|
}
|
|
@@ -65,6 +68,7 @@ export function GameInstanceProvider({
|
|
|
65
68
|
copy[idx] = instance;
|
|
66
69
|
return copy;
|
|
67
70
|
}
|
|
71
|
+
// Add new
|
|
68
72
|
return [...prev, instance];
|
|
69
73
|
});
|
|
70
74
|
}, []);
|
|
@@ -76,27 +80,26 @@ export function GameInstanceProvider({
|
|
|
76
80
|
});
|
|
77
81
|
}, []);
|
|
78
82
|
|
|
79
|
-
// Flatten all model meshes once
|
|
83
|
+
// Flatten all model meshes once (models → flat mesh parts)
|
|
80
84
|
const { flatMeshes, modelParts } = useMemo(() => {
|
|
81
|
-
const flatMeshes: Record<string,
|
|
85
|
+
const flatMeshes: Record<string, Mesh> = {};
|
|
82
86
|
const modelParts: Record<string, number> = {};
|
|
83
87
|
|
|
84
88
|
Object.entries(models).forEach(([modelKey, model]) => {
|
|
85
89
|
const root = model;
|
|
86
90
|
root.updateWorldMatrix(false, true);
|
|
87
|
-
const rootInverse = new
|
|
91
|
+
const rootInverse = new Matrix4().copy(root.matrixWorld).invert();
|
|
88
92
|
|
|
89
93
|
let partIndex = 0;
|
|
90
94
|
|
|
91
95
|
root.traverse((obj: any) => {
|
|
92
96
|
if (obj.isMesh) {
|
|
93
97
|
const geom = obj.geometry.clone();
|
|
94
|
-
|
|
95
98
|
const relativeTransform = obj.matrixWorld.clone().premultiply(rootInverse);
|
|
96
99
|
geom.applyMatrix4(relativeTransform);
|
|
97
100
|
|
|
98
101
|
const partKey = `${modelKey}__${partIndex}`;
|
|
99
|
-
flatMeshes[partKey] = new
|
|
102
|
+
flatMeshes[partKey] = new Mesh(geom, obj.material);
|
|
100
103
|
partIndex++;
|
|
101
104
|
}
|
|
102
105
|
});
|
|
@@ -106,7 +109,7 @@ export function GameInstanceProvider({
|
|
|
106
109
|
return { flatMeshes, modelParts };
|
|
107
110
|
}, [models]);
|
|
108
111
|
|
|
109
|
-
// Group instances by meshPath + physics type
|
|
112
|
+
// Group instances by meshPath + physics type for batch rendering
|
|
110
113
|
const grouped = useMemo(() => {
|
|
111
114
|
const groups: Record<string, { physicsType: string, instances: InstanceData[] }> = {};
|
|
112
115
|
for (const inst of instances) {
|
|
@@ -128,10 +131,10 @@ export function GameInstanceProvider({
|
|
|
128
131
|
modelParts
|
|
129
132
|
}}
|
|
130
133
|
>
|
|
131
|
-
{/*
|
|
134
|
+
{/* Render normal prefab hierarchy (non-instanced objects) */}
|
|
132
135
|
{children}
|
|
133
136
|
|
|
134
|
-
{/*
|
|
137
|
+
{/* Render physics-enabled instanced groups using InstancedRigidBodies */}
|
|
135
138
|
{Object.entries(grouped).map(([key, group]) => {
|
|
136
139
|
if (group.physicsType === 'none') return null;
|
|
137
140
|
const modelKey = group.instances[0].meshPath;
|
|
@@ -149,7 +152,7 @@ export function GameInstanceProvider({
|
|
|
149
152
|
);
|
|
150
153
|
})}
|
|
151
154
|
|
|
152
|
-
{/*
|
|
155
|
+
{/* Render non-physics instanced visuals using Merged (one per model type) */}
|
|
153
156
|
{Object.entries(grouped).map(([key, group]) => {
|
|
154
157
|
if (group.physicsType !== 'none') return null;
|
|
155
158
|
|
|
@@ -157,8 +160,8 @@ export function GameInstanceProvider({
|
|
|
157
160
|
const partCount = modelParts[modelKey] || 0;
|
|
158
161
|
if (partCount === 0) return null;
|
|
159
162
|
|
|
160
|
-
//
|
|
161
|
-
const meshesForModel: Record<string,
|
|
163
|
+
// Create mesh subset for this specific model
|
|
164
|
+
const meshesForModel: Record<string, Mesh> = {};
|
|
162
165
|
for (let i = 0; i < partCount; i++) {
|
|
163
166
|
const partKey = `${modelKey}__${i}`;
|
|
164
167
|
meshesForModel[partKey] = flatMeshes[partKey];
|
|
@@ -188,7 +191,7 @@ export function GameInstanceProvider({
|
|
|
188
191
|
);
|
|
189
192
|
}
|
|
190
193
|
|
|
191
|
-
//
|
|
194
|
+
// Render physics-enabled instances using InstancedRigidBodies
|
|
192
195
|
function InstancedRigidGroup({
|
|
193
196
|
group,
|
|
194
197
|
modelKey,
|
|
@@ -198,7 +201,7 @@ function InstancedRigidGroup({
|
|
|
198
201
|
group: { physicsType: string, instances: InstanceData[] },
|
|
199
202
|
modelKey: string,
|
|
200
203
|
partCount: number,
|
|
201
|
-
flatMeshes: Record<string,
|
|
204
|
+
flatMeshes: Record<string, Mesh>
|
|
202
205
|
}) {
|
|
203
206
|
const instances = useMemo(
|
|
204
207
|
() => group.instances.map(inst => ({
|
|
@@ -232,24 +235,33 @@ function InstancedRigidGroup({
|
|
|
232
235
|
);
|
|
233
236
|
}
|
|
234
237
|
|
|
235
|
-
//
|
|
238
|
+
// Render non-physics instances using Merged's per-instance groups
|
|
236
239
|
function NonPhysicsInstancedGroup({
|
|
237
240
|
modelKey,
|
|
238
241
|
group,
|
|
239
242
|
partCount,
|
|
240
|
-
instancesMap
|
|
241
|
-
|
|
243
|
+
instancesMap,
|
|
244
|
+
onSelect,
|
|
245
|
+
registerRef
|
|
242
246
|
}: {
|
|
243
247
|
modelKey: string;
|
|
244
248
|
group: { physicsType: string, instances: InstanceData[] };
|
|
245
249
|
partCount: number;
|
|
246
250
|
instancesMap: Record<string, React.ComponentType<any>>;
|
|
247
251
|
onSelect?: (id: string | null) => void;
|
|
248
|
-
registerRef?: (id: string, obj:
|
|
252
|
+
registerRef?: (id: string, obj: Object3D | null) => void;
|
|
249
253
|
}) {
|
|
250
254
|
const clickValid = useRef(false);
|
|
251
|
-
|
|
252
|
-
const
|
|
255
|
+
|
|
256
|
+
const handlePointerDown = (e: any) => {
|
|
257
|
+
e.stopPropagation();
|
|
258
|
+
clickValid.current = true;
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
const handlePointerMove = () => {
|
|
262
|
+
if (clickValid.current) clickValid.current = false;
|
|
263
|
+
};
|
|
264
|
+
|
|
253
265
|
const handlePointerUp = (e: any, id: string) => {
|
|
254
266
|
if (clickValid.current) {
|
|
255
267
|
e.stopPropagation();
|
|
@@ -263,7 +275,7 @@ function NonPhysicsInstancedGroup({
|
|
|
263
275
|
{group.instances.map(inst => (
|
|
264
276
|
<group
|
|
265
277
|
key={inst.id}
|
|
266
|
-
ref={(el) => { registerRef?.(inst.id, el as unknown as
|
|
278
|
+
ref={(el) => { registerRef?.(inst.id, el as unknown as Object3D | null); }}
|
|
267
279
|
position={inst.position}
|
|
268
280
|
rotation={inst.rotation}
|
|
269
281
|
scale={inst.scale}
|
|
@@ -283,8 +295,8 @@ function NonPhysicsInstancedGroup({
|
|
|
283
295
|
}
|
|
284
296
|
|
|
285
297
|
|
|
286
|
-
//
|
|
287
|
-
export const GameInstance = React.forwardRef<
|
|
298
|
+
// GameInstance component: registers an instance for batch rendering (renders nothing itself)
|
|
299
|
+
export const GameInstance = React.forwardRef<Group, {
|
|
288
300
|
id: string;
|
|
289
301
|
modelUrl: string;
|
|
290
302
|
position: [number, number, number];
|
|
@@ -320,7 +332,6 @@ export const GameInstance = React.forwardRef<THREE.Group, {
|
|
|
320
332
|
};
|
|
321
333
|
}, [addInstance, removeInstance, instance]);
|
|
322
334
|
|
|
323
|
-
|
|
324
|
-
// No visual here – provider will render visuals for all instances
|
|
335
|
+
// No visual rendering - provider handles all instanced visuals
|
|
325
336
|
return null;
|
|
326
337
|
});
|
|
@@ -2,34 +2,29 @@
|
|
|
2
2
|
|
|
3
3
|
import GameCanvas from "../../shared/GameCanvas";
|
|
4
4
|
import { useState, useRef, useEffect } from "react";
|
|
5
|
-
import {
|
|
6
|
-
import { Prefab, } from "./types";
|
|
5
|
+
import { Prefab } from "./types";
|
|
7
6
|
import PrefabRoot from "./PrefabRoot";
|
|
8
7
|
import { Physics } from "@react-three/rapier";
|
|
9
8
|
import EditorUI from "./EditorUI";
|
|
9
|
+
import { base, toolbar } from "./styles";
|
|
10
10
|
|
|
11
11
|
const PrefabEditor = ({ basePath, initialPrefab, onPrefabChange, children }: { basePath?: string, initialPrefab?: Prefab, onPrefabChange?: (prefab: Prefab) => void, children?: React.ReactNode }) => {
|
|
12
12
|
const [editMode, setEditMode] = useState(true);
|
|
13
13
|
const [loadedPrefab, setLoadedPrefab] = useState<Prefab>(initialPrefab ?? {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
"position": [0, 0, 0],
|
|
23
|
-
"rotation": [0, 0, 0],
|
|
24
|
-
"scale": [1, 1, 1]
|
|
25
|
-
}
|
|
14
|
+
id: "prefab-default",
|
|
15
|
+
name: "New Prefab",
|
|
16
|
+
root: {
|
|
17
|
+
id: "root",
|
|
18
|
+
components: {
|
|
19
|
+
transform: {
|
|
20
|
+
type: "Transform",
|
|
21
|
+
properties: { position: [0, 0, 0], rotation: [0, 0, 0], scale: [1, 1, 1] }
|
|
26
22
|
}
|
|
27
23
|
}
|
|
28
24
|
}
|
|
29
25
|
});
|
|
30
26
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
31
27
|
const [transformMode, setTransformMode] = useState<"translate" | "rotate" | "scale">("translate");
|
|
32
|
-
const prefabRef = useRef<Group>(null);
|
|
33
28
|
|
|
34
29
|
// Sync internal state with external initialPrefab prop
|
|
35
30
|
useEffect(() => {
|
|
@@ -52,15 +47,11 @@ const PrefabEditor = ({ basePath, initialPrefab, onPrefabChange, children }: { b
|
|
|
52
47
|
<gridHelper args={[10, 10]} position={[0, -1, 0]} />
|
|
53
48
|
<PrefabRoot
|
|
54
49
|
data={loadedPrefab}
|
|
55
|
-
ref={prefabRef}
|
|
56
|
-
|
|
57
|
-
// props for edit mode
|
|
58
50
|
editMode={editMode}
|
|
59
51
|
onPrefabChange={updatePrefab}
|
|
60
52
|
selectedId={selectedId}
|
|
61
53
|
onSelect={setSelectedId}
|
|
62
54
|
transformMode={transformMode}
|
|
63
|
-
setTransformMode={setTransformMode}
|
|
64
55
|
basePath={basePath}
|
|
65
56
|
/>
|
|
66
57
|
{children}
|
|
@@ -98,189 +89,87 @@ const SaveDataPanel = ({
|
|
|
98
89
|
}) => {
|
|
99
90
|
const [history, setHistory] = useState<Prefab[]>([currentData]);
|
|
100
91
|
const [historyIndex, setHistoryIndex] = useState(0);
|
|
101
|
-
const
|
|
102
|
-
const
|
|
92
|
+
const throttleRef = useRef<NodeJS.Timeout | null>(null);
|
|
93
|
+
const lastDataRef = useRef<string>(JSON.stringify(currentData));
|
|
103
94
|
|
|
104
|
-
|
|
105
|
-
const handleUndo = () => {
|
|
95
|
+
const undo = () => {
|
|
106
96
|
if (historyIndex > 0) {
|
|
107
97
|
const newIndex = historyIndex - 1;
|
|
108
98
|
setHistoryIndex(newIndex);
|
|
109
|
-
|
|
99
|
+
lastDataRef.current = JSON.stringify(history[newIndex]);
|
|
110
100
|
onDataChange(history[newIndex]);
|
|
111
101
|
}
|
|
112
102
|
};
|
|
113
103
|
|
|
114
|
-
const
|
|
104
|
+
const redo = () => {
|
|
115
105
|
if (historyIndex < history.length - 1) {
|
|
116
106
|
const newIndex = historyIndex + 1;
|
|
117
107
|
setHistoryIndex(newIndex);
|
|
118
|
-
|
|
108
|
+
lastDataRef.current = JSON.stringify(history[newIndex]);
|
|
119
109
|
onDataChange(history[newIndex]);
|
|
120
110
|
}
|
|
121
111
|
};
|
|
122
112
|
|
|
123
|
-
// Keyboard shortcuts for undo/redo
|
|
124
113
|
useEffect(() => {
|
|
125
114
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
126
|
-
// Undo: Ctrl+Z (Cmd+Z on Mac)
|
|
127
115
|
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
|
|
128
116
|
e.preventDefault();
|
|
129
|
-
|
|
130
|
-
}
|
|
131
|
-
// Redo: Ctrl+Shift+Z or Ctrl+Y (Cmd+Shift+Z or Cmd+Y on Mac)
|
|
132
|
-
else if ((e.ctrlKey || e.metaKey) && (e.shiftKey && e.key === 'z' || e.key === 'y')) {
|
|
117
|
+
undo();
|
|
118
|
+
} else if ((e.ctrlKey || e.metaKey) && (e.shiftKey && e.key === 'z' || e.key === 'y')) {
|
|
133
119
|
e.preventDefault();
|
|
134
|
-
|
|
120
|
+
redo();
|
|
135
121
|
}
|
|
136
122
|
};
|
|
137
|
-
|
|
138
123
|
window.addEventListener('keydown', handleKeyDown);
|
|
139
124
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
140
125
|
}, [historyIndex, history]);
|
|
141
126
|
|
|
142
|
-
// Throttled history update when currentData changes
|
|
143
127
|
useEffect(() => {
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
// Skip if data hasn't actually changed
|
|
147
|
-
if (currentDataStr === lastSavedDataRef.current) {
|
|
148
|
-
return;
|
|
149
|
-
}
|
|
128
|
+
const currentStr = JSON.stringify(currentData);
|
|
129
|
+
if (currentStr === lastDataRef.current) return;
|
|
150
130
|
|
|
151
|
-
|
|
152
|
-
if (throttleTimeoutRef.current) {
|
|
153
|
-
clearTimeout(throttleTimeoutRef.current);
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
// Set new throttled update
|
|
157
|
-
throttleTimeoutRef.current = setTimeout(() => {
|
|
158
|
-
lastSavedDataRef.current = currentDataStr;
|
|
131
|
+
if (throttleRef.current) clearTimeout(throttleRef.current);
|
|
159
132
|
|
|
133
|
+
throttleRef.current = setTimeout(() => {
|
|
134
|
+
lastDataRef.current = currentStr;
|
|
160
135
|
setHistory(prev => {
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
// Add new state
|
|
164
|
-
newHistory.push(currentData);
|
|
165
|
-
// Limit history size to 50 states
|
|
166
|
-
if (newHistory.length > 50) {
|
|
167
|
-
newHistory.shift();
|
|
168
|
-
return newHistory;
|
|
169
|
-
}
|
|
170
|
-
return newHistory;
|
|
136
|
+
const newHistory = [...prev.slice(0, historyIndex + 1), currentData];
|
|
137
|
+
return newHistory.length > 50 ? newHistory.slice(1) : newHistory;
|
|
171
138
|
});
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
const newHistory = history.slice(0, prev + 1);
|
|
175
|
-
newHistory.push(currentData);
|
|
176
|
-
return Math.min(newHistory.length - 1, 49);
|
|
177
|
-
});
|
|
178
|
-
}, 500); // 500ms throttle
|
|
139
|
+
setHistoryIndex(prev => Math.min(prev + 1, 49));
|
|
140
|
+
}, 500);
|
|
179
141
|
|
|
180
142
|
return () => {
|
|
181
|
-
if (
|
|
182
|
-
clearTimeout(throttleTimeoutRef.current);
|
|
183
|
-
}
|
|
143
|
+
if (throttleRef.current) clearTimeout(throttleRef.current);
|
|
184
144
|
};
|
|
185
|
-
}, [currentData
|
|
145
|
+
}, [currentData]);
|
|
186
146
|
|
|
187
147
|
const handleLoad = async () => {
|
|
188
148
|
const prefab = await loadJson();
|
|
189
149
|
if (prefab) {
|
|
190
150
|
onDataChange(prefab);
|
|
191
|
-
// Reset history when loading new file
|
|
192
151
|
setHistory([prefab]);
|
|
193
152
|
setHistoryIndex(0);
|
|
194
|
-
|
|
153
|
+
lastDataRef.current = JSON.stringify(prefab);
|
|
195
154
|
}
|
|
196
155
|
};
|
|
197
156
|
|
|
198
157
|
const canUndo = historyIndex > 0;
|
|
199
158
|
const canRedo = historyIndex < history.length - 1;
|
|
200
159
|
|
|
201
|
-
return <div style={
|
|
202
|
-
|
|
203
|
-
top: 8,
|
|
204
|
-
left: "50%",
|
|
205
|
-
transform: "translateX(-50%)",
|
|
206
|
-
display: "flex",
|
|
207
|
-
alignItems: "center",
|
|
208
|
-
gap: 6,
|
|
209
|
-
padding: "2px 4px",
|
|
210
|
-
background: "rgba(0,0,0,0.55)",
|
|
211
|
-
border: "1px solid rgba(255,255,255,0.12)",
|
|
212
|
-
borderRadius: 4,
|
|
213
|
-
color: "rgba(255,255,255,0.9)",
|
|
214
|
-
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
|
|
215
|
-
fontSize: 11,
|
|
216
|
-
lineHeight: 1,
|
|
217
|
-
WebkitUserSelect: "none",
|
|
218
|
-
userSelect: "none",
|
|
219
|
-
}}>
|
|
220
|
-
<PanelButton onClick={() => onEditModeChange(!editMode)}>
|
|
160
|
+
return <div style={toolbar.panel}>
|
|
161
|
+
<button style={base.btn} onClick={() => onEditModeChange(!editMode)}>
|
|
221
162
|
{editMode ? "▶" : "⏸"}
|
|
222
|
-
</
|
|
223
|
-
|
|
224
|
-
<
|
|
225
|
-
|
|
226
|
-
<
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
<PanelButton onClick={handleRedo} disabled={!canRedo} title="Redo (Ctrl+Shift+Z)">
|
|
231
|
-
↷
|
|
232
|
-
</PanelButton>
|
|
233
|
-
|
|
234
|
-
<span style={{ opacity: 0.35 }}>|</span>
|
|
235
|
-
|
|
236
|
-
<PanelButton onClick={handleLoad} title="Load JSON">
|
|
237
|
-
📥
|
|
238
|
-
</PanelButton>
|
|
239
|
-
|
|
240
|
-
<PanelButton onClick={() => saveJson(currentData, "prefab")} title="Save JSON">
|
|
241
|
-
💾
|
|
242
|
-
</PanelButton>
|
|
163
|
+
</button>
|
|
164
|
+
<div style={toolbar.divider} />
|
|
165
|
+
<button style={{ ...base.btn, ...(canUndo ? {} : toolbar.disabled) }} onClick={undo} disabled={!canUndo}>↶</button>
|
|
166
|
+
<button style={{ ...base.btn, ...(canRedo ? {} : toolbar.disabled) }} onClick={redo} disabled={!canRedo}>↷</button>
|
|
167
|
+
<div style={toolbar.divider} />
|
|
168
|
+
<button style={base.btn} onClick={handleLoad}>📥</button>
|
|
169
|
+
<button style={base.btn} onClick={() => saveJson(currentData, "prefab")}>💾</button>
|
|
243
170
|
</div>;
|
|
244
171
|
};
|
|
245
172
|
|
|
246
|
-
const PanelButton = ({
|
|
247
|
-
onClick,
|
|
248
|
-
disabled,
|
|
249
|
-
title,
|
|
250
|
-
children
|
|
251
|
-
}: {
|
|
252
|
-
onClick: () => void;
|
|
253
|
-
disabled?: boolean;
|
|
254
|
-
title?: string;
|
|
255
|
-
children: React.ReactNode;
|
|
256
|
-
}) => {
|
|
257
|
-
return <button
|
|
258
|
-
style={{
|
|
259
|
-
padding: "2px 6px",
|
|
260
|
-
font: "inherit",
|
|
261
|
-
background: "transparent",
|
|
262
|
-
color: disabled ? "rgba(255,255,255,0.3)" : "inherit",
|
|
263
|
-
border: "1px solid rgba(255,255,255,0.18)",
|
|
264
|
-
borderRadius: 3,
|
|
265
|
-
cursor: disabled ? "not-allowed" : "pointer",
|
|
266
|
-
opacity: disabled ? 0.5 : 1,
|
|
267
|
-
}}
|
|
268
|
-
onClick={onClick}
|
|
269
|
-
disabled={disabled}
|
|
270
|
-
title={title}
|
|
271
|
-
onPointerEnter={(e) => {
|
|
272
|
-
if (!disabled) {
|
|
273
|
-
(e.currentTarget as HTMLButtonElement).style.background = "rgba(255,255,255,0.08)";
|
|
274
|
-
}
|
|
275
|
-
}}
|
|
276
|
-
onPointerLeave={(e) => {
|
|
277
|
-
(e.currentTarget as HTMLButtonElement).style.background = "transparent";
|
|
278
|
-
}}
|
|
279
|
-
>
|
|
280
|
-
{children}
|
|
281
|
-
</button>;
|
|
282
|
-
};
|
|
283
|
-
|
|
284
173
|
const saveJson = (data: any, filename: string) => {
|
|
285
174
|
const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(data, null, 2));
|
|
286
175
|
const downloadAnchorNode = document.createElement('a');
|