feat(editor): add multi-selection transforms

This commit is contained in:
Tom Boullay
2026-05-28 00:29:00 +02:00
parent 81cd935bba
commit 65651405b6
6 changed files with 233 additions and 46 deletions
+5 -3
View File
@@ -52,7 +52,7 @@ src/
## Responsibilities
`src/pages/editor/page.tsx` is the route-level composition component. It owns route-specific state such as selected object, hovered object, transform mode, selection lock, player-mode toggle, cinematic preview requests, and editor scene loading state.
`src/pages/editor/page.tsx` is the route-level composition component. It owns route-specific state such as primary selected object, selected object indexes, hovered object, transform mode, selection lock, player-mode toggle, cinematic preview requests, and editor scene loading state.
`src/hooks/editor/useEditorSceneData.ts` loads the default map data and handles folder uploads.
@@ -60,7 +60,7 @@ src/
`src/components/editor/scene/EditorScene.tsx` composes the editor canvas scene, camera controls, lights, keyboard shortcuts, and `EditorMap`.
`src/components/editor/scene/EditorMap.tsx` renders map nodes, fallback cubes, selection highlighting, and transform controls.
`src/components/editor/scene/EditorMap.tsx` renders map nodes, fallback cubes, selection highlighting, and transform controls. For multi-selection, it attaches `TransformControls` to a temporary group centered on the selected nodes, then decomposes the group delta back into each selected node transform.
`src/components/editor/EditorControls.tsx` renders the HTML control panel outside the canvas. The panel is organized into top-level `details` groups: `Editor`, `Cinematics`, `Dialogues`, and `SRT`.
@@ -115,11 +115,12 @@ If `model.glb` and `model.gltf` are both missing, the editor renders a fallback
4. If `/map.json` is missing, the page displays a folder-upload flow.
5. `EditorSceneLoadingTracker` uses drei `useProgress()` to update the fullscreen editor loading overlay while models load.
6. `EditorScene` renders the grid, lights, camera controls, and map nodes inside `Suspense`.
7. `EditorControls` exposes transform mode, terrain snap, terrain-selection lock, add/delete node, precise scale inputs, history actions, camera focus/reset, export, save, JSON preview, selection lock, and the cinematic/dialogue/SRT editors.
7. `EditorControls` exposes transform mode, terrain snap, terrain-selection lock, add/delete node, precise scale inputs, history actions, camera focus/reset, export, save, JSON preview, selection lock, multi-selection status, and the cinematic/dialogue/SRT editors.
## Controls
- Click: select a node.
- `Shift` + right click: add or remove a node from the multi-selection.
- `Esc`: clear selection.
- Click empty space: clear selection.
- Selection lock button: prevent object clicks, empty-space clicks, and `Esc` from changing the current selection.
@@ -128,6 +129,7 @@ If `model.glb` and `model.gltf` are both missing, the editor renders a fallback
- `R`: rotate mode.
- `S`: scale mode.
- Snap terrain on move: enabled by default and applied while translating an object.
- Multi-selection transforms use a temporary centered group and write the resulting position, rotation, and scale back to every selected map node.
- Lock terrain: enabled by default so terrain remains visible but ignores selection clicks.
- Camera action: centers on the selected object or resets to the editor home view.
- Add node: creates a fallback cube under `blocking` using the requested model folder name.
+12 -8
View File
@@ -45,14 +45,15 @@ Only the `Editor` group is open by default. Open the other groups when you need
1. Open `/editor` in the local app.
2. Click an object in the scene to select it.
3. Choose a transform mode: translate, rotate, or scale.
4. Drag the transform gizmo in the 3D view.
5. Keep `Snap terrain on move` enabled when placing objects on the terrain.
6. Use `Center on object` or `Reset camera` from the `View` section when navigating large maps.
7. Adjust scale numerically from the `Selection` section if the gizmo is not precise enough.
8. Check the JSON inspector if you need exact values.
9. Use undo or redo if the transform is not correct.
10. Export the JSON or save it to the dev server.
3. Use `Shift + right click` on other objects to add or remove them from the current multi-selection.
4. Choose a transform mode: translate, rotate, or scale.
5. Drag the transform gizmo in the 3D view. With multiple objects selected, the gizmo transforms the selected group and writes each object transform back to `map.json`.
6. Keep `Snap terrain on move` enabled when placing objects on the terrain.
7. Use `Center on object` or `Reset camera` from the `View` section when navigating large maps.
8. Adjust scale numerically from the `Selection` section if the gizmo is not precise enough.
9. Check the JSON inspector if you need exact values.
10. Use undo or redo if the transform is not correct.
11. Export the JSON or save it to the dev server.
## Adding And Deleting Nodes
@@ -70,6 +71,7 @@ Use the trash button in `Selection` to delete the selected node from the map tre
| Action | Input |
| -------------------- | -------------------------- |
| Select object | Click object |
| Toggle multi-select | `Shift` + right click |
| Deselect | `Esc` or click empty space |
| Lock selection | `Lock` button in Selection |
| Clear selection | `X` button in Selection |
@@ -87,6 +89,8 @@ Use the trash button in `Selection` to delete the selected node from the map tre
The `Selection` section shows the selected object name and its index in `public/map.json`.
- Click an object to select it.
- Use `Shift + right click` on objects to add or remove them from a multi-selection.
- When several objects are selected, the gizmo appears on the selection group and applies translate, rotate, or scale to each selected node.
- Click empty space or press `Esc` to clear the selection.
- Use the `X` button to clear the selection explicitly.
- Use the `Lock` button to protect the current selection while editing.
+10 -2
View File
@@ -29,6 +29,7 @@ interface EditorControlsProps {
transformMode: TransformMode;
onTransformModeChange: (mode: TransformMode) => void;
selectedNodeIndex: number | null;
selectedNodeIndexes: number[];
mapNodes: MapNode[];
nodesCount: number;
selectedNodeName: string | null;
@@ -66,6 +67,7 @@ const TRANSFORM_OPTIONS = [
const EDITOR_SHORTCUTS = [
["Click", "Select object"],
["Shift + Right click", "Toggle multi-selection"],
["T / R / S", "Transform mode"],
["Ctrl Z / Y", "Undo / redo"],
["Esc", "Deselect"],
@@ -103,6 +105,7 @@ export function EditorControls({
transformMode,
onTransformModeChange,
selectedNodeIndex,
selectedNodeIndexes,
mapNodes,
nodesCount,
selectedNodeName,
@@ -135,6 +138,7 @@ export function EditorControls({
const jsonPreview = getJsonPreview(mapNodes, selectedNodeIndex);
const selectedNode =
selectedNodeIndex !== null ? mapNodes[selectedNodeIndex] : null;
const selectionCount = selectedNodeIndexes.length;
const transformValues = getTransformValues(selectedNode ?? null);
return (
@@ -240,10 +244,14 @@ export function EditorControls({
<Box size={17} aria-hidden="true" />
<div>
<strong>
{selectedNodeName || `Node ${selectedNodeIndex + 1}`}
{selectionCount > 1
? `${selectionCount} selected nodes`
: selectedNodeName || `Node ${selectedNodeIndex + 1}`}
</strong>
<span>
Index {selectedNodeIndex + 1} of {nodesCount}
{selectionCount > 1
? `Primary index ${selectedNodeIndex + 1} of ${nodesCount}`
: `Index ${selectedNodeIndex + 1} of ${nodesCount}`}
</span>
</div>
<div className="editor-selected-actions">
+158 -28
View File
@@ -1,4 +1,4 @@
import { useRef, useEffect, useState } from "react";
import { useCallback, useEffect, useLayoutEffect, useRef } from "react";
import { Grid, TransformControls } from "@react-three/drei";
import type { ThreeEvent } from "@react-three/fiber";
import * as THREE from "three";
@@ -16,7 +16,9 @@ import {
interface EditorMapProps {
sceneData: SceneData;
selectedNodeIndex: number | null;
selectedNodeIndexes: number[];
onSelectNode: (index: number | null) => void;
onToggleNodeSelection: (index: number) => void;
isSelectionLocked: boolean;
hoveredNodeIndex: number | null;
onHoverNode: (index: number | null) => void;
@@ -37,16 +39,31 @@ interface EditorNodeCommonProps {
isHovered: boolean;
objectsMapRef: EditorNodeObjectRef;
onSelectNode: (index: number | null) => void;
onToggleNodeSelection: (index: number) => void;
isSelectionLocked: boolean;
onHoverNode: (index: number | null) => void;
}
interface EditorNodePointerHandlers {
onClick: (event: ThreeEvent<MouseEvent>) => void;
onContextMenu: (event: ThreeEvent<MouseEvent>) => void;
onPointerEnter: (event: ThreeEvent<PointerEvent>) => void;
onPointerLeave: (event: ThreeEvent<PointerEvent>) => void;
}
interface TransformSnapshot {
groupMatrix: THREE.Matrix4;
objects: Map<number, THREE.Matrix4>;
}
const TEMP_BOX = new THREE.Box3();
const TEMP_CENTER = new THREE.Vector3();
const TEMP_DELTA_MATRIX = new THREE.Matrix4();
const TEMP_INVERSE_GROUP_MATRIX = new THREE.Matrix4();
const TEMP_POSITION = new THREE.Vector3();
const TEMP_QUATERNION = new THREE.Quaternion();
const TEMP_SCALE = new THREE.Vector3();
function applyNodeTransform(object: THREE.Object3D, node: MapNode): void {
object.position.set(...node.position);
object.rotation.set(...node.rotation);
@@ -118,6 +135,7 @@ function getNodeHighlightColor(
function createEditorNodePointerHandlers(
index: number,
onSelectNode: (index: number | null) => void,
onToggleNodeSelection: (index: number) => void,
isSelectionLocked: boolean,
onHoverNode: (index: number | null) => void,
): EditorNodePointerHandlers {
@@ -127,6 +145,12 @@ function createEditorNodePointerHandlers(
if (isSelectionLocked) return;
onSelectNode(index);
},
onContextMenu: (event) => {
event.stopPropagation();
event.nativeEvent.preventDefault();
if (!event.nativeEvent.shiftKey || isSelectionLocked) return;
onToggleNodeSelection(index);
},
onPointerEnter: (event) => {
event.stopPropagation();
onHoverNode(index);
@@ -141,7 +165,9 @@ function createEditorNodePointerHandlers(
export function EditorMap({
sceneData,
selectedNodeIndex,
selectedNodeIndexes,
onSelectNode,
onToggleNodeSelection,
isSelectionLocked,
hoveredNodeIndex,
onHoverNode,
@@ -153,18 +179,110 @@ export function EditorMap({
onNodeTransform,
}: EditorMapProps): React.JSX.Element {
const objectsMapRef = useRef<Map<number, THREE.Object3D>>(new Map());
const transformGroupRef = useRef<THREE.Group>(null);
const transformSnapshotRef = useRef<TransformSnapshot | null>(null);
const terrainHeight = useTerrainHeightSampler();
const handleTransformMouseDown = () => {
onTransformStart();
};
const selectedIndexSet = new Set(selectedNodeIndexes);
const isMultiSelection = selectedNodeIndexes.length > 1;
const handleTransformMouseUp = () => {
syncSelectedObjectTransform();
onTransformEnd();
const getTransformObject = useCallback(() => {
if (isMultiSelection) {
return transformGroupRef.current;
}
if (selectedNodeIndex !== null) {
return objectsMapRef.current.get(selectedNodeIndex) ?? null;
}
return null;
}, [isMultiSelection, selectedNodeIndex]);
const prepareTransformGroup = useCallback(() => {
if (!isMultiSelection || !transformGroupRef.current) return;
const selectedObjects = selectedNodeIndexes
.map((index) => objectsMapRef.current.get(index))
.filter((object): object is THREE.Object3D => Boolean(object));
if (selectedObjects.length === 0) return;
TEMP_BOX.makeEmpty();
for (const object of selectedObjects) {
object.updateWorldMatrix(true, false);
TEMP_BOX.expandByPoint(object.getWorldPosition(TEMP_CENTER));
}
TEMP_BOX.getCenter(TEMP_CENTER);
transformGroupRef.current.position.copy(TEMP_CENTER);
transformGroupRef.current.rotation.set(0, 0, 0);
transformGroupRef.current.scale.set(1, 1, 1);
transformGroupRef.current.updateMatrixWorld(true);
}, [isMultiSelection, selectedNodeIndexes]);
const createTransformSnapshot = useCallback((): TransformSnapshot | null => {
const transformGroup = transformGroupRef.current;
if (!isMultiSelection || !transformGroup) return null;
const objects = new Map<number, THREE.Matrix4>();
for (const index of selectedNodeIndexes) {
const object = objectsMapRef.current.get(index);
if (!object) continue;
object.updateMatrixWorld(true);
objects.set(index, object.matrix.clone());
}
transformGroup.updateMatrixWorld(true);
return {
groupMatrix: transformGroup.matrix.clone(),
objects,
};
}, [isMultiSelection, selectedNodeIndexes]);
const syncSelectedObjectTransform = () => {
if (isMultiSelection) {
const transformGroup = transformGroupRef.current;
const snapshot = transformSnapshotRef.current;
if (!transformGroup || !snapshot) return;
transformGroup.updateMatrix();
TEMP_INVERSE_GROUP_MATRIX.copy(snapshot.groupMatrix).invert();
TEMP_DELTA_MATRIX.multiplyMatrices(
transformGroup.matrix,
TEMP_INVERSE_GROUP_MATRIX,
);
for (const [index, startMatrix] of snapshot.objects) {
const obj = objectsMapRef.current.get(index);
const node = sceneData.mapNodes[index];
if (!obj || !node) continue;
const nextMatrix = TEMP_DELTA_MATRIX.clone().multiply(startMatrix);
nextMatrix.decompose(TEMP_POSITION, TEMP_QUATERNION, TEMP_SCALE);
obj.position.copy(TEMP_POSITION);
obj.quaternion.copy(TEMP_QUATERNION);
obj.scale.copy(TEMP_SCALE);
const terrainY = snapToTerrain
? terrainHeight.getHeight(obj.position.x, obj.position.z)
: null;
if (terrainY !== null && transformMode === "translate") {
obj.position.y = terrainY;
}
onNodeTransform(index, {
...node,
position: [obj.position.x, obj.position.y, obj.position.z],
rotation: [obj.rotation.x, obj.rotation.y, obj.rotation.z],
scale: [obj.scale.x, obj.scale.y, obj.scale.z],
});
}
return;
}
if (selectedNodeIndex !== null) {
const obj = objectsMapRef.current.get(selectedNodeIndex);
if (!obj) return;
@@ -194,25 +312,30 @@ export function EditorMap({
}
};
const [selectedObject, setSelectedObject] = useState<THREE.Object3D | null>(
null,
);
const handleTransformMouseDown = () => {
prepareTransformGroup();
transformSnapshotRef.current = createTransformSnapshot();
onTransformStart();
};
const handleTransformMouseUp = () => {
syncSelectedObjectTransform();
transformSnapshotRef.current = null;
prepareTransformGroup();
onTransformEnd();
};
const terrainNode = getTerrainMapNode(sceneData.mapNodes);
const terrainNodeIndex = terrainNode
? sceneData.mapNodes.indexOf(terrainNode)
: -1;
const selectedNode =
selectedNodeIndex !== null ? sceneData.mapNodes[selectedNodeIndex] : null;
const selectedModelName = selectedNode?.name ?? null;
useLayoutEffect(() => {
prepareTransformGroup();
}, [prepareTransformGroup]);
useEffect(() => {
if (selectedNodeIndex !== null) {
const obj = objectsMapRef.current.get(selectedNodeIndex);
setSelectedObject(obj || null);
} else {
setSelectedObject(null);
}
}, [selectedNodeIndex]);
// TransformControls needs the current Three object; editor refs are managed outside React rendering.
// eslint-disable-next-line react-hooks/refs
const selectedObject = getTransformObject();
return (
<>
@@ -236,11 +359,12 @@ export function EditorMap({
<EditorTerrainNode
index={terrainNodeIndex}
node={terrainNode}
isSelected={selectedNodeIndex === terrainNodeIndex}
isSelected={selectedIndexSet.has(terrainNodeIndex)}
isHovered={hoveredNodeIndex === terrainNodeIndex}
lockTerrainSelection={lockTerrainSelection}
objectsMapRef={objectsMapRef}
onSelectNode={onSelectNode}
onToggleNodeSelection={onToggleNodeSelection}
isSelectionLocked={isSelectionLocked}
onHoverNode={onHoverNode}
/>
@@ -250,10 +374,6 @@ export function EditorMap({
return null;
}
if (selectedModelName && node.name !== selectedModelName) {
return null;
}
const modelUrl = sceneData.models.get(node.name);
if (modelUrl) {
@@ -263,10 +383,11 @@ export function EditorMap({
index={index}
node={node}
modelUrl={modelUrl}
isSelected={selectedNodeIndex === index}
isSelected={selectedIndexSet.has(index)}
isHovered={hoveredNodeIndex === index}
objectsMapRef={objectsMapRef}
onSelectNode={onSelectNode}
onToggleNodeSelection={onToggleNodeSelection}
isSelectionLocked={isSelectionLocked}
onHoverNode={onHoverNode}
/>
@@ -277,10 +398,11 @@ export function EditorMap({
key={index}
index={index}
node={node}
isSelected={selectedNodeIndex === index}
isSelected={selectedIndexSet.has(index)}
isHovered={hoveredNodeIndex === index}
objectsMapRef={objectsMapRef}
onSelectNode={onSelectNode}
onToggleNodeSelection={onToggleNodeSelection}
isSelectionLocked={isSelectionLocked}
onHoverNode={onHoverNode}
/>
@@ -289,6 +411,8 @@ export function EditorMap({
})}
</group>
<group ref={transformGroupRef} />
{selectedObject && (
<TransformControls
object={selectedObject}
@@ -310,6 +434,7 @@ function EditorModelNode({
isHovered,
objectsMapRef,
onSelectNode,
onToggleNodeSelection,
isSelectionLocked,
onHoverNode,
}: EditorNodeCommonProps & {
@@ -329,6 +454,7 @@ function EditorModelNode({
const pointerHandlers = createEditorNodePointerHandlers(
index,
onSelectNode,
onToggleNodeSelection,
isSelectionLocked,
onHoverNode,
);
@@ -403,6 +529,7 @@ function EditorTerrainNode({
lockTerrainSelection,
objectsMapRef,
onSelectNode,
onToggleNodeSelection,
isSelectionLocked,
onHoverNode,
}: EditorNodeCommonProps & { lockTerrainSelection: boolean }) {
@@ -410,6 +537,7 @@ function EditorTerrainNode({
const pointerHandlers = createEditorNodePointerHandlers(
index,
onSelectNode,
onToggleNodeSelection,
isSelectionLocked,
onHoverNode,
);
@@ -435,6 +563,7 @@ function EditorFallbackNode({
isHovered,
objectsMapRef,
onSelectNode,
onToggleNodeSelection,
isSelectionLocked,
onHoverNode,
}: EditorNodeCommonProps) {
@@ -442,6 +571,7 @@ function EditorFallbackNode({
const pointerHandlers = createEditorNodePointerHandlers(
index,
onSelectNode,
onToggleNodeSelection,
isSelectionLocked,
onHoverNode,
);
+6 -3
View File
@@ -5,7 +5,6 @@ import gsap from "gsap";
import * as THREE from "three";
import type { OrbitControls as OrbitControlsImpl } from "three-stdlib";
import { EditorMap } from "@/components/editor/scene/EditorMap";
import { TerrainModel } from "@/components/three/world/TerrainModel";
import { FlyController } from "@/controls/editor/FlyController";
import type { CinematicDefinition } from "@/types/cinematics/cinematics";
import type { MapNode, TransformMode, SceneData } from "@/types/editor/editor";
@@ -21,7 +20,9 @@ export interface EditorCinematicPreviewRequest {
interface EditorSceneProps {
sceneData: SceneData;
selectedNodeIndex: number | null;
selectedNodeIndexes: number[];
onSelectNode: (index: number | null) => void;
onToggleNodeSelection: (index: number) => void;
isSelectionLocked: boolean;
hoveredNodeIndex: number | null;
onHoverNode: (index: number | null) => void;
@@ -44,7 +45,9 @@ interface EditorSceneProps {
export function EditorScene({
sceneData,
selectedNodeIndex,
selectedNodeIndexes,
onSelectNode,
onToggleNodeSelection,
isSelectionLocked,
hoveredNodeIndex,
onHoverNode,
@@ -209,7 +212,9 @@ export function EditorScene({
<EditorMap
sceneData={sceneData}
selectedNodeIndex={selectedNodeIndex}
selectedNodeIndexes={selectedNodeIndexes}
onSelectNode={onSelectNode}
onToggleNodeSelection={onToggleNodeSelection}
isSelectionLocked={isSelectionLocked}
hoveredNodeIndex={hoveredNodeIndex}
onHoverNode={onHoverNode}
@@ -221,8 +226,6 @@ export function EditorScene({
onNodeTransform={onNodeTransform}
/>
<TerrainModel />
<ambientLight intensity={0.6} />
<directionalLight position={[10, 20, 10]} intensity={1.5} castShadow />
<directionalLight position={[-10, 10, -10]} intensity={0.5} />
+41 -1
View File
@@ -313,6 +313,7 @@ export function EditorPage(): React.JSX.Element {
const [selectedNodeIndex, setSelectedNodeIndex] = useState<number | null>(
null,
);
const [selectedNodeIndexes, setSelectedNodeIndexes] = useState<number[]>([]);
const [hoveredNodeIndex, setHoveredNodeIndex] = useState<number | null>(null);
const [transformMode, setTransformMode] =
useState<TransformMode>("translate");
@@ -370,13 +371,31 @@ export function EditorPage(): React.JSX.Element {
const handleSelectNode = useCallback((index: number | null) => {
setSelectedNodeIndex(index);
setSelectedNodeIndexes(index === null ? [] : [index]);
if (index !== null) {
setCameraViewMode("object");
}
}, []);
const handleToggleNodeSelection = useCallback((index: number) => {
setSelectedNodeIndexes((currentIndexes) => {
const isSelected = currentIndexes.includes(index);
const nextIndexes = isSelected
? currentIndexes.filter((item) => item !== index)
: [...currentIndexes, index];
setSelectedNodeIndex(nextIndexes.at(-1) ?? null);
if (nextIndexes.length > 0) {
setCameraViewMode("object");
}
return nextIndexes;
});
}, []);
const handleClearSelection = useCallback(() => {
setSelectedNodeIndex(null);
setSelectedNodeIndexes([]);
}, []);
const handleSelectionLockToggle = useCallback(() => {
@@ -401,7 +420,21 @@ export function EditorPage(): React.JSX.Element {
if (currentIndex === null) return null;
const selectedNode = sceneData?.mapNodes[currentIndex];
return selectedNode?.name === "terrain" ? null : currentIndex;
if (selectedNode?.name === "terrain") {
setSelectedNodeIndexes((indexes) =>
indexes.filter(
(index) => sceneData?.mapNodes[index]?.name !== "terrain",
),
);
return null;
}
setSelectedNodeIndexes((indexes) =>
indexes.filter(
(index) => sceneData?.mapNodes[index]?.name !== "terrain",
),
);
return currentIndex;
});
},
[sceneData],
@@ -544,12 +577,14 @@ export function EditorPage(): React.JSX.Element {
const newNode = createNewMapNode(newNodeName);
const mapNodes = [...prev.mapNodes, removeEditorMetadata(newNode)];
setSelectedNodeIndex(mapNodes.length - 1);
setSelectedNodeIndexes([mapNodes.length - 1]);
return { ...prev, mapNodes };
}
const mapTree = addTreeNode(prev.mapTree, createNewMapNode(newNodeName));
const nextSceneData = updateSceneDataTree(prev, mapTree);
setSelectedNodeIndex(nextSceneData.mapNodes.length - 1);
setSelectedNodeIndexes([nextSceneData.mapNodes.length - 1]);
return nextSceneData;
});
}, [newNodeName, setSceneData]);
@@ -563,6 +598,7 @@ export function EditorPage(): React.JSX.Element {
if (!currentNode) return prev;
if (!prev.mapTree || !currentNode.sourcePath) {
setSelectedNodeIndex(null);
setSelectedNodeIndexes([]);
return {
...prev,
mapNodes: prev.mapNodes.filter(
@@ -576,6 +612,7 @@ export function EditorPage(): React.JSX.Element {
currentNode.sourcePath,
);
setSelectedNodeIndex(null);
setSelectedNodeIndexes([]);
return updateSceneDataTree(prev, mapTree);
});
}, [selectedNodeIndex, setSceneData]);
@@ -660,7 +697,9 @@ export function EditorPage(): React.JSX.Element {
<EditorScene
sceneData={sceneData!}
selectedNodeIndex={selectedNodeIndex}
selectedNodeIndexes={selectedNodeIndexes}
onSelectNode={handleSelectNode}
onToggleNodeSelection={handleToggleNodeSelection}
isSelectionLocked={isSelectionLocked}
hoveredNodeIndex={hoveredNodeIndex}
onHoverNode={handleHoverNode}
@@ -689,6 +728,7 @@ export function EditorPage(): React.JSX.Element {
transformMode={transformMode}
onTransformModeChange={handleTransformModeChange}
selectedNodeIndex={selectedNodeIndex}
selectedNodeIndexes={selectedNodeIndexes}
mapNodes={sceneData.mapNodes}
nodesCount={sceneData.mapNodes.length}
selectedNodeName={