diff --git a/src/components/voxelEditor/VoxelEditorPage.tsx b/src/components/voxelEditor/VoxelEditorPage.tsx index 397985f..0ae6fee 100644 --- a/src/components/voxelEditor/VoxelEditorPage.tsx +++ b/src/components/voxelEditor/VoxelEditorPage.tsx @@ -1,11 +1,11 @@ import { Canvas } from '@react-three/fiber'; import { useParams, useNavigate, Link } from 'react-router-dom'; import { observer } from 'mobx-react-lite'; -import { useState, useCallback, useEffect } from 'react'; +import { useState, useCallback, useEffect, useRef } from 'react'; import { state } from '../../state'; import type { Voxel, V3 } from '../../types'; import { VoxelEditorScene } from './VoxelEditorScene'; -import { IconArrowLeft, IconPlus, IconTrash } from '@tabler/icons-react'; +import { IconArrowLeft, IconArrowBackUp, IconArrowForwardUp, IconPlus, IconTrash } from '@tabler/icons-react'; const PALETTE = [ '#ffffff', '#cccccc', '#999999', '#555555', '#222222', @@ -14,11 +14,19 @@ const PALETTE = [ '#e8b820', '#484040', '#d0c8b8', '#808080', '#181414', ]; +type HistoryEntry = + | { type: 'add'; voxel: Voxel } + | { type: 'remove'; voxel: Voxel }; + export const VoxelEditorPage = observer(function () { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); const [mode, setMode] = useState<'add' | 'remove'>('add'); const [color, setColor] = useState('#808080'); + const [canUndo, setCanUndo] = useState(false); + const [canRedo, setCanRedo] = useState(false); + const history = useRef([]); + const future = useRef([]); const objectType = id ? state.world.getObjectTypeById(id) : undefined; @@ -28,14 +36,67 @@ export const VoxelEditorPage = observer(function () { }; }, [id]); - const handleAdd = useCallback((voxel: Voxel) => { - if (id) state.worldEditor.addVoxelToObjectType(id, voxel); + function pushHistory(entry: HistoryEntry) { + history.current.push(entry); + future.current = []; + setCanUndo(true); + setCanRedo(false); + } + + const undo = useCallback(() => { + const entry = history.current.pop(); + if (!entry || !id) return; + if (entry.type === 'add') { + state.worldEditor.removeVoxelFromObjectType(id, entry.voxel.position); + } else { + state.worldEditor.addVoxelToObjectType(id, entry.voxel); + } + future.current.push(entry); + setCanUndo(history.current.length > 0); + setCanRedo(true); }, [id]); - const handleRemove = useCallback((position: V3) => { - if (id) state.worldEditor.removeVoxelFromObjectType(id, position); + const redo = useCallback(() => { + const entry = future.current.pop(); + if (!entry || !id) return; + if (entry.type === 'add') { + state.worldEditor.addVoxelToObjectType(id, entry.voxel); + } else { + state.worldEditor.removeVoxelFromObjectType(id, entry.voxel.position); + } + history.current.push(entry); + setCanUndo(true); + setCanRedo(future.current.length > 0); }, [id]); + useEffect(() => { + function onKeyDown(e: KeyboardEvent) { + if (!e.ctrlKey) return; + if (e.key === 'z' && !e.shiftKey) { e.preventDefault(); undo(); } + if (e.key === 'y' || (e.key === 'z' && e.shiftKey)) { e.preventDefault(); redo(); } + } + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [undo, redo]); + + function handleAdd(voxel: Voxel) { + if (!id) return; + state.worldEditor.addVoxelToObjectType(id, voxel); + pushHistory({ type: 'add', voxel }); + } + + function handleRemove(position: V3) { + if (!id || !objectType) return; + const voxel = objectType.voxels.find(v => + v.position[0] === position[0] && + v.position[1] === position[1] && + v.position[2] === position[2] + ); + if (!voxel) return; + state.worldEditor.removeVoxelFromObjectType(id, position); + pushHistory({ type: 'remove', voxel }); + } + if (!objectType) { return (
@@ -47,15 +108,19 @@ export const VoxelEditorPage = observer(function () { return (
{/* Top bar */} -
- {objectType.name} {objectType.voxels.length} voxels +
+ +
{/* 3D canvas */}