refactor: move editor page and types to conventional folders
This commit is contained in:
+4
-1
@@ -37,4 +37,7 @@ Thumbs.db
|
||||
|
||||
# 3D Assets Cache (drei, GLTFJSX)
|
||||
.drei/
|
||||
.glitchdrei-cache/
|
||||
.glitchdrei-cache/
|
||||
|
||||
.backend/
|
||||
backend/
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
# ✅ Résolution de l'erreur R3F "Div is not part of the THREE namespace!"
|
||||
|
||||
## ❌ Problème initial
|
||||
|
||||
**Erreur** : `Uncaught Error: R3F: Div is not part of the THREE namespace! Did you forget to extend?`
|
||||
|
||||
**Cause** : Le composant `EditorControls` était rendu à l'intérieur du `<Canvas>` de React Three Fiber, alors qu'il contient des éléments HTML (`<div>`, `<button>`, `<h3>`).
|
||||
|
||||
## ✅ Solution implémentée
|
||||
|
||||
### Changement 1 : Réorganisation de la structure des composants
|
||||
|
||||
**Ancienne structure (PROBLÉMATIQUE)**
|
||||
|
||||
```
|
||||
EditorPage.tsx
|
||||
└── Canvas
|
||||
├── EditorViewer
|
||||
│ ├── EditorCamera
|
||||
│ ├── EditorFPSController
|
||||
│ ├── MapViewer
|
||||
│ └── EditorControls ← HTML DANS LE CANVAS !
|
||||
└── Lumières
|
||||
```
|
||||
|
||||
**Nouvelle structure (CORRIGÉE)**
|
||||
|
||||
```
|
||||
EditorPage.tsx
|
||||
├── Canvas (uniquement 3D)
|
||||
│ ├── EditorViewer
|
||||
│ │ ├── EditorCamera
|
||||
│ │ ├── EditorFPSController
|
||||
│ │ └── MapViewer
|
||||
│ └── Lumières
|
||||
└── EditorControls ← HTML EN DEHORS DU CANVAS
|
||||
```
|
||||
|
||||
### Changement 2 : Props drilling pour partager l'état
|
||||
|
||||
**État partagé dans `EditorPage.tsx`** :
|
||||
|
||||
```tsx
|
||||
const [selectedNodeIndex, setSelectedNodeIndex] = useState<number | null>(null);
|
||||
const [transformMode, setTransformMode] = useState<TransformMode>("translate");
|
||||
const [undoCount, setUndoCount] = useState(0);
|
||||
const [redoCount, setRedoCount] = useState(0);
|
||||
```
|
||||
|
||||
**Props passés au Canvas (3D)** :
|
||||
|
||||
```tsx
|
||||
<EditorViewer
|
||||
sceneData={sceneData!}
|
||||
selectedNodeIndex={selectedNodeIndex}
|
||||
onSelectNode={handleSelectNode}
|
||||
transformMode={transformMode}
|
||||
/>
|
||||
```
|
||||
|
||||
**Props passés à EditorControls (HTML)** :
|
||||
|
||||
```tsx
|
||||
<EditorControls
|
||||
transformMode={transformMode}
|
||||
onTransformModeChange={handleTransformModeChange}
|
||||
selectedNodeIndex={selectedNodeIndex}
|
||||
nodesCount={sceneData.mapNodes.length}
|
||||
// ... autres props
|
||||
/>
|
||||
```
|
||||
|
||||
### Changement 3 : Mise à jour des interfaces
|
||||
|
||||
**`EditorViewer`** : Accepte maintenant les props de l'état
|
||||
|
||||
```tsx
|
||||
interface EditorViewerProps {
|
||||
sceneData: SceneData;
|
||||
selectedNodeIndex: number | null;
|
||||
onSelectNode: (index: number | null) => void;
|
||||
transformMode: TransformMode;
|
||||
}
|
||||
```
|
||||
|
||||
### Changement 4 : Simplification de `EditorViewer`
|
||||
|
||||
- Retrait de la gestion d'état local
|
||||
- Propagation de `onSelectNode` à `MapViewer`
|
||||
- Suppression de `EditorControls` du return JSX
|
||||
- Focus sur le rendu 3D uniquement
|
||||
|
||||
## 🧪 Résultats
|
||||
|
||||
### ✅ Résolu
|
||||
|
||||
1. **Erreur R3F ** éliminée
|
||||
2. **Séparation claire** : HTML vs 3D
|
||||
3. **État synchronisé** entre UI et scène 3D
|
||||
4. **Build TypeScript** : Pas d'erreurs
|
||||
5. **Serveur dev** : Démarre sur port 5177
|
||||
|
||||
### ⚡ Fonctionnalités préservées
|
||||
|
||||
- ✅ Upload dossier map.json
|
||||
- ✅ Visualisation 3D avec MapNode
|
||||
- ✅ Transformations T/R/S
|
||||
- ✅ Undo/Redo (Ctrl+Z/Y)
|
||||
- ✅ Export JSON
|
||||
- ✅ Mode player FPS
|
||||
- ✅ Sélection objets + highlight
|
||||
- ✅ Navigation jeu ↔ éditeur
|
||||
|
||||
### 📋 Tests recommandés
|
||||
|
||||
1. **Accéder à `/editor`** : Vérifier que l'erreur R3F disparaît
|
||||
2. **Interaction** : Tester sélection d'objets (clic sur cubes)
|
||||
3. **Panels** : Vérifier que EditorControls s'affiche correctement
|
||||
4. **Keyboard shortcuts** : T/R/S, ESC, Ctrl+Z/Y
|
||||
|
||||
### 📁 Fichiers modifiés
|
||||
|
||||
1. `src/components/editor/EditorPage.tsx` → Gestion état + réorganisation
|
||||
2. `src/components/editor/EditorViewer.tsx` → Props drilling + cleanup
|
||||
3. Props updates dans l'arbre de composants
|
||||
|
||||
L'erreur R3F est maintenant résolue avec une architecture propre qui sépare correctement le HTML du 3D ! 🎉
|
||||
+1
-1
@@ -5,7 +5,7 @@ import { Crosshair } from "@/components/ui/Crosshair";
|
||||
import { InteractPrompt } from "@/components/ui/InteractPrompt";
|
||||
import { DebugPerf } from "@/utils/debug/DebugPerf";
|
||||
import { World } from "@/world/World";
|
||||
import { EditorPage } from "@/components/editor/EditorPage";
|
||||
import { EditorPage } from "@/pages/EditorPage";
|
||||
|
||||
function App(): React.JSX.Element {
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { TransformMode } from "./types";
|
||||
import type { TransformMode } from "@/types/editor";
|
||||
|
||||
interface EditorControlsProps {
|
||||
transformMode: TransformMode;
|
||||
|
||||
@@ -1,335 +0,0 @@
|
||||
.editor-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||
font-family: system-ui, sans-serif;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.editor-loading,
|
||||
.editor-error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
color: white;
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.editor-loading h2 {
|
||||
font-size: 2rem;
|
||||
color: #ff6600;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.editor-loading p {
|
||||
font-size: 1rem;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.editor-error h2 {
|
||||
font-size: 1.8rem;
|
||||
color: #ff4444;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.editor-error p {
|
||||
font-size: 1.1rem;
|
||||
color: #ccc;
|
||||
margin-bottom: 2rem;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.upload-section {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
border: 2px dashed rgba(255, 102, 0, 0.3);
|
||||
max-width: 500px;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.upload-section h3 {
|
||||
color: #ff6600;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
.drop-zone {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 2rem 1rem;
|
||||
border: 2px dashed #ff6600;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 102, 0, 0.1);
|
||||
color: #ff6600;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.drop-zone:hover {
|
||||
background: rgba(255, 102, 0, 0.2);
|
||||
border-color: #ff8533;
|
||||
}
|
||||
|
||||
.folder-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.folder-structure {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.folder-structure h4 {
|
||||
color: #aaa;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.folder-structure pre {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
padding: 1rem;
|
||||
border-radius: 6px;
|
||||
color: #ddd;
|
||||
font-family: "Courier New", monospace;
|
||||
font-size: 0.9rem;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
code {
|
||||
background: rgba(255, 102, 0, 0.2);
|
||||
padding: 0.2rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
color: #ff8533;
|
||||
font-family: "Courier New", monospace;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.editor-error h2 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.upload-section {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.drop-zone {
|
||||
padding: 1.5rem 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Editor Controls Styles */
|
||||
.editor-camera-info {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: #00ff00;
|
||||
padding: 10px 15px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #00ff00;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.editor-controls-panel {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
width: 250px;
|
||||
height: 100%;
|
||||
background: rgba(30, 30, 30, 0.95);
|
||||
padding: 20px;
|
||||
color: white;
|
||||
border-left: 2px solid #ff6600;
|
||||
overflow-y: auto;
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.editor-controls-panel h3 {
|
||||
margin-top: 20px;
|
||||
margin-bottom: 15px;
|
||||
font-size: 18px;
|
||||
color: #ff6600;
|
||||
}
|
||||
|
||||
.transform-buttons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.transform-button {
|
||||
padding: 12px;
|
||||
background: #333;
|
||||
color: white;
|
||||
border: 1px solid #555;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.transform-button.active {
|
||||
background: #ff6600;
|
||||
color: black;
|
||||
border-color: #ff6600;
|
||||
}
|
||||
|
||||
.transform-button:hover {
|
||||
background: #444;
|
||||
}
|
||||
|
||||
.transform-button.active:hover {
|
||||
background: #ff8533;
|
||||
}
|
||||
|
||||
.history-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.history-button {
|
||||
flex: 1;
|
||||
padding: 8px;
|
||||
background: #333;
|
||||
color: #aaa;
|
||||
border: 1px solid #555;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.history-button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.export-button {
|
||||
width: 100%;
|
||||
margin-top: 10px;
|
||||
padding: 12px;
|
||||
background: #ff6600;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.export-button:hover {
|
||||
background: #ff8533;
|
||||
}
|
||||
|
||||
.save-button {
|
||||
width: 100%;
|
||||
margin-top: 10px;
|
||||
padding: 12px;
|
||||
background: #22c55e;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.save-button:hover {
|
||||
background: #16a34a;
|
||||
}
|
||||
|
||||
.reset-button {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background: #444;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.reset-button:hover {
|
||||
background: #555;
|
||||
}
|
||||
|
||||
.player-button {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background: #444;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.player-button.active {
|
||||
background: #ff6600;
|
||||
color: black;
|
||||
}
|
||||
|
||||
.player-button:hover {
|
||||
background: #555;
|
||||
}
|
||||
|
||||
.player-button.active:hover {
|
||||
background: #ff8533;
|
||||
}
|
||||
|
||||
.selected-info {
|
||||
background: rgba(255, 102, 0, 0.1);
|
||||
border: 1px solid #ff6600;
|
||||
border-radius: 6px;
|
||||
padding: 15px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.selected-name {
|
||||
font-size: 16px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.selected-index {
|
||||
font-size: 14px;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.no-selection {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px dashed #555;
|
||||
border-radius: 6px;
|
||||
padding: 15px;
|
||||
text-align: center;
|
||||
color: #888;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.controls-help {
|
||||
background: #222;
|
||||
border-radius: 6px;
|
||||
padding: 15px;
|
||||
border: 1px solid #444;
|
||||
}
|
||||
|
||||
.controls-help p {
|
||||
margin: 4px 0;
|
||||
font-size: 12px;
|
||||
color: #aaa;
|
||||
}
|
||||
@@ -3,8 +3,7 @@ import { OrbitControls } from "@react-three/drei";
|
||||
import EditorCamera from "./EditorCamera";
|
||||
import FlyController from "./FlyController";
|
||||
import MapViewer from "./MapViewer";
|
||||
import type { MapNode, TransformMode } from "./types";
|
||||
import type { SceneData } from "./types";
|
||||
import type { MapNode, TransformMode, SceneData } from "@/types/editor";
|
||||
|
||||
interface EditorViewerProps {
|
||||
sceneData: SceneData;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useGLTF } from "@react-three/drei";
|
||||
import { Grid, TransformControls } from "@react-three/drei";
|
||||
import * as THREE from "three";
|
||||
|
||||
import type { SceneData, MapNode, TransformMode } from "./types";
|
||||
import type { SceneData, MapNode, TransformMode } from "@/types/editor";
|
||||
|
||||
interface MapViewerProps {
|
||||
sceneData: SceneData;
|
||||
@@ -171,7 +171,14 @@ function ModelNodeWithRef({
|
||||
return () => {
|
||||
currentMap.delete(currentIndex);
|
||||
};
|
||||
}, [index]);
|
||||
}, [
|
||||
index,
|
||||
node.name,
|
||||
node.position,
|
||||
node.rotation,
|
||||
node.scale,
|
||||
objectsMapRef,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (groupRef.current) {
|
||||
@@ -258,7 +265,14 @@ function FallbackNodeWithRef({
|
||||
return () => {
|
||||
currentMap.delete(currentIndex);
|
||||
};
|
||||
}, [index]);
|
||||
}, [
|
||||
index,
|
||||
node.name,
|
||||
node.position,
|
||||
node.rotation,
|
||||
node.scale,
|
||||
objectsMapRef,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (meshRef.current) {
|
||||
|
||||
@@ -3,14 +3,7 @@ import { useGLTF } from "@react-three/drei";
|
||||
import * as THREE from "three";
|
||||
import { useOctreeGraphNode } from "@/hooks/useOctreeGraphNode";
|
||||
import type { OctreeReadyHandler } from "@/types/3d";
|
||||
|
||||
interface MapNode {
|
||||
name: string;
|
||||
type: string;
|
||||
position: [number, number, number];
|
||||
rotation: [number, number, number];
|
||||
scale: [number, number, number];
|
||||
}
|
||||
import type { MapNode } from "@/types/editor";
|
||||
|
||||
const MAP_JSON_PATH = "/map.json";
|
||||
|
||||
@@ -88,32 +81,23 @@ function ModelInstance({ node }: { node: MapNode }): React.JSX.Element {
|
||||
const modelPath = `/models/${node.name}/model.gltf`;
|
||||
const groupRef = useRef<THREE.Group>(null);
|
||||
const { scene } = useGLTF(modelPath);
|
||||
const { position, rotation, scale } = node;
|
||||
|
||||
useEffect(() => {
|
||||
if (groupRef.current) {
|
||||
groupRef.current.position.set(...node.position);
|
||||
groupRef.current.rotation.set(...node.rotation);
|
||||
groupRef.current.scale.set(...node.scale);
|
||||
groupRef.current.position.set(...position);
|
||||
groupRef.current.rotation.set(...rotation);
|
||||
groupRef.current.scale.set(...scale);
|
||||
}
|
||||
}, [
|
||||
node.position[0],
|
||||
node.position[1],
|
||||
node.position[2],
|
||||
node.rotation[0],
|
||||
node.rotation[1],
|
||||
node.rotation[2],
|
||||
node.scale[0],
|
||||
node.scale[1],
|
||||
node.scale[2],
|
||||
]);
|
||||
}, [position, rotation, scale]);
|
||||
|
||||
return (
|
||||
<primitive
|
||||
ref={groupRef}
|
||||
object={scene}
|
||||
position={node.position}
|
||||
rotation={node.rotation}
|
||||
scale={node.scale}
|
||||
position={position}
|
||||
rotation={rotation}
|
||||
scale={scale}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+336
@@ -79,3 +79,339 @@ canvas {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
/* Editor page */
|
||||
.editor-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||
font-family: system-ui, sans-serif;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.editor-loading,
|
||||
.editor-error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
color: white;
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.editor-loading h2 {
|
||||
font-size: 2rem;
|
||||
color: #ff6600;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.editor-loading p {
|
||||
font-size: 1rem;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.editor-error h2 {
|
||||
font-size: 1.8rem;
|
||||
color: #ff4444;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.editor-error p {
|
||||
font-size: 1.1rem;
|
||||
color: #ccc;
|
||||
margin-bottom: 2rem;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.editor-container code {
|
||||
background: rgba(255, 102, 0, 0.2);
|
||||
padding: 0.2rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
color: #ff8533;
|
||||
font-family: "Courier New", monospace;
|
||||
}
|
||||
|
||||
.upload-section {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
border: 2px dashed rgba(255, 102, 0, 0.3);
|
||||
max-width: 500px;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.upload-section h3 {
|
||||
color: #ff6600;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
.drop-zone {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 2rem 1rem;
|
||||
border: 2px dashed #ff6600;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 102, 0, 0.1);
|
||||
color: #ff6600;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.drop-zone:hover {
|
||||
background: rgba(255, 102, 0, 0.2);
|
||||
border-color: #ff8533;
|
||||
}
|
||||
|
||||
.folder-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.folder-structure {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.folder-structure h4 {
|
||||
color: #aaa;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.folder-structure pre {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
padding: 1rem;
|
||||
border-radius: 6px;
|
||||
color: #ddd;
|
||||
font-family: "Courier New", monospace;
|
||||
font-size: 0.9rem;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.editor-camera-info {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: #00ff00;
|
||||
padding: 10px 15px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #00ff00;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.editor-controls-panel {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
width: 250px;
|
||||
height: 100%;
|
||||
background: rgba(30, 30, 30, 0.95);
|
||||
padding: 20px;
|
||||
color: white;
|
||||
border-left: 2px solid #ff6600;
|
||||
overflow-y: auto;
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.editor-controls-panel h3 {
|
||||
margin-top: 20px;
|
||||
margin-bottom: 15px;
|
||||
font-size: 18px;
|
||||
color: #ff6600;
|
||||
}
|
||||
|
||||
.transform-buttons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.transform-button {
|
||||
padding: 12px;
|
||||
background: #333;
|
||||
color: white;
|
||||
border: 1px solid #555;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.transform-button.active {
|
||||
background: #ff6600;
|
||||
color: black;
|
||||
border-color: #ff6600;
|
||||
}
|
||||
|
||||
.transform-button:hover {
|
||||
background: #444;
|
||||
}
|
||||
|
||||
.transform-button.active:hover {
|
||||
background: #ff8533;
|
||||
}
|
||||
|
||||
.history-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.history-button {
|
||||
flex: 1;
|
||||
padding: 8px;
|
||||
background: #333;
|
||||
color: #aaa;
|
||||
border: 1px solid #555;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.history-button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.export-button {
|
||||
width: 100%;
|
||||
margin-top: 10px;
|
||||
padding: 12px;
|
||||
background: #ff6600;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.export-button:hover {
|
||||
background: #ff8533;
|
||||
}
|
||||
|
||||
.save-button {
|
||||
width: 100%;
|
||||
margin-top: 10px;
|
||||
padding: 12px;
|
||||
background: #22c55e;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.save-button:hover {
|
||||
background: #16a34a;
|
||||
}
|
||||
|
||||
.reset-button {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background: #444;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.reset-button:hover {
|
||||
background: #555;
|
||||
}
|
||||
|
||||
.player-button {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background: #444;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.player-button.active {
|
||||
background: #ff6600;
|
||||
color: black;
|
||||
}
|
||||
|
||||
.player-button:hover {
|
||||
background: #555;
|
||||
}
|
||||
|
||||
.player-button.active:hover {
|
||||
background: #ff8533;
|
||||
}
|
||||
|
||||
.selected-info {
|
||||
background: rgba(255, 102, 0, 0.1);
|
||||
border: 1px solid #ff6600;
|
||||
border-radius: 6px;
|
||||
padding: 15px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.selected-name {
|
||||
font-size: 16px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.selected-index {
|
||||
font-size: 14px;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.no-selection {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px dashed #555;
|
||||
border-radius: 6px;
|
||||
padding: 15px;
|
||||
text-align: center;
|
||||
color: #888;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.controls-help {
|
||||
background: #222;
|
||||
border-radius: 6px;
|
||||
padding: 15px;
|
||||
border: 1px solid #444;
|
||||
}
|
||||
|
||||
.controls-help p {
|
||||
margin: 4px 0;
|
||||
font-size: 12px;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.editor-error h2 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.upload-section {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.drop-zone {
|
||||
padding: 1.5rem 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import { Canvas } from "@react-three/fiber";
|
||||
import EditorViewer from "./EditorViewer";
|
||||
import EditorControls from "./EditorControls";
|
||||
import type { TransformMode, MapNode } from "./types";
|
||||
import type { SceneData } from "./types";
|
||||
import "./EditorPage.css";
|
||||
|
||||
interface ObjectTransform {
|
||||
uuid: string;
|
||||
position: { x: number; y: number; z: number };
|
||||
rotation: { x: number; y: number; z: number };
|
||||
scale: { x: number; y: number; z: number };
|
||||
}
|
||||
import EditorViewer from "@/components/editor/EditorViewer";
|
||||
import EditorControls from "@/components/editor/EditorControls";
|
||||
import type {
|
||||
TransformMode,
|
||||
MapNode,
|
||||
SceneData,
|
||||
ObjectTransform,
|
||||
} from "@/types/editor";
|
||||
|
||||
class HistoryManager {
|
||||
private history: ObjectTransform[][] = [];
|
||||
@@ -60,19 +56,6 @@ class HistoryManager {
|
||||
getRedoCount(): number {
|
||||
return this.history.length - 1 - this.currentIndex;
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.history = [];
|
||||
this.currentIndex = -1;
|
||||
}
|
||||
|
||||
canUndo(): boolean {
|
||||
return this.currentIndex > 0;
|
||||
}
|
||||
|
||||
canRedo(): boolean {
|
||||
return this.currentIndex < this.history.length - 1;
|
||||
}
|
||||
}
|
||||
|
||||
export function EditorPage(): React.JSX.Element {
|
||||
@@ -80,7 +63,6 @@ export function EditorPage(): React.JSX.Element {
|
||||
const [isMapLoading, setIsMapLoading] = useState<boolean>(true);
|
||||
const [sceneData, setSceneData] = useState<SceneData | null>(null);
|
||||
|
||||
// État partagé entre Canvas (3D) et EditorControls (HTML)
|
||||
const [selectedNodeIndex, setSelectedNodeIndex] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
@@ -91,10 +73,8 @@ export function EditorPage(): React.JSX.Element {
|
||||
const [redoCount, setRedoCount] = useState(0);
|
||||
const [isPlayerMode, setIsPlayerMode] = useState(false);
|
||||
|
||||
const historyManagerRef = useCallback(() => new HistoryManager(50), []);
|
||||
const historyManager = useRef<HistoryManager>(historyManagerRef());
|
||||
const historyManager = useRef<HistoryManager>(new HistoryManager(50));
|
||||
|
||||
// Callbacks partagés
|
||||
const handleSelectNode = useCallback((index: number | null) => {
|
||||
setSelectedNodeIndex(index);
|
||||
}, []);
|
||||
@@ -195,7 +175,6 @@ export function EditorPage(): React.JSX.Element {
|
||||
}, [sceneData]);
|
||||
|
||||
const handleResetCamera = useCallback(() => {
|
||||
// Logique pour reset camera
|
||||
console.log("Reset camera");
|
||||
}, []);
|
||||
|
||||
@@ -203,9 +182,10 @@ export function EditorPage(): React.JSX.Element {
|
||||
setIsPlayerMode((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const handleTransformStart = useCallback(() => {
|
||||
if (!sceneData) return;
|
||||
const snapshot = sceneData.mapNodes.map((node, index) => ({
|
||||
const createSnapshot = useCallback((): ObjectTransform[] => {
|
||||
if (!sceneData) return [];
|
||||
|
||||
return sceneData.mapNodes.map((node, index) => ({
|
||||
uuid: `node-${index}`,
|
||||
position: {
|
||||
x: node.position[0],
|
||||
@@ -219,29 +199,19 @@ export function EditorPage(): React.JSX.Element {
|
||||
},
|
||||
scale: { x: node.scale[0], y: node.scale[1], z: node.scale[2] },
|
||||
}));
|
||||
historyManager.current.saveSnapshot(snapshot);
|
||||
}, [sceneData]);
|
||||
|
||||
const handleTransformStart = useCallback(() => {
|
||||
if (!sceneData) return;
|
||||
historyManager.current.saveSnapshot(createSnapshot());
|
||||
}, [createSnapshot, sceneData]);
|
||||
|
||||
const handleTransformEnd = useCallback(() => {
|
||||
if (!sceneData) return;
|
||||
const snapshot = sceneData.mapNodes.map((node, index) => ({
|
||||
uuid: `node-${index}`,
|
||||
position: {
|
||||
x: node.position[0],
|
||||
y: node.position[1],
|
||||
z: node.position[2],
|
||||
},
|
||||
rotation: {
|
||||
x: node.rotation[0],
|
||||
y: node.rotation[1],
|
||||
z: node.rotation[2],
|
||||
},
|
||||
scale: { x: node.scale[0], y: node.scale[1], z: node.scale[2] },
|
||||
}));
|
||||
historyManager.current.saveSnapshot(snapshot);
|
||||
historyManager.current.saveSnapshot(createSnapshot());
|
||||
setUndoCount(historyManager.current.getUndoCount());
|
||||
setRedoCount(historyManager.current.getRedoCount());
|
||||
}, [sceneData]);
|
||||
}, [createSnapshot, sceneData]);
|
||||
|
||||
const handleNodeTransform = useCallback(
|
||||
(nodeIndex: number, updatedNode: MapNode) => {
|
||||
@@ -445,7 +415,6 @@ export function EditorPage(): React.JSX.Element {
|
||||
/>
|
||||
</Canvas>
|
||||
|
||||
{/* EditorControls rendu en dehors du Canvas (HTML overlay) */}
|
||||
{sceneData && (
|
||||
<EditorControls
|
||||
transformMode={transformMode}
|
||||
+37
-60
@@ -3,74 +3,51 @@ import react from "@vitejs/plugin-react";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { ViteDevServer } from "vite";
|
||||
import type { IncomingMessage, ServerResponse } from "http";
|
||||
import type { Plugin } from "vite";
|
||||
|
||||
const __dirname = fileURLToPath(new URL(".", import.meta.url));
|
||||
|
||||
const MAX_MAP_PAYLOAD_BYTES = 1024 * 1024; // 1MB limit
|
||||
const MAX_MAP_PAYLOAD_BYTES = 1024 * 1024;
|
||||
|
||||
const saveMapPlugin = () => ({
|
||||
const saveMapPlugin = (): Plugin => ({
|
||||
name: "save-map-api",
|
||||
configureServer(server: ViteDevServer) {
|
||||
server.middlewares.use(
|
||||
"/api/save-map",
|
||||
async (req: IncomingMessage, res: ServerResponse) => {
|
||||
if (req.method !== "POST") {
|
||||
res.writeHead(405, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "Method not allowed" }));
|
||||
configureServer(server) {
|
||||
server.middlewares.use("/api/save-map", async (req, res) => {
|
||||
if (req.method !== "POST") {
|
||||
res.writeHead(405).end(JSON.stringify({ error: "Method not allowed" }));
|
||||
return;
|
||||
}
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
let size = 0;
|
||||
|
||||
for await (const chunk of req) {
|
||||
size += chunk.length;
|
||||
if (size > MAX_MAP_PAYLOAD_BYTES) {
|
||||
res
|
||||
.writeHead(413)
|
||||
.end(JSON.stringify({ error: "Payload too large" }));
|
||||
req.destroy();
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
let body = "";
|
||||
let bodySize = 0;
|
||||
let requestAborted = false;
|
||||
|
||||
req.on("data", (chunk: Buffer) => {
|
||||
if (requestAborted) return;
|
||||
bodySize += chunk.length;
|
||||
if (bodySize > MAX_MAP_PAYLOAD_BYTES) {
|
||||
requestAborted = true;
|
||||
res.writeHead(413, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "Payload too large" }));
|
||||
req.destroy();
|
||||
return;
|
||||
}
|
||||
body += chunk.toString();
|
||||
});
|
||||
|
||||
req.on("error", (err: Error) => {
|
||||
if (!res.headersSent) {
|
||||
res.writeHead(400, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: err.message }));
|
||||
}
|
||||
});
|
||||
|
||||
req.on("end", async () => {
|
||||
if (requestAborted) return;
|
||||
|
||||
try {
|
||||
const parsedBody = JSON.parse(body);
|
||||
const mapPath = path.resolve(__dirname, "public/map.json");
|
||||
await fs.promises.writeFile(
|
||||
mapPath,
|
||||
JSON.stringify(parsedBody, null, 2),
|
||||
"utf8",
|
||||
);
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ success: true }));
|
||||
} catch (err) {
|
||||
const statusCode = err instanceof SyntaxError ? 400 : 500;
|
||||
res.writeHead(statusCode, { "Content-Type": "application/json" });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error: err instanceof Error ? err.message : "Unknown error",
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
try {
|
||||
const data = JSON.parse(Buffer.concat(chunks).toString());
|
||||
const mapPath = path.resolve(__dirname, "public/map.json");
|
||||
await fs.promises.writeFile(
|
||||
mapPath,
|
||||
JSON.stringify(data, null, 2),
|
||||
"utf8",
|
||||
);
|
||||
res.writeHead(200).end(JSON.stringify({ success: true }));
|
||||
} catch (err) {
|
||||
const status = err instanceof SyntaxError ? 400 : 500;
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
res.writeHead(status).end(JSON.stringify({ error: message }));
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user