refactor: move editor page and types to conventional folders

This commit is contained in:
2026-04-28 09:29:18 +02:00
parent bfe8c49323
commit 7b38f04a0d
12 changed files with 426 additions and 606 deletions
+3
View File
@@ -38,3 +38,6 @@ Thumbs.db
# 3D Assets Cache (drei, GLTFJSX) # 3D Assets Cache (drei, GLTFJSX)
.drei/ .drei/
.glitchdrei-cache/ .glitchdrei-cache/
.backend/
backend/
-127
View File
@@ -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
View File
@@ -5,7 +5,7 @@ import { Crosshair } from "@/components/ui/Crosshair";
import { InteractPrompt } from "@/components/ui/InteractPrompt"; import { InteractPrompt } from "@/components/ui/InteractPrompt";
import { DebugPerf } from "@/utils/debug/DebugPerf"; import { DebugPerf } from "@/utils/debug/DebugPerf";
import { World } from "@/world/World"; import { World } from "@/world/World";
import { EditorPage } from "@/components/editor/EditorPage"; import { EditorPage } from "@/pages/EditorPage";
function App(): React.JSX.Element { function App(): React.JSX.Element {
return ( return (
+1 -1
View File
@@ -1,4 +1,4 @@
import type { TransformMode } from "./types"; import type { TransformMode } from "@/types/editor";
interface EditorControlsProps { interface EditorControlsProps {
transformMode: TransformMode; transformMode: TransformMode;
-335
View File
@@ -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;
}
+1 -2
View File
@@ -3,8 +3,7 @@ import { OrbitControls } from "@react-three/drei";
import EditorCamera from "./EditorCamera"; import EditorCamera from "./EditorCamera";
import FlyController from "./FlyController"; import FlyController from "./FlyController";
import MapViewer from "./MapViewer"; import MapViewer from "./MapViewer";
import type { MapNode, TransformMode } from "./types"; import type { MapNode, TransformMode, SceneData } from "@/types/editor";
import type { SceneData } from "./types";
interface EditorViewerProps { interface EditorViewerProps {
sceneData: SceneData; sceneData: SceneData;
+17 -3
View File
@@ -3,7 +3,7 @@ import { useGLTF } from "@react-three/drei";
import { Grid, TransformControls } from "@react-three/drei"; import { Grid, TransformControls } from "@react-three/drei";
import * as THREE from "three"; import * as THREE from "three";
import type { SceneData, MapNode, TransformMode } from "./types"; import type { SceneData, MapNode, TransformMode } from "@/types/editor";
interface MapViewerProps { interface MapViewerProps {
sceneData: SceneData; sceneData: SceneData;
@@ -171,7 +171,14 @@ function ModelNodeWithRef({
return () => { return () => {
currentMap.delete(currentIndex); currentMap.delete(currentIndex);
}; };
}, [index]); }, [
index,
node.name,
node.position,
node.rotation,
node.scale,
objectsMapRef,
]);
useEffect(() => { useEffect(() => {
if (groupRef.current) { if (groupRef.current) {
@@ -258,7 +265,14 @@ function FallbackNodeWithRef({
return () => { return () => {
currentMap.delete(currentIndex); currentMap.delete(currentIndex);
}; };
}, [index]); }, [
index,
node.name,
node.position,
node.rotation,
node.scale,
objectsMapRef,
]);
useEffect(() => { useEffect(() => {
if (meshRef.current) { if (meshRef.current) {
+9 -25
View File
@@ -3,14 +3,7 @@ import { useGLTF } from "@react-three/drei";
import * as THREE from "three"; import * as THREE from "three";
import { useOctreeGraphNode } from "@/hooks/useOctreeGraphNode"; import { useOctreeGraphNode } from "@/hooks/useOctreeGraphNode";
import type { OctreeReadyHandler } from "@/types/3d"; import type { OctreeReadyHandler } from "@/types/3d";
import type { MapNode } from "@/types/editor";
interface MapNode {
name: string;
type: string;
position: [number, number, number];
rotation: [number, number, number];
scale: [number, number, number];
}
const MAP_JSON_PATH = "/map.json"; 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 modelPath = `/models/${node.name}/model.gltf`;
const groupRef = useRef<THREE.Group>(null); const groupRef = useRef<THREE.Group>(null);
const { scene } = useGLTF(modelPath); const { scene } = useGLTF(modelPath);
const { position, rotation, scale } = node;
useEffect(() => { useEffect(() => {
if (groupRef.current) { if (groupRef.current) {
groupRef.current.position.set(...node.position); groupRef.current.position.set(...position);
groupRef.current.rotation.set(...node.rotation); groupRef.current.rotation.set(...rotation);
groupRef.current.scale.set(...node.scale); groupRef.current.scale.set(...scale);
} }
}, [ }, [position, rotation, 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],
]);
return ( return (
<primitive <primitive
ref={groupRef} ref={groupRef}
object={scene} object={scene}
position={node.position} position={position}
rotation={node.rotation} rotation={rotation}
scale={node.scale} scale={scale}
/> />
); );
} }
+336
View File
@@ -79,3 +79,339 @@ canvas {
color: rgba(255, 255, 255, 0.85); color: rgba(255, 255, 255, 0.85);
letter-spacing: 0.03em; 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 { useEffect, useState, useCallback, useRef } from "react";
import { Canvas } from "@react-three/fiber"; import { Canvas } from "@react-three/fiber";
import EditorViewer from "./EditorViewer"; import EditorViewer from "@/components/editor/EditorViewer";
import EditorControls from "./EditorControls"; import EditorControls from "@/components/editor/EditorControls";
import type { TransformMode, MapNode } from "./types"; import type {
import type { SceneData } from "./types"; TransformMode,
import "./EditorPage.css"; MapNode,
SceneData,
interface ObjectTransform { ObjectTransform,
uuid: string; } from "@/types/editor";
position: { x: number; y: number; z: number };
rotation: { x: number; y: number; z: number };
scale: { x: number; y: number; z: number };
}
class HistoryManager { class HistoryManager {
private history: ObjectTransform[][] = []; private history: ObjectTransform[][] = [];
@@ -60,19 +56,6 @@ class HistoryManager {
getRedoCount(): number { getRedoCount(): number {
return this.history.length - 1 - this.currentIndex; 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 { export function EditorPage(): React.JSX.Element {
@@ -80,7 +63,6 @@ export function EditorPage(): React.JSX.Element {
const [isMapLoading, setIsMapLoading] = useState<boolean>(true); const [isMapLoading, setIsMapLoading] = useState<boolean>(true);
const [sceneData, setSceneData] = useState<SceneData | null>(null); const [sceneData, setSceneData] = useState<SceneData | null>(null);
// État partagé entre Canvas (3D) et EditorControls (HTML)
const [selectedNodeIndex, setSelectedNodeIndex] = useState<number | null>( const [selectedNodeIndex, setSelectedNodeIndex] = useState<number | null>(
null, null,
); );
@@ -91,10 +73,8 @@ export function EditorPage(): React.JSX.Element {
const [redoCount, setRedoCount] = useState(0); const [redoCount, setRedoCount] = useState(0);
const [isPlayerMode, setIsPlayerMode] = useState(false); const [isPlayerMode, setIsPlayerMode] = useState(false);
const historyManagerRef = useCallback(() => new HistoryManager(50), []); const historyManager = useRef<HistoryManager>(new HistoryManager(50));
const historyManager = useRef<HistoryManager>(historyManagerRef());
// Callbacks partagés
const handleSelectNode = useCallback((index: number | null) => { const handleSelectNode = useCallback((index: number | null) => {
setSelectedNodeIndex(index); setSelectedNodeIndex(index);
}, []); }, []);
@@ -195,7 +175,6 @@ export function EditorPage(): React.JSX.Element {
}, [sceneData]); }, [sceneData]);
const handleResetCamera = useCallback(() => { const handleResetCamera = useCallback(() => {
// Logique pour reset camera
console.log("Reset camera"); console.log("Reset camera");
}, []); }, []);
@@ -203,9 +182,10 @@ export function EditorPage(): React.JSX.Element {
setIsPlayerMode((prev) => !prev); setIsPlayerMode((prev) => !prev);
}, []); }, []);
const handleTransformStart = useCallback(() => { const createSnapshot = useCallback((): ObjectTransform[] => {
if (!sceneData) return; if (!sceneData) return [];
const snapshot = sceneData.mapNodes.map((node, index) => ({
return sceneData.mapNodes.map((node, index) => ({
uuid: `node-${index}`, uuid: `node-${index}`,
position: { position: {
x: node.position[0], 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] }, scale: { x: node.scale[0], y: node.scale[1], z: node.scale[2] },
})); }));
historyManager.current.saveSnapshot(snapshot);
}, [sceneData]); }, [sceneData]);
const handleTransformStart = useCallback(() => {
if (!sceneData) return;
historyManager.current.saveSnapshot(createSnapshot());
}, [createSnapshot, sceneData]);
const handleTransformEnd = useCallback(() => { const handleTransformEnd = useCallback(() => {
if (!sceneData) return; if (!sceneData) return;
const snapshot = sceneData.mapNodes.map((node, index) => ({ historyManager.current.saveSnapshot(createSnapshot());
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);
setUndoCount(historyManager.current.getUndoCount()); setUndoCount(historyManager.current.getUndoCount());
setRedoCount(historyManager.current.getRedoCount()); setRedoCount(historyManager.current.getRedoCount());
}, [sceneData]); }, [createSnapshot, sceneData]);
const handleNodeTransform = useCallback( const handleNodeTransform = useCallback(
(nodeIndex: number, updatedNode: MapNode) => { (nodeIndex: number, updatedNode: MapNode) => {
@@ -445,7 +415,6 @@ export function EditorPage(): React.JSX.Element {
/> />
</Canvas> </Canvas>
{/* EditorControls rendu en dehors du Canvas (HTML overlay) */}
{sceneData && ( {sceneData && (
<EditorControls <EditorControls
transformMode={transformMode} transformMode={transformMode}
+21 -44
View File
@@ -3,75 +3,52 @@ import react from "@vitejs/plugin-react";
import path from "node:path"; import path from "node:path";
import fs from "node:fs"; import fs from "node:fs";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import type { ViteDevServer } from "vite"; import type { Plugin } from "vite";
import type { IncomingMessage, ServerResponse } from "http";
const __dirname = fileURLToPath(new URL(".", import.meta.url)); 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", name: "save-map-api",
configureServer(server: ViteDevServer) { configureServer(server) {
server.middlewares.use( server.middlewares.use("/api/save-map", async (req, res) => {
"/api/save-map",
async (req: IncomingMessage, res: ServerResponse) => {
if (req.method !== "POST") { if (req.method !== "POST") {
res.writeHead(405, { "Content-Type": "application/json" }); res.writeHead(405).end(JSON.stringify({ error: "Method not allowed" }));
res.end(JSON.stringify({ error: "Method not allowed" }));
return; return;
} }
let body = ""; const chunks: Buffer[] = [];
let bodySize = 0; let size = 0;
let requestAborted = false;
req.on("data", (chunk: Buffer) => { for await (const chunk of req) {
if (requestAborted) return; size += chunk.length;
bodySize += chunk.length; if (size > MAX_MAP_PAYLOAD_BYTES) {
if (bodySize > MAX_MAP_PAYLOAD_BYTES) { res
requestAborted = true; .writeHead(413)
res.writeHead(413, { "Content-Type": "application/json" }); .end(JSON.stringify({ error: "Payload too large" }));
res.end(JSON.stringify({ error: "Payload too large" }));
req.destroy(); req.destroy();
return; return;
} }
body += chunk.toString(); chunks.push(chunk);
});
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 { try {
const parsedBody = JSON.parse(body); const data = JSON.parse(Buffer.concat(chunks).toString());
const mapPath = path.resolve(__dirname, "public/map.json"); const mapPath = path.resolve(__dirname, "public/map.json");
await fs.promises.writeFile( await fs.promises.writeFile(
mapPath, mapPath,
JSON.stringify(parsedBody, null, 2), JSON.stringify(data, null, 2),
"utf8", "utf8",
); );
res.writeHead(200, { "Content-Type": "application/json" }); res.writeHead(200).end(JSON.stringify({ success: true }));
res.end(JSON.stringify({ success: true }));
} catch (err) { } catch (err) {
const statusCode = err instanceof SyntaxError ? 400 : 500; const status = err instanceof SyntaxError ? 400 : 500;
res.writeHead(statusCode, { "Content-Type": "application/json" }); const message = err instanceof Error ? err.message : "Unknown error";
res.end( res.writeHead(status).end(JSON.stringify({ error: message }));
JSON.stringify({
error: err instanceof Error ? err.message : "Unknown error",
}),
);
} }
}); });
}, },
);
},
}); });
export default defineConfig({ export default defineConfig({