voxel editor undo/redo

This commit is contained in:
azykov@mail.ru 2026-06-06 09:05:07 +03:00
parent 30ab3e4d7e
commit 52caee642e
No known key found for this signature in database
1 changed files with 76 additions and 11 deletions

View File

@ -1,11 +1,11 @@
import { Canvas } from '@react-three/fiber'; import { Canvas } from '@react-three/fiber';
import { useParams, useNavigate, Link } from 'react-router-dom'; import { useParams, useNavigate, Link } from 'react-router-dom';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import { useState, useCallback, useEffect } from 'react'; import { useState, useCallback, useEffect, useRef } from 'react';
import { state } from '../../state'; import { state } from '../../state';
import type { Voxel, V3 } from '../../types'; import type { Voxel, V3 } from '../../types';
import { VoxelEditorScene } from './VoxelEditorScene'; import { VoxelEditorScene } from './VoxelEditorScene';
import { IconArrowLeft, IconPlus, IconTrash } from '@tabler/icons-react'; import { IconArrowLeft, IconArrowBackUp, IconArrowForwardUp, IconPlus, IconTrash } from '@tabler/icons-react';
const PALETTE = [ const PALETTE = [
'#ffffff', '#cccccc', '#999999', '#555555', '#222222', '#ffffff', '#cccccc', '#999999', '#555555', '#222222',
@ -14,11 +14,19 @@ const PALETTE = [
'#e8b820', '#484040', '#d0c8b8', '#808080', '#181414', '#e8b820', '#484040', '#d0c8b8', '#808080', '#181414',
]; ];
type HistoryEntry =
| { type: 'add'; voxel: Voxel }
| { type: 'remove'; voxel: Voxel };
export const VoxelEditorPage = observer(function () { export const VoxelEditorPage = observer(function () {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const [mode, setMode] = useState<'add' | 'remove'>('add'); const [mode, setMode] = useState<'add' | 'remove'>('add');
const [color, setColor] = useState('#808080'); const [color, setColor] = useState('#808080');
const [canUndo, setCanUndo] = useState(false);
const [canRedo, setCanRedo] = useState(false);
const history = useRef<HistoryEntry[]>([]);
const future = useRef<HistoryEntry[]>([]);
const objectType = id ? state.world.getObjectTypeById(id) : undefined; const objectType = id ? state.world.getObjectTypeById(id) : undefined;
@ -28,14 +36,67 @@ export const VoxelEditorPage = observer(function () {
}; };
}, [id]); }, [id]);
const handleAdd = useCallback((voxel: Voxel) => { function pushHistory(entry: HistoryEntry) {
if (id) state.worldEditor.addVoxelToObjectType(id, voxel); 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]); }, [id]);
const handleRemove = useCallback((position: V3) => { const redo = useCallback(() => {
if (id) state.worldEditor.removeVoxelFromObjectType(id, position); 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]); }, [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) { if (!objectType) {
return ( return (
<div style={{ color: 'white', padding: 24 }}> <div style={{ color: 'white', padding: 24 }}>
@ -47,15 +108,19 @@ export const VoxelEditorPage = observer(function () {
return ( return (
<div style={{ position: 'fixed', inset: 0, background: '#1a1a2e', display: 'flex', flexDirection: 'column' }}> <div style={{ position: 'fixed', inset: 0, background: '#1a1a2e', display: 'flex', flexDirection: 'column' }}>
{/* Top bar */} {/* Top bar */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '8px 12px', background: '#11111f', flexShrink: 0 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 12px', background: '#11111f', flexShrink: 0 }}>
<button <button onClick={() => navigate(`/editor/objectType/${id}`)} style={btnStyle}>
onClick={() => navigate(`/editor/objectType/${id}`)}
style={btnStyle}
>
<IconArrowLeft size={20} /> <IconArrowLeft size={20} />
</button> </button>
<span style={{ color: '#ccc', fontWeight: 600 }}>{objectType.name}</span> <span style={{ color: '#ccc', fontWeight: 600 }}>{objectType.name}</span>
<span style={{ color: '#666', fontSize: 13 }}>{objectType.voxels.length} voxels</span> <span style={{ color: '#666', fontSize: 13 }}>{objectType.voxels.length} voxels</span>
<div style={{ flex: 1 }} />
<button onClick={undo} disabled={!canUndo} style={{ ...btnStyle, opacity: canUndo ? 1 : 0.35 }}>
<IconArrowBackUp size={20} />
</button>
<button onClick={redo} disabled={!canRedo} style={{ ...btnStyle, opacity: canRedo ? 1 : 0.35 }}>
<IconArrowForwardUp size={20} />
</button>
</div> </div>
{/* 3D canvas */} {/* 3D canvas */}