react-three-game 0.0.6 → 0.0.8

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
@@ -10,6 +10,8 @@ npm i react-three-game @react-three/fiber three
10
10
  [![TypeScript](https://img.shields.io/badge/TypeScript-5.0-blue.svg)](https://www.typescriptlang.org/)
11
11
  [![React](https://img.shields.io/badge/React-19-blue.svg)](https://react.dev/)
12
12
 
13
+ ![Prefab Editor](assets/editor.gif)
14
+
13
15
  ## Core Principle
14
16
 
15
17
  Scenes are JSON prefabs. Components are registered modules. Hierarchy is declarative.
Binary file
@@ -167,12 +167,14 @@ function GameObjectRenderer({ gameObject, selectedId, onSelect, registerRef, loa
167
167
  }
168
168
  // --- 3. Core content decided by component registry ---
169
169
  const core = renderCoreNode(gameObject, ctx, parentMatrix);
170
- // --- 4. Wrap with physics if needed ---
171
- const physicsWrapped = wrapPhysicsIfNeeded(gameObject, core, ctx);
172
170
  // --- 5. Render children (always relative transforms) ---
173
171
  const children = ((_d = gameObject.children) !== null && _d !== void 0 ? _d : []).map((child) => (_jsx(GameObjectRenderer, { gameObject: child, selectedId: selectedId, onSelect: onSelect, registerRef: registerRef, loadedModels: loadedModels, loadedTextures: loadedTextures, editMode: editMode, parentMatrix: worldMatrix }, child.id)));
172
+ // --- 4. Wrap with physics if needed ---
173
+ // Combine core and children so they both get wrapped by physics (if present)
174
+ const content = (_jsxs(_Fragment, { children: [core, children] }));
175
+ const physicsWrapped = wrapPhysicsIfNeeded(gameObject, content, ctx);
174
176
  // --- 6. Final group wrapper ---
175
- return (_jsxs("group", { ref: (el) => registerRef(gameObject.id, el), position: transformProps.position, rotation: transformProps.rotation, scale: transformProps.scale, onPointerDown: handlePointerDown, onPointerMove: handlePointerMove, onPointerUp: handlePointerUp, children: [physicsWrapped, children] }));
177
+ return (_jsx("group", { ref: (el) => registerRef(gameObject.id, el), position: transformProps.position, rotation: transformProps.rotation, scale: transformProps.scale, onPointerDown: handlePointerDown, onPointerMove: handlePointerMove, onPointerUp: handlePointerUp, children: physicsWrapped }));
176
178
  }
177
179
  // Helper: render an instanced GameInstance (terminal node)
178
180
  function renderInstancedNode(gameObject, worldMatrix, ctx) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-three-game",
3
- "version": "0.0.6",
3
+ "version": "0.0.8",
4
4
  "description": "Batteries included React Three Fiber game engine",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.js",
@@ -13,7 +13,7 @@
13
13
  },
14
14
  "keywords": [],
15
15
  "author": "prnth",
16
- "license": "ISC",
16
+ "license": "VPL",
17
17
  "type": "module",
18
18
  "peerDependencies": {
19
19
  "@react-three/fiber": "^9.0.0",
package/src/index.ts ADDED
@@ -0,0 +1,15 @@
1
+ // Components
2
+ export { default as GameCanvas } from './shared/GameCanvas';
3
+ export { default as PrefabEditor } from './tools/prefabeditor/PrefabEditor';
4
+ export { default as PrefabRoot } from './tools/prefabeditor/PrefabRoot';
5
+ export { DragDropLoader } from './tools/dragdrop/DragDropLoader';
6
+ export {
7
+ default as AssetViewerPage,
8
+ TextureListViewer,
9
+ ModelListViewer,
10
+ SoundListViewer,
11
+ SharedCanvas,
12
+ } from './tools/assetviewer/page';
13
+
14
+ // Types
15
+ export type { Prefab, GameObject } from './tools/prefabeditor/types';
@@ -0,0 +1,48 @@
1
+ import { Canvas, extend } from "@react-three/fiber";
2
+ import { WebGPURenderer, MeshBasicNodeMaterial, MeshStandardNodeMaterial, SpriteNodeMaterial, PCFShadowMap } from "three/webgpu";
3
+ import { Suspense, useState } from "react";
4
+ import { WebGPURendererParameters } from "three/src/renderers/webgpu/WebGPURenderer.Nodes.js";
5
+ import { Loader } from "@react-three/drei";
6
+
7
+ // generic version
8
+ // extend(THREE as any)
9
+
10
+ extend({
11
+ MeshBasicNodeMaterial: MeshBasicNodeMaterial,
12
+ MeshStandardNodeMaterial: MeshStandardNodeMaterial,
13
+ SpriteNodeMaterial: SpriteNodeMaterial,
14
+ });
15
+
16
+
17
+ export default function GameCanvas({ loader = false, children, ...props }: { loader?: boolean, children: React.ReactNode, props?: WebGPURendererParameters }) {
18
+ const [frameloop, setFrameloop] = useState<"never" | "always">("never");
19
+ const [loading, setLoading] = useState(true);
20
+
21
+ return <>
22
+ <Canvas
23
+ shadows={{ type: PCFShadowMap, }}
24
+ frameloop={frameloop}
25
+ gl={async ({ canvas }) => {
26
+ const renderer = new WebGPURenderer({
27
+ canvas: canvas as HTMLCanvasElement,
28
+ // @ts-expect-error futuristic
29
+ shadowMap: true,
30
+ antialias: true,
31
+ ...props,
32
+ });
33
+ await renderer.init().then(() => {
34
+ setFrameloop("always");
35
+ });
36
+ return renderer
37
+ }}
38
+ camera={{
39
+ position: [0, 1, 5],
40
+ }}
41
+ >
42
+ <Suspense>
43
+ {children}
44
+ </Suspense>
45
+ </Canvas>
46
+ {loader ? <Loader /> : null}
47
+ </>;
48
+ }
@@ -0,0 +1,411 @@
1
+ "use client";
2
+
3
+ import { Canvas, useLoader } from "@react-three/fiber";
4
+ import { OrbitControls, useGLTF, useFBX, Stage, View, PerspectiveCamera } from "@react-three/drei";
5
+ import { Suspense, useEffect, useState, useRef } from "react";
6
+ import * as React from "react";
7
+ import { TextureLoader } from "three";
8
+
9
+ // view models and textures in manifest, onselect callback
10
+
11
+ function getItemsInPath(files: string[], currentPath: string) {
12
+ // Remove the leading category folder (e.g., /textures/, /models/, /sounds/)
13
+ const filesWithoutCategory = files.map(file => {
14
+ const parts = file.split('/').filter(Boolean);
15
+ return parts.length > 1 ? '/' + parts.slice(1).join('/') : '';
16
+ }).filter(Boolean);
17
+
18
+ const prefix = currentPath ? `/${currentPath}/` : '/';
19
+ const relevantFiles = filesWithoutCategory.filter(file => file.startsWith(prefix));
20
+
21
+ const folders = new Set<string>();
22
+ const filesInCurrentPath: string[] = [];
23
+
24
+ relevantFiles.forEach((file, index) => {
25
+ const relativePath = file.slice(prefix.length);
26
+ const parts = relativePath.split('/').filter(Boolean);
27
+
28
+ if (parts.length > 1) {
29
+ folders.add(parts[0]);
30
+ } else if (parts[0]) {
31
+ // Return the original file path
32
+ filesInCurrentPath.push(files[filesWithoutCategory.indexOf(file)]);
33
+ }
34
+ });
35
+
36
+ return { folders: Array.from(folders), filesInCurrentPath };
37
+ }
38
+
39
+ function FolderTile({ name, onClick }: { name: string; onClick: () => void }) {
40
+ return (
41
+ <div
42
+ onClick={onClick}
43
+ className="aspect-square bg-gray-800 cursor-pointer hover:bg-gray-700 flex flex-col items-center justify-center"
44
+ >
45
+ <div className="text-3xl">📁</div>
46
+ <div className="text-xs text-center truncate w-full px-1 mt-1">{name}</div>
47
+ </div>
48
+ );
49
+ }
50
+
51
+ function useInView() {
52
+ const [isInView, setIsInView] = useState(false);
53
+ const ref = useRef<HTMLDivElement>(null);
54
+
55
+ useEffect(() => {
56
+ const observer = new IntersectionObserver(
57
+ ([entry]) => {
58
+ setIsInView(entry.isIntersecting);
59
+ },
60
+ { rootMargin: '100px' }
61
+ );
62
+
63
+ if (ref.current) {
64
+ observer.observe(ref.current);
65
+ }
66
+
67
+ return () => {
68
+ if (ref.current) {
69
+ observer.unobserve(ref.current);
70
+ }
71
+ };
72
+ }, []);
73
+
74
+ return { ref, isInView };
75
+ }
76
+
77
+ export default function AssetViewerPage({ basePath = "" }: { basePath?: string } = {}) {
78
+ const [textures, setTextures] = useState<string[]>([]);
79
+ const [models, setModels] = useState<string[]>([]);
80
+ const [sounds, setSounds] = useState<string[]>([]);
81
+ const [loading, setLoading] = useState(true);
82
+
83
+ useEffect(() => {
84
+ const base = basePath ? `${basePath}/` : '';
85
+ Promise.all([
86
+ fetch(`/${base}textures/manifest.json`).then(r => r.json()),
87
+ fetch(`/${base}models/manifest.json`).then(r => r.json()),
88
+ fetch(`/${base}sound/manifest.json`).then(r => r.json()).catch(() => [])
89
+ ]).then(([textureData, modelData, soundData]) => {
90
+ setTextures(textureData);
91
+ setModels(modelData);
92
+ setSounds(soundData);
93
+ setLoading(false);
94
+ });
95
+ }, [basePath]);
96
+
97
+ if (loading) {
98
+ return <div className="p-4 text-gray-300">Loading manifests...</div>;
99
+ }
100
+
101
+ return (
102
+ <>
103
+ <div className="p-2 text-gray-300 overflow-y-auto h-screen text-sm">
104
+ <h1 className="text-lg mb-2 font-bold">Asset Viewer</h1>
105
+
106
+ <h2 className="text-sm mt-4 mb-1 font-semibold">Textures ({textures.length})</h2>
107
+ <TextureListViewer files={textures} basePath={basePath} onSelect={(file) => console.log('Selected texture:', file)} />
108
+
109
+ <h2 className="text-sm mt-4 mb-1 font-semibold">Models ({models.length})</h2>
110
+ <ModelListViewer files={models} basePath={basePath} onSelect={(file) => console.log('Selected model:', file)} />
111
+
112
+ {sounds.length > 0 && (
113
+ <>
114
+ <h2 className="text-sm mt-4 mb-1 font-semibold">Sounds ({sounds.length})</h2>
115
+ <SoundListViewer files={sounds} basePath={basePath} onSelect={(file) => console.log('Selected sound:', file)} />
116
+ </>
117
+ )}
118
+ </div>
119
+ <SharedCanvas />
120
+ </>
121
+ );
122
+ }
123
+
124
+ interface AssetListViewerProps {
125
+ files: string[];
126
+ selected?: string;
127
+ onSelect: (file: string) => void;
128
+ renderCard: (file: string, onSelect: (file: string) => void) => React.ReactNode;
129
+ }
130
+
131
+ function AssetListViewer({ files, selected, onSelect, renderCard }: AssetListViewerProps) {
132
+ const [currentPath, setCurrentPath] = useState('');
133
+ const [showPicker, setShowPicker] = useState(false);
134
+ const { folders, filesInCurrentPath } = getItemsInPath(files, currentPath);
135
+
136
+ const showCompactView = selected && !showPicker;
137
+
138
+ if (showCompactView) {
139
+ return (
140
+ <div className="flex gap-1 items-center">
141
+ {renderCard(selected, onSelect)}
142
+ <button
143
+ onClick={() => setShowPicker(true)}
144
+ className="px-2 py-1 bg-gray-800 hover:bg-gray-700 text-xs"
145
+ >
146
+ Change
147
+ </button>
148
+ </div>
149
+ );
150
+ }
151
+
152
+ return (
153
+ <div>
154
+ {currentPath && (
155
+ <button
156
+ onClick={() => {
157
+ const pathParts = currentPath.split('/').filter(Boolean);
158
+ pathParts.pop();
159
+ setCurrentPath(pathParts.join('/'));
160
+ }}
161
+ className="mb-1 px-2 py-1 bg-gray-800 hover:bg-gray-700 text-xs"
162
+ >
163
+ ← Back
164
+ </button>
165
+ )}
166
+ <div className="grid grid-cols-3 gap-1">
167
+ {folders.map((folder) => (
168
+ <FolderTile
169
+ key={folder}
170
+ name={folder}
171
+ onClick={() => setCurrentPath(currentPath ? `${currentPath}/${folder}` : folder)}
172
+ />
173
+ ))}
174
+ {filesInCurrentPath.map((file) => (
175
+ <div key={file}>
176
+ {renderCard(file, (f) => {
177
+ onSelect(f);
178
+ if (selected) setShowPicker(false);
179
+ })}
180
+ </div>
181
+ ))}
182
+ </div>
183
+ </div>
184
+ );
185
+ }
186
+
187
+ interface TextureListViewerProps {
188
+ files: string[];
189
+ selected?: string;
190
+ onSelect: (file: string) => void;
191
+ basePath?: string;
192
+ }
193
+
194
+ export function TextureListViewer({ files, selected, onSelect, basePath = "" }: TextureListViewerProps) {
195
+ return (
196
+ <>
197
+ <AssetListViewer
198
+ files={files}
199
+ selected={selected}
200
+ onSelect={onSelect}
201
+ renderCard={(file, onSelectHandler) => (
202
+ <TextureCard file={file} basePath={basePath} onSelect={onSelectHandler} />
203
+ )}
204
+ />
205
+ <SharedCanvas />
206
+ </>
207
+ );
208
+ }
209
+
210
+ function TextureCard({ file, onSelect, basePath = "" }: { file: string; onSelect: (file: string) => void; basePath?: string }) {
211
+ const [error, setError] = useState(false);
212
+ const [isHovered, setIsHovered] = useState(false);
213
+ const { ref, isInView } = useInView();
214
+ const fullPath = basePath ? `/${basePath}${file}` : file;
215
+
216
+ if (error) {
217
+ return (
218
+ <div
219
+ ref={ref}
220
+ className="aspect-square bg-gray-700 cursor-pointer hover:bg-gray-600 flex items-center justify-center"
221
+ onClick={() => onSelect(file)}
222
+ >
223
+ <div className="text-red-400 text-xs">✗</div>
224
+ </div>
225
+ );
226
+ }
227
+
228
+ return (
229
+ <div
230
+ ref={ref}
231
+ className="aspect-square bg-gray-800 cursor-pointer hover:bg-gray-700 flex flex-col"
232
+ onClick={() => onSelect(file)}
233
+ onMouseEnter={() => setIsHovered(true)}
234
+ onMouseLeave={() => setIsHovered(false)}
235
+ >
236
+ <div className="flex-1 relative">
237
+ {isInView ? (
238
+ <View className="w-full h-full">
239
+ <PerspectiveCamera makeDefault position={[0, 0, 2.5]} fov={50} />
240
+ <Suspense fallback={null}>
241
+ <ambientLight intensity={0.8} />
242
+ <pointLight position={[5, 5, 5]} intensity={0.5} />
243
+ <TextureSphere url={fullPath} onError={() => setError(true)} />
244
+ <OrbitControls
245
+ enableZoom={false}
246
+ enablePan={false}
247
+ autoRotate={isHovered}
248
+ autoRotateSpeed={2}
249
+ />
250
+ </Suspense>
251
+ </View>
252
+ ) : null}
253
+ </div>
254
+ <div className="bg-black/60 text-[10px] px-1 truncate text-center">
255
+ {file.split('/').pop()}
256
+ </div>
257
+ </div>
258
+ );
259
+ }
260
+
261
+ function TextureSphere({ url, onError }: { url: string; onError?: () => void }) {
262
+ const texture = useLoader(TextureLoader, url, undefined, (error) => {
263
+ console.error('Failed to load texture:', url, error);
264
+ onError?.();
265
+ });
266
+ return (
267
+ <mesh position={[0, 0, 0]}>
268
+ <sphereGeometry args={[1, 32, 32]} />
269
+ <meshStandardMaterial map={texture} />
270
+ </mesh>
271
+ );
272
+ }
273
+
274
+ interface ModelListViewerProps {
275
+ files: string[];
276
+ selected?: string;
277
+ onSelect: (file: string) => void;
278
+ basePath?: string;
279
+ }
280
+
281
+ export function ModelListViewer({ files, selected, onSelect, basePath = "" }: ModelListViewerProps) {
282
+ return (
283
+ <>
284
+ <AssetListViewer
285
+ files={files}
286
+ selected={selected}
287
+ onSelect={onSelect}
288
+ renderCard={(file, onSelectHandler) => (
289
+ <ModelCard file={file} basePath={basePath} onSelect={onSelectHandler} />
290
+ )}
291
+ />
292
+ <SharedCanvas />
293
+ </>
294
+ );
295
+ }
296
+
297
+ function ModelCard({ file, onSelect, basePath = "" }: { file: string; onSelect: (file: string) => void; basePath?: string }) {
298
+ const [error, setError] = useState(false);
299
+ const { ref, isInView } = useInView();
300
+ const fullPath = basePath ? `/${basePath}${file}` : file;
301
+
302
+ if (error) {
303
+ return (
304
+ <div
305
+ ref={ref}
306
+ className="aspect-square bg-gray-700 cursor-pointer hover:bg-gray-600 flex items-center justify-center"
307
+ onClick={() => onSelect(file)}
308
+ >
309
+ <div className="text-red-400 text-xs">✗</div>
310
+ </div>
311
+ );
312
+ }
313
+
314
+ return (
315
+ <div
316
+ ref={ref}
317
+ className="aspect-square bg-gray-900 cursor-pointer hover:bg-gray-800 flex flex-col"
318
+ onClick={() => onSelect(file)}
319
+ >
320
+ <div className="flex-1 relative">
321
+ {isInView ? (
322
+ <View className="w-full h-full">
323
+ <PerspectiveCamera makeDefault position={[0, 1, 3]} fov={50} />
324
+ <Suspense fallback={null}>
325
+ <Stage intensity={0.5} environment="city">
326
+ <ModelPreview url={fullPath} onError={() => setError(true)} />
327
+ </Stage>
328
+ <OrbitControls enableZoom={false} />
329
+ </Suspense>
330
+ </View>
331
+ ) : null}
332
+ </div>
333
+ <div className="bg-black/60 text-[10px] px-1 truncate text-center">
334
+ {file.split('/').pop()}
335
+ </div>
336
+ </div>
337
+ );
338
+ }
339
+
340
+ function ModelPreview({ url, onError }: { url: string; onError?: () => void }) {
341
+ const isFbx = url.toLowerCase().endsWith('.fbx');
342
+ if (isFbx) return <FBXModel url={url} onError={onError} />;
343
+ return <GLTFModel url={url} onError={onError} />;
344
+ }
345
+
346
+ function GLTFModel({ url, onError }: { url: string; onError?: () => void }) {
347
+ const { scene } = useGLTF(url);
348
+ return <primitive object={scene} />;
349
+ }
350
+
351
+ function FBXModel({ url, onError }: { url: string; onError?: () => void }) {
352
+ const fbx = useFBX(url);
353
+ return <primitive object={fbx} scale={0.01} />;
354
+ }
355
+
356
+ interface SoundListViewerProps {
357
+ files: string[];
358
+ selected?: string;
359
+ onSelect: (file: string) => void;
360
+ basePath?: string;
361
+ }
362
+
363
+ export function SoundListViewer({ files, selected, onSelect, basePath = "" }: SoundListViewerProps) {
364
+ return (
365
+ <AssetListViewer
366
+ files={files}
367
+ selected={selected}
368
+ onSelect={onSelect}
369
+ renderCard={(file, onSelectHandler) => (
370
+ <SoundCard file={file} basePath={basePath} onSelect={onSelectHandler} />
371
+ )}
372
+ />
373
+ );
374
+ }
375
+
376
+ function SoundCard({ file, onSelect, basePath = "" }: { file: string; onSelect: (file: string) => void; basePath?: string }) {
377
+ const fileName = file.split('/').pop() || '';
378
+ const fullPath = basePath ? `/${basePath}${file}` : file;
379
+ return (
380
+ <div
381
+ onClick={() => onSelect(file)}
382
+ className="aspect-square bg-gray-700 cursor-pointer hover:bg-gray-600 flex flex-col items-center justify-center"
383
+ >
384
+ <div className="text-2xl">🔊</div>
385
+ <div className="text-[10px] px-1 mt-1 truncate text-center w-full">{fileName}</div>
386
+ </div>
387
+ );
388
+ }
389
+
390
+ // Shared Canvas Component - can be used independently in any viewer
391
+ export function SharedCanvas() {
392
+ return (
393
+ <Canvas
394
+ shadows
395
+ dpr={[1, 1.5]}
396
+ camera={{ position: [0, 0, 3], fov: 45, near: 0.1, far: 1000 }}
397
+ style={{
398
+ position: 'fixed',
399
+ top: 0,
400
+ left: 0,
401
+ width: '100vw',
402
+ height: '100vh',
403
+ pointerEvents: 'none',
404
+ }}
405
+ eventSource={typeof document !== 'undefined' ? document.getElementById('root') || undefined : undefined}
406
+ eventPrefix="client"
407
+ >
408
+ <View.Port />
409
+ </Canvas>
410
+ );
411
+ }
@@ -0,0 +1,105 @@
1
+ // DragDropLoader.tsx
2
+ import { useEffect, ChangeEvent } from "react";
3
+ import { DRACOLoader, FBXLoader, GLTFLoader } from "three/examples/jsm/Addons.js";
4
+
5
+ interface DragDropLoaderProps {
6
+ onModelLoaded: (model: any, filename: string) => void;
7
+ }
8
+
9
+ // Shared file handling logic
10
+ function handleFiles(files: File[], onModelLoaded: (model: any, filename: string) => void) {
11
+ files.forEach((file) => {
12
+ if (file.name.endsWith(".glb") || file.name.endsWith(".gltf")) {
13
+ loadGLTFFile(file, onModelLoaded);
14
+ } else if (file.name.endsWith(".fbx")) {
15
+ loadFBXFile(file, onModelLoaded);
16
+ }
17
+ });
18
+ }
19
+
20
+ function loadGLTFFile(file: File, onModelLoaded: (model: any, filename: string) => void) {
21
+ const reader = new FileReader();
22
+ reader.onload = (event) => {
23
+ const arrayBuffer = event.target?.result;
24
+ if (arrayBuffer) {
25
+ const loader = new GLTFLoader();
26
+ const dracoLoader = new DRACOLoader();
27
+ dracoLoader.setDecoderPath("https://www.gstatic.com/draco/v1/decoders/");
28
+ loader.setDRACOLoader(dracoLoader);
29
+ loader.parse(arrayBuffer as ArrayBuffer, "", (gltf) => {
30
+ onModelLoaded(gltf.scene, file.name);
31
+ }, (error) => {
32
+ console.error("GLTFLoader parse error", error);
33
+ });
34
+ }
35
+ };
36
+ reader.readAsArrayBuffer(file);
37
+ }
38
+
39
+ function loadFBXFile(file: File, onModelLoaded: (model: any, filename: string) => void) {
40
+ const reader = new FileReader();
41
+ reader.onload = (event) => {
42
+ const arrayBuffer = event.target?.result;
43
+ if (arrayBuffer) {
44
+ const loader = new FBXLoader();
45
+ const model = loader.parse(arrayBuffer as ArrayBuffer, "");
46
+ onModelLoaded(model, file.name);
47
+ }
48
+ };
49
+ reader.readAsArrayBuffer(file);
50
+ }
51
+
52
+ export function DragDropLoader({ onModelLoaded }: DragDropLoaderProps) {
53
+ useEffect(() => {
54
+ function handleDrop(e: DragEvent) {
55
+ e.preventDefault();
56
+ e.stopPropagation();
57
+ const files = e.dataTransfer?.files ? Array.from(e.dataTransfer.files) : [];
58
+ handleFiles(files, onModelLoaded);
59
+ }
60
+ function handleDragOver(e: DragEvent) {
61
+ e.preventDefault();
62
+ e.stopPropagation();
63
+ }
64
+ window.addEventListener("drop", handleDrop);
65
+ window.addEventListener("dragover", handleDragOver);
66
+ return () => {
67
+ window.removeEventListener("drop", handleDrop);
68
+ window.removeEventListener("dragover", handleDragOver);
69
+ };
70
+ }, [onModelLoaded]);
71
+ return null;
72
+ }
73
+
74
+ // FilePicker component
75
+ interface FilePickerProps {
76
+ onModelLoaded: (model: any, filename: string) => void;
77
+ }
78
+
79
+ export function FilePicker({ onModelLoaded }: FilePickerProps) {
80
+ function onChange(e: ChangeEvent<HTMLInputElement>) {
81
+ const files = e.target.files ? Array.from(e.target.files) : [];
82
+ handleFiles(files, onModelLoaded);
83
+ }
84
+ // Ref for the hidden input
85
+ const inputId = "file-picker-input";
86
+ return (
87
+ <>
88
+ <input
89
+ id={inputId}
90
+ type="file"
91
+ accept=".glb,.gltf,.fbx"
92
+ multiple
93
+ onChange={onChange}
94
+ className="hidden"
95
+ />
96
+ <button
97
+ className="px-3 py-1 bg-blue-500/20 hover:bg-blue-500/30 border border-blue-400/40 hover:border-blue-400/60 text-blue-200 hover:text-blue-100 text-xs font-medium transition-all"
98
+ type="button"
99
+ onClick={() => document.getElementById(inputId)?.click()}
100
+ >
101
+ Select Files
102
+ </button>
103
+ </>
104
+ );
105
+ }