docs: document editor architecture and user features

This commit is contained in:
2026-04-28 09:43:51 +02:00
parent 7b38f04a0d
commit 8f40bb8133
21 changed files with 776 additions and 911 deletions
-442
View File
@@ -1,442 +0,0 @@
import { useEffect, useState, useCallback, useRef } from "react";
import { Canvas } from "@react-three/fiber";
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[][] = [];
private currentIndex = -1;
private maxSize: number;
constructor(maxSize = 50) {
this.maxSize = maxSize;
}
saveSnapshot(objects: ObjectTransform[]) {
if (this.currentIndex < this.history.length - 1) {
this.history = this.history.slice(0, this.currentIndex + 1);
}
this.history.push(objects.map((obj) => ({ ...obj })));
this.currentIndex = this.history.length - 1;
if (this.history.length > this.maxSize) {
this.history.shift();
this.currentIndex--;
}
}
undo(): ObjectTransform[] | undefined {
if (this.currentIndex > 0) {
this.currentIndex--;
return this.history[this.currentIndex];
}
return undefined;
}
redo(): ObjectTransform[] | undefined {
if (this.currentIndex < this.history.length - 1) {
this.currentIndex++;
return this.history[this.currentIndex];
}
return undefined;
}
getUndoCount(): number {
return this.currentIndex;
}
getRedoCount(): number {
return this.history.length - 1 - this.currentIndex;
}
}
export function EditorPage(): React.JSX.Element {
const [hasMapJson, setHasMapJson] = useState<boolean>(false);
const [isMapLoading, setIsMapLoading] = useState<boolean>(true);
const [sceneData, setSceneData] = useState<SceneData | null>(null);
const [selectedNodeIndex, setSelectedNodeIndex] = useState<number | null>(
null,
);
const [hoveredNodeIndex, setHoveredNodeIndex] = useState<number | null>(null);
const [transformMode, setTransformMode] =
useState<TransformMode>("translate");
const [undoCount, setUndoCount] = useState(0);
const [redoCount, setRedoCount] = useState(0);
const [isPlayerMode, setIsPlayerMode] = useState(false);
const historyManager = useRef<HistoryManager>(new HistoryManager(50));
const handleSelectNode = useCallback((index: number | null) => {
setSelectedNodeIndex(index);
}, []);
const handleHoverNode = useCallback((index: number | null) => {
setHoveredNodeIndex(index);
}, []);
const handleTransformModeChange = useCallback((mode: TransformMode) => {
setTransformMode(mode);
}, []);
const applySnapshot = useCallback(
(snapshot: ObjectTransform[]) => {
if (!sceneData) return;
setSceneData((prev) => {
if (!prev) return null;
const newMapNodes = prev.mapNodes.map((node, index) => {
const transform = snapshot.find((s) => s.uuid === `node-${index}`);
if (transform) {
return {
...node,
position: [
transform.position.x,
transform.position.y,
transform.position.z,
] as [number, number, number],
rotation: [
transform.rotation.x,
transform.rotation.y,
transform.rotation.z,
] as [number, number, number],
scale: [
transform.scale.x,
transform.scale.y,
transform.scale.z,
] as [number, number, number],
};
}
return node;
});
return { ...prev, mapNodes: newMapNodes };
});
},
[sceneData],
);
const handleUndo = useCallback(() => {
const snapshot = historyManager.current.undo();
if (snapshot) {
applySnapshot(snapshot);
setUndoCount(historyManager.current.getUndoCount());
setRedoCount(historyManager.current.getRedoCount());
}
}, [applySnapshot]);
const handleRedo = useCallback(() => {
const snapshot = historyManager.current.redo();
if (snapshot) {
applySnapshot(snapshot);
setUndoCount(historyManager.current.getUndoCount());
setRedoCount(historyManager.current.getRedoCount());
}
}, [applySnapshot]);
const handleSaveToServer = useCallback(async () => {
if (!sceneData) return;
const json = JSON.stringify(sceneData.mapNodes, null, 2);
try {
const response = await fetch("/api/save-map", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: json,
});
if (response.ok) {
alert("Map enregistrée avec succès!");
} else {
alert("Erreur lors de l'enregistrement");
}
} catch (err) {
console.error("Error saving map:", err);
alert("Erreur lors de l'enregistrement");
}
}, [sceneData]);
const handleExportJson = useCallback(() => {
if (!sceneData) return;
const json = JSON.stringify(sceneData.mapNodes, null, 2);
const blob = new Blob([json], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "map.json";
a.click();
URL.revokeObjectURL(url);
}, [sceneData]);
const handleResetCamera = useCallback(() => {
console.log("Reset camera");
}, []);
const handlePlayerMode = useCallback(() => {
setIsPlayerMode((prev) => !prev);
}, []);
const createSnapshot = useCallback((): ObjectTransform[] => {
if (!sceneData) return [];
return 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] },
}));
}, [sceneData]);
const handleTransformStart = useCallback(() => {
if (!sceneData) return;
historyManager.current.saveSnapshot(createSnapshot());
}, [createSnapshot, sceneData]);
const handleTransformEnd = useCallback(() => {
if (!sceneData) return;
historyManager.current.saveSnapshot(createSnapshot());
setUndoCount(historyManager.current.getUndoCount());
setRedoCount(historyManager.current.getRedoCount());
}, [createSnapshot, sceneData]);
const handleNodeTransform = useCallback(
(nodeIndex: number, updatedNode: MapNode) => {
if (!sceneData) return;
setSceneData((prev) => {
if (!prev) return null;
const newMapNodes = [...prev.mapNodes];
newMapNodes[nodeIndex] = updatedNode;
return { ...prev, mapNodes: newMapNodes };
});
},
[sceneData],
);
useEffect(() => {
const loadMapData = async (): Promise<void> => {
setIsMapLoading(true);
try {
const response = await fetch("/map.json");
if (!response.ok) {
setHasMapJson(false);
setIsMapLoading(false);
return;
}
const mapNodes: MapNode[] = await response.json();
const models = new Map<string, string>();
const uniqueModelNames = [
...new Set(mapNodes.map((n: MapNode) => n.name)),
];
console.log("Unique model names in map:", uniqueModelNames);
for (const modelName of uniqueModelNames) {
try {
const modelUrl = `/models/${modelName}/model.gltf`;
const modelResponse = await fetch(modelUrl);
if (modelResponse.ok) {
const contentType =
modelResponse.headers.get("content-type") || "";
if (
contentType.includes("gltf") ||
contentType.includes("json") ||
contentType.includes("model")
) {
const text = await modelResponse.text();
if (
text.includes('"glTF"') ||
text.includes('"scene"') ||
text.includes('"nodes"')
) {
models.set(modelName, modelUrl);
} else {
console.warn(
`Invalid GLTF content for ${modelName}:`,
text.substring(0, 100),
);
}
} else {
console.warn(
`Invalid Content-Type for ${modelName}:`,
contentType,
);
}
}
} catch {
/* empty */
}
}
console.log("Loaded models:", Array.from(models.keys()));
setSceneData({ mapNodes, models });
setHasMapJson(true);
} catch (error) {
console.error("Error loading map data:", error);
setHasMapJson(false);
} finally {
setIsMapLoading(false);
}
};
loadMapData();
}, []);
const handleFolderUpload = async (
event: React.ChangeEvent<HTMLInputElement>,
): Promise<void> => {
const files = event.target.files;
if (!files) return;
const fileMap = new Map<string, File>();
for (const file of Array.from(files)) {
const webkitRelativePath =
(file as File & { webkitRelativePath?: string }).webkitRelativePath ||
"/" + file.name;
fileMap.set(webkitRelativePath, file);
}
const mapFile = fileMap.get("/map.json");
if (!mapFile) {
alert("Fichier map.json manquant à la racine du dossier");
return;
}
try {
const mapText = await mapFile.text();
const mapNodes = JSON.parse(mapText);
const models = new Map<string, string>();
for (const [path, file] of fileMap.entries()) {
const modelMatch = path && path.match(/^\/models\/(.+)\/model\.gltf$/);
if (modelMatch && modelMatch[1]) {
const modelName = modelMatch[1];
const blobUrl = URL.createObjectURL(file);
models.set(modelName, blobUrl);
}
}
setSceneData({ mapNodes, models });
setHasMapJson(true);
} catch (error) {
console.error("Error processing upload:", error);
alert("Erreur lors du traitement du dossier");
}
};
if (isMapLoading) {
return (
<div className="editor-container">
<div className="editor-loading">
<h2>Chargement de l'éditeur...</h2>
<p>Vérification de map.json dans public/</p>
</div>
</div>
);
}
if (!hasMapJson) {
return (
<div className="editor-container">
<div className="editor-error">
<h2>Erreur : map.json introuvable</h2>
<p>
Le fichier map.json est requis dans le dossier <code>public/</code>.
</p>
<div className="upload-section">
<h3>Télécharger un dossier contenant map.json</h3>
<label className="drop-zone">
<input
type="file"
className="folder-input"
onChange={handleFolderUpload}
/>
Choisir un dossier contenant map.json
</label>
<div className="folder-structure">
<h4>Structure requise :</h4>
<pre>
public/ <strong>map.json</strong> (à la racine) models/
arbre/ model.glb building/ model.glb ...
</pre>
</div>
</div>
</div>
</div>
);
}
return (
<div className="editor-container">
<Canvas
camera={{ position: [0, 50, 100], fov: 50 }}
style={{ width: "100%", height: "100%" }}
onCreated={({ gl }) => {
gl.setClearColor("#1e293b");
}}
>
<EditorViewer
sceneData={sceneData!}
selectedNodeIndex={selectedNodeIndex}
onSelectNode={handleSelectNode}
hoveredNodeIndex={hoveredNodeIndex}
onHoverNode={handleHoverNode}
transformMode={transformMode}
onTransformModeChange={handleTransformModeChange}
onTransformStart={handleTransformStart}
onTransformEnd={handleTransformEnd}
onNodeTransform={handleNodeTransform}
onUndo={handleUndo}
onRedo={handleRedo}
isPlayerMode={isPlayerMode}
/>
</Canvas>
{sceneData && (
<EditorControls
transformMode={transformMode}
onTransformModeChange={handleTransformModeChange}
selectedNodeIndex={selectedNodeIndex}
nodesCount={sceneData.mapNodes.length}
selectedNodeName={
selectedNodeIndex !== null && sceneData.mapNodes[selectedNodeIndex]
? sceneData.mapNodes[selectedNodeIndex].name || null
: null
}
undoCount={undoCount}
redoCount={redoCount}
onUndo={handleUndo}
onRedo={handleRedo}
onExportJson={handleExportJson}
onSaveToServer={handleSaveToServer}
onResetCamera={handleResetCamera}
onPlayerMode={handlePlayerMode}
isPlayerMode={isPlayerMode}
/>
)}
</div>
);
}
+194
View File
@@ -0,0 +1,194 @@
import { useCallback, useState } from "react";
import { Canvas } from "@react-three/fiber";
import { EditorControls } from "@/features/editor/components/EditorControls";
import { useEditorHistory } from "@/features/editor/hooks/useEditorHistory";
import { useEditorSceneData } from "@/features/editor/hooks/useEditorSceneData";
import { EditorScene } from "@/features/editor/scene/EditorScene";
import type { MapNode, TransformMode } from "@/types/editor";
export function EditorPage(): React.JSX.Element {
const {
hasMapJson,
isMapLoading,
sceneData,
setSceneData,
handleFolderUpload,
} = useEditorSceneData();
const [selectedNodeIndex, setSelectedNodeIndex] = useState<number | null>(
null,
);
const [hoveredNodeIndex, setHoveredNodeIndex] = useState<number | null>(null);
const [transformMode, setTransformMode] =
useState<TransformMode>("translate");
const [isPlayerMode, setIsPlayerMode] = useState(false);
const {
undoCount,
redoCount,
handleUndo,
handleRedo,
handleTransformStart,
handleTransformEnd,
} = useEditorHistory(sceneData, setSceneData);
const handleSelectNode = useCallback((index: number | null) => {
setSelectedNodeIndex(index);
}, []);
const handleHoverNode = useCallback((index: number | null) => {
setHoveredNodeIndex(index);
}, []);
const handleTransformModeChange = useCallback((mode: TransformMode) => {
setTransformMode(mode);
}, []);
const handleSaveToServer = useCallback(async () => {
if (!sceneData) return;
const json = JSON.stringify(sceneData.mapNodes, null, 2);
try {
const response = await fetch("/api/save-map", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: json,
});
if (response.ok) {
alert("Map enregistrée avec succès!");
} else {
alert("Erreur lors de l'enregistrement");
}
} catch (err) {
console.error("Error saving map:", err);
alert("Erreur lors de l'enregistrement");
}
}, [sceneData]);
const handleExportJson = useCallback(() => {
if (!sceneData) return;
const json = JSON.stringify(sceneData.mapNodes, null, 2);
const blob = new Blob([json], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "map.json";
a.click();
URL.revokeObjectURL(url);
}, [sceneData]);
const handlePlayerMode = useCallback(() => {
setIsPlayerMode((prev) => !prev);
}, []);
const handleNodeTransform = useCallback(
(nodeIndex: number, updatedNode: MapNode) => {
setSceneData((prev) => {
if (!prev) return null;
const newMapNodes = [...prev.mapNodes];
newMapNodes[nodeIndex] = updatedNode;
return { ...prev, mapNodes: newMapNodes };
});
},
[setSceneData],
);
if (isMapLoading) {
return (
<div className="editor-container">
<div className="editor-loading">
<h2>Chargement de l'éditeur...</h2>
<p>Vérification de map.json dans public/</p>
</div>
</div>
);
}
if (!hasMapJson) {
return (
<div className="editor-container">
<div className="editor-error">
<h2>Erreur : map.json introuvable</h2>
<p>
Le fichier map.json est requis dans le dossier <code>public/</code>.
</p>
<div className="editor-upload-section">
<h3>Télécharger un dossier contenant map.json</h3>
<label className="editor-drop-zone">
<input
type="file"
className="editor-folder-input"
onChange={handleFolderUpload}
multiple
{...{ webkitdirectory: "" }}
/>
Choisir un dossier contenant map.json
</label>
<div className="editor-folder-structure">
<h4>Structure requise :</h4>
<pre>
public/ <strong>map.json</strong> (à la racine) models/
arbre/ model.gltf building/ model.gltf
...
</pre>
</div>
</div>
</div>
</div>
);
}
return (
<div className="editor-container">
<Canvas
camera={{ position: [0, 50, 100], fov: 50 }}
style={{ width: "100%", height: "100%" }}
onCreated={({ gl }) => {
gl.setClearColor("#1e293b");
}}
>
<EditorScene
sceneData={sceneData!}
selectedNodeIndex={selectedNodeIndex}
onSelectNode={handleSelectNode}
hoveredNodeIndex={hoveredNodeIndex}
onHoverNode={handleHoverNode}
transformMode={transformMode}
onTransformModeChange={handleTransformModeChange}
onTransformStart={handleTransformStart}
onTransformEnd={handleTransformEnd}
onNodeTransform={handleNodeTransform}
onUndo={handleUndo}
onRedo={handleRedo}
isPlayerMode={isPlayerMode}
/>
</Canvas>
{sceneData && (
<EditorControls
transformMode={transformMode}
onTransformModeChange={handleTransformModeChange}
selectedNodeIndex={selectedNodeIndex}
nodesCount={sceneData.mapNodes.length}
selectedNodeName={
selectedNodeIndex !== null && sceneData.mapNodes[selectedNodeIndex]
? sceneData.mapNodes[selectedNodeIndex].name || null
: null
}
undoCount={undoCount}
redoCount={redoCount}
onUndo={handleUndo}
onRedo={handleRedo}
onExportJson={handleExportJson}
onSaveToServer={handleSaveToServer}
onPlayerMode={handlePlayerMode}
isPlayerMode={isPlayerMode}
/>
)}
</div>
);
}