react-three-game 0.0.16 → 0.0.17

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/README.md CHANGED
@@ -30,14 +30,6 @@ Scenes are JSON prefabs. Components are registered modules. Hierarchy is declara
30
30
  }} />
31
31
  ```
32
32
 
33
- ## Styling
34
-
35
- The prefab editor UI ships with **inline styles** (no Tailwind / CSS framework required). That means you can install and render it without any additional build-time CSS configuration.
36
-
37
- If you want to fully restyle the editor, you can:
38
- - Wrap `PrefabEditor` in your own layout and override positioning.
39
- - Fork/compose the editor UI components (they’re plain React components).
40
-
41
33
  ## Quick Start
42
34
 
43
35
  ```bash
@@ -108,28 +100,100 @@ interface GameObject {
108
100
 
109
101
  ## Custom Components
110
102
 
111
- ```tsx
103
+ Extend the engine by registering your own components. Components have two parts:
104
+ - **Editor**: UI for inspector panel (edit mode)
105
+ - **View**: Three.js runtime renderer (play mode)
106
+
107
+ ### Component Interface
108
+
109
+ ```typescript
112
110
  import { Component } from 'react-three-game';
113
111
 
114
- const LaserComponent: Component = {
115
- name: 'Laser',
112
+ interface Component {
113
+ name: string;
114
+ Editor: FC<{ component: any; onUpdate: (newComp: any) => void }>;
115
+ View?: FC<{ properties: any; children?: React.ReactNode }>;
116
+ defaultProperties: any;
117
+ }
118
+ ```
119
+
120
+ ### Example: Rotator Component
121
+
122
+ ```tsx
123
+ import { Component, registerComponent } from 'react-three-game';
124
+ import { useFrame } from '@react-three/fiber';
125
+ import { useRef } from 'react';
126
+
127
+ const RotatorComponent: Component = {
128
+ name: 'Rotator',
129
+
116
130
  Editor: ({ component, onUpdate }) => (
117
- <input
118
- value={component.properties.damage}
119
- onChange={e => onUpdate({ damage: +e.target.value })}
120
- />
121
- ),
122
- View: ({ properties }) => (
123
- <pointLight color="red" intensity={properties.damage} />
131
+ <div>
132
+ <label>Speed</label>
133
+ <input
134
+ type="number"
135
+ value={component.properties.speed ?? 1.0}
136
+ onChange={e => onUpdate({ ...component.properties, speed: parseFloat(e.target.value) })}
137
+ />
138
+ <label>Axis</label>
139
+ <select
140
+ value={component.properties.axis ?? 'y'}
141
+ onChange={e => onUpdate({ ...component.properties, axis: e.target.value })}
142
+ >
143
+ <option value="x">X</option>
144
+ <option value="y">Y</option>
145
+ <option value="z">Z</option>
146
+ </select>
147
+ </div>
124
148
  ),
125
- defaultProperties: { damage: 10 }
149
+
150
+ View: ({ properties, children }) => {
151
+ const ref = useRef();
152
+ const speed = properties.speed ?? 1.0;
153
+ const axis = properties.axis ?? 'y';
154
+
155
+ useFrame((state, delta) => {
156
+ if (ref.current) {
157
+ ref.current.rotation[axis] += delta * speed;
158
+ }
159
+ });
160
+
161
+ return <group ref={ref}>{children}</group>;
162
+ },
163
+
164
+ defaultProperties: { speed: 1.0, axis: 'y' }
126
165
  };
127
166
 
128
- // Register
129
- import { registerComponent } from 'react-three-game';
130
- registerComponent(LaserComponent);
167
+ // Register before using PrefabEditor
168
+ registerComponent(RotatorComponent);
169
+ ```
170
+
171
+ ### Usage in Prefab JSON
172
+
173
+ ```json
174
+ {
175
+ "id": "spinning-cube",
176
+ "components": {
177
+ "transform": { "type": "Transform", "properties": { "position": [0, 1, 0] } },
178
+ "geometry": { "type": "Geometry", "properties": { "geometryType": "box" } },
179
+ "material": { "type": "Material", "properties": { "color": "#ff6b6b" } },
180
+ "rotator": { "type": "Rotator", "properties": { "speed": 2.0, "axis": "y" } }
181
+ }
182
+ }
131
183
  ```
132
184
 
185
+ ### Wrapper vs Leaf Components
186
+
187
+ **Wrapper components** (accept `children`) wrap the rendered content:
188
+ - Use for behaviors that need to manipulate the scene graph (animations, controllers)
189
+ - Example: Rotator wraps mesh to apply rotation
190
+
191
+ **Leaf components** (no `children`) render as siblings:
192
+ - Use for standalone effects (lights, particles, audio sources)
193
+ - Example: SpotLight renders a `<spotLight>` element
194
+
195
+ The engine automatically detects component type by checking if `View` accepts `children` prop.
196
+
133
197
  ## Built-in Components
134
198
 
135
199
  | Component | Properties |
@@ -158,41 +222,13 @@ Transform gizmos (T/R/S keys), drag-to-reorder tree, import/export JSON, edit/pl
158
222
  ### Transform Hierarchy
159
223
  - Local transforms stored in JSON (relative to parent)
160
224
  - World transforms computed at runtime via matrix multiplication
161
- - `computeParentWorldMatrix(root, targetId)` traverses tree for parent's world matrix
162
225
  - TransformControls extract world matrix → compute parent inverse → derive new local transform
163
226
 
164
227
  ### GPU Instancing
165
- Enable with `model.properties.instanced = true`:
166
- ```json
167
- {
168
- "components": {
169
- "model": {
170
- "type": "Model",
171
- "properties": {
172
- "filename": "tree.glb",
173
- "instanced": true
174
- }
175
- }
176
- }
177
- }
178
- ```
179
- Uses drei's `<Merged>` + `<InstancedRigidBodies>` for physics. World-space transforms, terminal nodes.
228
+ Enable with `model.properties.instanced = true` for optimized repeated geometry. Uses drei's `<Merged>` + `<InstancedRigidBodies>`.
180
229
 
181
230
  ### Model Loading
182
- - Supports GLB/GLTF (Draco compression) and FBX
183
- - Singleton loaders in `modelLoader.ts`
184
- - Draco decoder from `https://www.gstatic.com/draco/v1/decoders/`
185
- - Auto-loads when `model.properties.filename` detected
186
-
187
- ### WebGPU Renderer
188
- ```tsx
189
- <Canvas gl={async ({ canvas }) => {
190
- const renderer = new WebGPURenderer({ canvas, shadowMap: true });
191
- await renderer.init(); // Required
192
- return renderer;
193
- }}>
194
- ```
195
- Use `MeshStandardNodeMaterial` not `MeshStandardMaterial`.
231
+ Supports GLB/GLTF (with Draco compression) and FBX. Models auto-load when `model.properties.filename` is detected.
196
232
 
197
233
  ## Patterns
198
234
 
@@ -202,15 +238,6 @@ import levelData from './prefabs/arena.json';
202
238
  <PrefabRoot data={levelData} />
203
239
  ```
204
240
 
205
- ### Mix with React Components
206
- ```jsx
207
- <Physics>
208
- <PrefabRoot data={environment} />
209
- <Player />
210
- <AIEnemies />
211
- </Physics>
212
- ```
213
-
214
241
  ### Update Prefab Nodes
215
242
  ```typescript
216
243
  function updatePrefabNode(root: GameObject, id: string, update: (node: GameObject) => GameObject): GameObject {
@@ -222,58 +249,6 @@ function updatePrefabNode(root: GameObject, id: string, update: (node: GameObjec
222
249
  }
223
250
  ```
224
251
 
225
- ## AI Agent Reference
226
-
227
- ### Prefab JSON Schema
228
- ```typescript
229
- {
230
- "id": "unique-id",
231
- "root": {
232
- "id": "root-id",
233
- "components": {
234
- "transform": {
235
- "type": "Transform",
236
- "properties": {
237
- "position": [x, y, z], // world units
238
- "rotation": [x, y, z], // radians
239
- "scale": [x, y, z] // multipliers
240
- }
241
- },
242
- "geometry": {
243
- "type": "Geometry",
244
- "properties": {
245
- "geometryType": "box" | "sphere" | "plane" | "cylinder" | "cone" | "torus",
246
- "args": [/* geometry-specific */]
247
- }
248
- },
249
- "material": {
250
- "type": "Material",
251
- "properties": {
252
- "color": "#rrggbb",
253
- "texture": "/path/to/texture.jpg",
254
- "metalness": 0.0-1.0,
255
- "roughness": 0.0-1.0
256
- }
257
- },
258
- "physics": {
259
- "type": "Physics",
260
- "properties": {
261
- "type": "dynamic" | "fixed"
262
- }
263
- },
264
- "model": {
265
- "type": "Model",
266
- "properties": {
267
- "filename": "/models/asset.glb",
268
- "instanced": true
269
- }
270
- }
271
- },
272
- "children": [/* recursive GameObjects */]
273
- }
274
- }
275
- ```
276
-
277
252
  ## Development
278
253
 
279
254
  ```bash
package/dist/index.d.ts CHANGED
@@ -3,5 +3,7 @@ export { default as PrefabEditor } from './tools/prefabeditor/PrefabEditor';
3
3
  export { default as PrefabRoot } from './tools/prefabeditor/PrefabRoot';
4
4
  export { DragDropLoader } from './tools/dragdrop/DragDropLoader';
5
5
  export { TextureListViewer, ModelListViewer, SoundListViewer, SharedCanvas, } from './tools/assetviewer/page';
6
+ export { registerComponent } from './tools/prefabeditor/components/ComponentRegistry';
7
+ export type { Component } from './tools/prefabeditor/components/ComponentRegistry';
6
8
  export * from './helpers';
7
9
  export type { Prefab, GameObject } from './tools/prefabeditor/types';
package/dist/index.js CHANGED
@@ -4,5 +4,7 @@ export { default as PrefabEditor } from './tools/prefabeditor/PrefabEditor';
4
4
  export { default as PrefabRoot } from './tools/prefabeditor/PrefabRoot';
5
5
  export { DragDropLoader } from './tools/dragdrop/DragDropLoader';
6
6
  export { TextureListViewer, ModelListViewer, SoundListViewer, SharedCanvas, } from './tools/assetviewer/page';
7
+ // Component Registry
8
+ export { registerComponent } from './tools/prefabeditor/components/ComponentRegistry';
7
9
  // Helpers
8
10
  export * from './helpers';
@@ -65,6 +65,17 @@ export default function EditorTree({ prefabData, setPrefabData, selectedId, setS
65
65
  overflow: "hidden",
66
66
  textOverflow: "ellipsis",
67
67
  },
68
+ dragHandle: {
69
+ width: 14,
70
+ height: 14,
71
+ display: "flex",
72
+ alignItems: "center",
73
+ justifyContent: "center",
74
+ marginRight: 4,
75
+ opacity: 0.4,
76
+ cursor: "grab",
77
+ fontSize: 10,
78
+ },
68
79
  contextMenu: {
69
80
  position: "fixed",
70
81
  zIndex: 50,
@@ -165,38 +176,36 @@ export default function EditorTree({ prefabData, setPrefabData, selectedId, setS
165
176
  };
166
177
  // Drag and Drop
167
178
  const handleDragStart = (e, id) => {
168
- e.stopPropagation();
169
179
  if (id === prefabData.root.id) {
170
- e.preventDefault(); // Cannot drag root
180
+ e.preventDefault();
171
181
  return;
172
182
  }
173
- setDraggedId(id);
174
183
  e.dataTransfer.effectAllowed = "move";
184
+ e.dataTransfer.setData("text/plain", id);
185
+ setDraggedId(id);
186
+ };
187
+ const handleDragEnd = () => {
188
+ setDraggedId(null);
175
189
  };
176
190
  const handleDragOver = (e, targetId) => {
177
- e.preventDefault();
178
- e.stopPropagation();
179
191
  if (!draggedId || draggedId === targetId)
180
192
  return;
181
- // Check for cycles: target cannot be a descendant of dragged node
182
193
  const draggedNode = findNode(prefabData.root, draggedId);
183
194
  if (draggedNode && findNode(draggedNode, targetId))
184
195
  return;
196
+ e.preventDefault();
185
197
  e.dataTransfer.dropEffect = "move";
186
198
  };
187
199
  const handleDrop = (e, targetId) => {
188
- e.preventDefault();
189
- e.stopPropagation();
190
200
  if (!draggedId || draggedId === targetId)
191
201
  return;
202
+ e.preventDefault();
192
203
  setPrefabData(prev => {
193
204
  var _a;
194
205
  const newRoot = JSON.parse(JSON.stringify(prev.root));
195
- // Check cycle again on the fresh tree
196
- const draggedNodeRef = findNode(newRoot, draggedId);
197
- if (draggedNodeRef && findNode(draggedNodeRef, targetId))
206
+ const draggedNode = findNode(newRoot, draggedId);
207
+ if (draggedNode && findNode(draggedNode, targetId))
198
208
  return prev;
199
- // Remove from old parent
200
209
  const parent = findParent(newRoot, draggedId);
201
210
  if (!parent)
202
211
  return prev;
@@ -204,7 +213,6 @@ export default function EditorTree({ prefabData, setPrefabData, selectedId, setS
204
213
  if (!nodeToMove)
205
214
  return prev;
206
215
  parent.children = parent.children.filter(c => c.id !== draggedId);
207
- // Add to new parent
208
216
  const target = findNode(newRoot, targetId);
209
217
  if (target) {
210
218
  target.children = target.children || [];
@@ -220,7 +228,7 @@ export default function EditorTree({ prefabData, setPrefabData, selectedId, setS
220
228
  const isSelected = node.id === selectedId;
221
229
  const isCollapsed = collapsedIds.has(node.id);
222
230
  const hasChildren = node.children && node.children.length > 0;
223
- return (_jsxs("div", { children: [_jsxs("div", { style: Object.assign(Object.assign(Object.assign({}, styles.row), (isSelected ? styles.rowSelected : null)), { paddingLeft: `${depth * 10 + 6}px` }), onClick: (e) => { e.stopPropagation(); setSelectedId(node.id); }, onContextMenu: (e) => handleContextMenu(e, node.id), draggable: node.id !== prefabData.root.id, onDragStart: (e) => handleDragStart(e, node.id), onDragOver: (e) => handleDragOver(e, node.id), onDrop: (e) => handleDrop(e, node.id), onPointerEnter: (e) => {
231
+ return (_jsxs("div", { children: [_jsxs("div", { style: Object.assign(Object.assign(Object.assign({}, styles.row), (isSelected ? styles.rowSelected : null)), { paddingLeft: `${depth * 10 + 6}px`, cursor: node.id !== prefabData.root.id ? "grab" : "pointer" }), draggable: node.id !== prefabData.root.id, onClick: (e) => { e.stopPropagation(); setSelectedId(node.id); }, onContextMenu: (e) => handleContextMenu(e, node.id), onDragStart: (e) => handleDragStart(e, node.id), onDragEnd: handleDragEnd, onDragOver: (e) => handleDragOver(e, node.id), onDrop: (e) => handleDrop(e, node.id), onPointerEnter: (e) => {
224
232
  if (!isSelected)
225
233
  e.currentTarget.style.background = "rgba(255,255,255,0.06)";
226
234
  }, onPointerLeave: (e) => {
@@ -230,7 +238,11 @@ export default function EditorTree({ prefabData, setPrefabData, selectedId, setS
230
238
  e.currentTarget.style.opacity = "0.9";
231
239
  }, onPointerLeave: (e) => {
232
240
  e.currentTarget.style.opacity = "0.55";
233
- }, children: isCollapsed ? '▶' : '▼' }), _jsx("span", { style: styles.idText, children: node.id })] }), !isCollapsed && node.children && (_jsx("div", { children: node.children.map(child => renderNode(child, depth + 1)) }))] }, node.id));
241
+ }, children: isCollapsed ? '▶' : '▼' }), node.id !== prefabData.root.id && (_jsx("span", { style: styles.dragHandle, onPointerEnter: (e) => {
242
+ e.currentTarget.style.opacity = "0.9";
243
+ }, onPointerLeave: (e) => {
244
+ e.currentTarget.style.opacity = "0.4";
245
+ }, children: "\u22EE\u22EE" })), _jsx("span", { style: styles.idText, children: node.id })] }), !isCollapsed && node.children && (_jsx("div", { children: node.children.map(child => renderNode(child, depth + 1)) }))] }, node.id));
234
246
  };
235
247
  return (_jsxs(_Fragment, { children: [_jsxs("div", { style: Object.assign(Object.assign({}, styles.panel), { width: isTreeCollapsed ? 'auto' : '14rem' }), onClick: closeContextMenu, children: [_jsxs("div", { style: styles.panelHeader, onClick: (e) => { e.stopPropagation(); setIsTreeCollapsed(!isTreeCollapsed); }, onPointerEnter: (e) => {
236
248
  e.currentTarget.style.background = "rgba(255,255,255,0.08)";
@@ -179,13 +179,7 @@ function NodeInspector({ node, updateNode, deleteNode, transformMode, setTransfo
179
179
  setAddComponentType(available[0] || "");
180
180
  }
181
181
  }, [componentKeys, addComponentType, node.components, allComponentKeys]);
182
- return _jsxs("div", { style: s.root, children: [_jsx("div", { style: s.section, children: _jsx("input", { style: s.input, value: node.id, onChange: e => updateNode(n => (Object.assign(Object.assign({}, n), { id: e.target.value }))) }) }), _jsxs("div", { style: Object.assign(Object.assign(Object.assign({}, s.row), s.section), { paddingBottom: 6 }), children: [_jsx("label", { style: Object.assign(Object.assign({}, s.label), { marginBottom: 0 }), children: "Components" }), _jsx("button", { onClick: deleteNode, style: s.smallDanger, title: "Delete node", children: "\u2715" })] }), _jsxs("div", { style: s.section, children: [_jsx("label", { style: s.label, children: "Mode" }), _jsx("div", { style: { display: 'flex', gap: 6 }, children: ["translate", "rotate", "scale"].map(mode => (_jsx("button", { onClick: () => setTransformMode(mode), style: Object.assign(Object.assign(Object.assign({}, s.button), { flex: 1 }), (transformMode === mode ? s.buttonActive : null)), onPointerEnter: (e) => {
183
- if (transformMode !== mode)
184
- e.currentTarget.style.background = 'rgba(255,255,255,0.08)';
185
- }, onPointerLeave: (e) => {
186
- if (transformMode !== mode)
187
- e.currentTarget.style.background = 'transparent';
188
- }, children: mode[0].toUpperCase() }, mode))) })] }), node.components && Object.entries(node.components).map(([key, comp]) => {
182
+ return _jsxs("div", { style: s.root, children: [_jsx("div", { style: s.section, children: _jsx("input", { style: s.input, value: node.id, onChange: e => updateNode(n => (Object.assign(Object.assign({}, n), { id: e.target.value }))) }) }), _jsxs("div", { style: Object.assign(Object.assign(Object.assign({}, s.row), s.section), { paddingBottom: 6 }), children: [_jsx("label", { style: Object.assign(Object.assign({}, s.label), { marginBottom: 0 }), children: "Components" }), _jsx("button", { onClick: deleteNode, style: s.smallDanger, title: "Delete node", children: "\u2715" })] }), node.components && Object.entries(node.components).map(([key, comp]) => {
189
183
  if (!comp)
190
184
  return null;
191
185
  const componentDef = ALL_COMPONENTS[comp.type];
@@ -196,7 +190,7 @@ function NodeInspector({ node, updateNode, deleteNode, transformMode, setTransfo
196
190
  const components = Object.assign({}, n.components);
197
191
  delete components[key];
198
192
  return Object.assign(Object.assign({}, n), { components });
199
- }), style: s.smallDanger, title: "Remove component", children: "\u2715" })] }), EditorComp ? (_jsx(EditorComp, { component: comp, onUpdate: (newProps) => updateNode(n => (Object.assign(Object.assign({}, n), { components: Object.assign(Object.assign({}, n.components), { [key]: Object.assign(Object.assign({}, comp), { properties: Object.assign(Object.assign({}, comp.properties), newProps) }) }) }))), basePath: basePath })) : null] }, key));
193
+ }), style: s.smallDanger, title: "Remove component", children: "\u2715" })] }), EditorComp ? (_jsx(EditorComp, { component: comp, onUpdate: (newProps) => updateNode(n => (Object.assign(Object.assign({}, n), { components: Object.assign(Object.assign({}, n.components), { [key]: Object.assign(Object.assign({}, comp), { properties: Object.assign(Object.assign({}, comp.properties), newProps) }) }) }))), basePath: basePath, transformMode: transformMode, setTransformMode: setTransformMode })) : null] }, key));
200
194
  }), _jsxs("div", { style: Object.assign(Object.assign({}, s.section), { borderBottom: 'none', paddingBottom: 0 }), children: [_jsx("label", { style: s.label, children: "Add Component" }), _jsxs("div", { style: { display: 'flex', gap: 6 }, children: [_jsx("select", { style: s.select, value: addComponentType, onChange: e => setAddComponentType(e.target.value), children: allComponentKeys.filter(k => { var _a; return !((_a = node.components) === null || _a === void 0 ? void 0 : _a[k.toLowerCase()]); }).map(k => (_jsx("option", { value: k, children: k }, k))) }), _jsx("button", { style: Object.assign(Object.assign({}, s.addButton), (!addComponentType ? s.disabled : null)), disabled: !addComponentType, onClick: () => {
201
195
  var _a;
202
196
  if (!addComponentType)
@@ -50,65 +50,134 @@ const PrefabEditor = ({ basePath, initialPrefab, onPrefabChange, children }) =>
50
50
  };
51
51
  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, ref: prefabRef,
52
52
  // props for edit mode
53
- editMode: editMode, onPrefabChange: updatePrefab, selectedId: selectedId, onSelect: setSelectedId, transformMode: transformMode, setTransformMode: setTransformMode, basePath: basePath }), children] }) }), _jsxs("div", { style: {
54
- position: "absolute",
55
- top: 8,
56
- left: "50%",
57
- transform: "translateX(-50%)",
58
- display: "flex",
59
- alignItems: "center",
60
- gap: 6,
61
- padding: "2px 4px",
62
- background: "rgba(0,0,0,0.55)",
63
- border: "1px solid rgba(255,255,255,0.12)",
64
- borderRadius: 4,
65
- color: "rgba(255,255,255,0.9)",
66
- fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
67
- fontSize: 11,
68
- lineHeight: 1,
69
- WebkitUserSelect: "none",
70
- userSelect: "none",
71
- }, children: [_jsx("button", { style: {
72
- padding: "2px 6px",
73
- font: "inherit",
74
- background: "transparent",
75
- color: "inherit",
76
- border: "1px solid rgba(255,255,255,0.18)",
77
- borderRadius: 3,
78
- cursor: "pointer",
79
- }, onClick: () => setEditMode(!editMode), onPointerEnter: (e) => {
80
- e.currentTarget.style.background = "rgba(255,255,255,0.08)";
81
- }, onPointerLeave: (e) => {
82
- e.currentTarget.style.background = "transparent";
83
- }, children: editMode ? "▶" : "⏸" }), _jsx("span", { style: { opacity: 0.35 }, children: "|" }), _jsx("button", { style: {
84
- padding: "2px 6px",
85
- font: "inherit",
86
- background: "transparent",
87
- color: "inherit",
88
- border: "1px solid rgba(255,255,255,0.18)",
89
- borderRadius: 3,
90
- cursor: "pointer",
91
- }, onClick: () => __awaiter(void 0, void 0, void 0, function* () {
92
- const prefab = yield loadJson();
93
- if (prefab)
94
- setLoadedPrefab(prefab);
95
- }), onPointerEnter: (e) => {
96
- e.currentTarget.style.background = "rgba(255,255,255,0.08)";
97
- }, onPointerLeave: (e) => {
98
- e.currentTarget.style.background = "transparent";
99
- }, children: "\uD83D\uDCE5" }), _jsx("button", { style: {
100
- padding: "2px 6px",
101
- font: "inherit",
102
- background: "transparent",
103
- color: "inherit",
104
- border: "1px solid rgba(255,255,255,0.18)",
105
- borderRadius: 3,
106
- cursor: "pointer",
107
- }, onClick: () => saveJson(loadedPrefab, "prefab"), onPointerEnter: (e) => {
108
- e.currentTarget.style.background = "rgba(255,255,255,0.08)";
109
- }, onPointerLeave: (e) => {
110
- e.currentTarget.style.background = "transparent";
111
- }, children: "\uD83D\uDCBE" })] }), editMode && _jsx(EditorUI, { prefabData: loadedPrefab, setPrefabData: updatePrefab, selectedId: selectedId, setSelectedId: setSelectedId, transformMode: transformMode, setTransformMode: setTransformMode, basePath: basePath })] });
53
+ editMode: editMode, onPrefabChange: updatePrefab, selectedId: selectedId, onSelect: setSelectedId, transformMode: transformMode, setTransformMode: setTransformMode, basePath: basePath }), children] }) }), _jsx(SaveDataPanel, { currentData: loadedPrefab, onDataChange: updatePrefab, editMode: editMode, onEditModeChange: setEditMode }), editMode && _jsx(EditorUI, { prefabData: loadedPrefab, setPrefabData: updatePrefab, selectedId: selectedId, setSelectedId: setSelectedId, transformMode: transformMode, setTransformMode: setTransformMode, basePath: basePath })] });
54
+ };
55
+ const SaveDataPanel = ({ currentData, onDataChange, editMode, onEditModeChange }) => {
56
+ const [history, setHistory] = useState([currentData]);
57
+ const [historyIndex, setHistoryIndex] = useState(0);
58
+ const throttleTimeoutRef = useRef(null);
59
+ const lastSavedDataRef = useRef(JSON.stringify(currentData));
60
+ // Define undo/redo handlers
61
+ const handleUndo = () => {
62
+ if (historyIndex > 0) {
63
+ const newIndex = historyIndex - 1;
64
+ setHistoryIndex(newIndex);
65
+ lastSavedDataRef.current = JSON.stringify(history[newIndex]);
66
+ onDataChange(history[newIndex]);
67
+ }
68
+ };
69
+ const handleRedo = () => {
70
+ if (historyIndex < history.length - 1) {
71
+ const newIndex = historyIndex + 1;
72
+ setHistoryIndex(newIndex);
73
+ lastSavedDataRef.current = JSON.stringify(history[newIndex]);
74
+ onDataChange(history[newIndex]);
75
+ }
76
+ };
77
+ // Keyboard shortcuts for undo/redo
78
+ useEffect(() => {
79
+ const handleKeyDown = (e) => {
80
+ // Undo: Ctrl+Z (Cmd+Z on Mac)
81
+ if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
82
+ e.preventDefault();
83
+ handleUndo();
84
+ }
85
+ // Redo: Ctrl+Shift+Z or Ctrl+Y (Cmd+Shift+Z or Cmd+Y on Mac)
86
+ else if ((e.ctrlKey || e.metaKey) && (e.shiftKey && e.key === 'z' || e.key === 'y')) {
87
+ e.preventDefault();
88
+ handleRedo();
89
+ }
90
+ };
91
+ window.addEventListener('keydown', handleKeyDown);
92
+ return () => window.removeEventListener('keydown', handleKeyDown);
93
+ }, [historyIndex, history]);
94
+ // Throttled history update when currentData changes
95
+ useEffect(() => {
96
+ const currentDataStr = JSON.stringify(currentData);
97
+ // Skip if data hasn't actually changed
98
+ if (currentDataStr === lastSavedDataRef.current) {
99
+ return;
100
+ }
101
+ // Clear existing throttle timeout
102
+ if (throttleTimeoutRef.current) {
103
+ clearTimeout(throttleTimeoutRef.current);
104
+ }
105
+ // Set new throttled update
106
+ throttleTimeoutRef.current = setTimeout(() => {
107
+ lastSavedDataRef.current = currentDataStr;
108
+ setHistory(prev => {
109
+ // Slice history at current index (discard future states)
110
+ const newHistory = prev.slice(0, historyIndex + 1);
111
+ // Add new state
112
+ newHistory.push(currentData);
113
+ // Limit history size to 50 states
114
+ if (newHistory.length > 50) {
115
+ newHistory.shift();
116
+ return newHistory;
117
+ }
118
+ return newHistory;
119
+ });
120
+ setHistoryIndex(prev => {
121
+ const newHistory = history.slice(0, prev + 1);
122
+ newHistory.push(currentData);
123
+ return Math.min(newHistory.length - 1, 49);
124
+ });
125
+ }, 500); // 500ms throttle
126
+ return () => {
127
+ if (throttleTimeoutRef.current) {
128
+ clearTimeout(throttleTimeoutRef.current);
129
+ }
130
+ };
131
+ }, [currentData, historyIndex, history]);
132
+ const handleLoad = () => __awaiter(void 0, void 0, void 0, function* () {
133
+ const prefab = yield loadJson();
134
+ if (prefab) {
135
+ onDataChange(prefab);
136
+ // Reset history when loading new file
137
+ setHistory([prefab]);
138
+ setHistoryIndex(0);
139
+ lastSavedDataRef.current = JSON.stringify(prefab);
140
+ }
141
+ });
142
+ const canUndo = historyIndex > 0;
143
+ const canRedo = historyIndex < history.length - 1;
144
+ return _jsxs("div", { style: {
145
+ position: "absolute",
146
+ top: 8,
147
+ left: "50%",
148
+ transform: "translateX(-50%)",
149
+ display: "flex",
150
+ alignItems: "center",
151
+ gap: 6,
152
+ padding: "2px 4px",
153
+ background: "rgba(0,0,0,0.55)",
154
+ border: "1px solid rgba(255,255,255,0.12)",
155
+ borderRadius: 4,
156
+ color: "rgba(255,255,255,0.9)",
157
+ fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
158
+ fontSize: 11,
159
+ lineHeight: 1,
160
+ WebkitUserSelect: "none",
161
+ userSelect: "none",
162
+ }, children: [_jsx(PanelButton, { onClick: () => onEditModeChange(!editMode), children: editMode ? "▶" : "⏸" }), _jsx("span", { style: { opacity: 0.35 }, children: "|" }), _jsx(PanelButton, { onClick: handleUndo, disabled: !canUndo, title: "Undo (Ctrl+Z)", children: "\u21B6" }), _jsx(PanelButton, { onClick: handleRedo, disabled: !canRedo, title: "Redo (Ctrl+Shift+Z)", children: "\u21B7" }), _jsx("span", { style: { opacity: 0.35 }, children: "|" }), _jsx(PanelButton, { onClick: handleLoad, title: "Load JSON", children: "\uD83D\uDCE5" }), _jsx(PanelButton, { onClick: () => saveJson(currentData, "prefab"), title: "Save JSON", children: "\uD83D\uDCBE" })] });
163
+ };
164
+ const PanelButton = ({ onClick, disabled, title, children }) => {
165
+ return _jsx("button", { style: {
166
+ padding: "2px 6px",
167
+ font: "inherit",
168
+ background: "transparent",
169
+ color: disabled ? "rgba(255,255,255,0.3)" : "inherit",
170
+ border: "1px solid rgba(255,255,255,0.18)",
171
+ borderRadius: 3,
172
+ cursor: disabled ? "not-allowed" : "pointer",
173
+ opacity: disabled ? 0.5 : 1,
174
+ }, onClick: onClick, disabled: disabled, title: title, onPointerEnter: (e) => {
175
+ if (!disabled) {
176
+ e.currentTarget.style.background = "rgba(255,255,255,0.08)";
177
+ }
178
+ }, onPointerLeave: (e) => {
179
+ e.currentTarget.style.background = "transparent";
180
+ }, children: children });
112
181
  };
113
182
  const saveJson = (data, filename) => {
114
183
  const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(data, null, 2));