voxel editor undo/redo
This commit is contained in:
parent
30ab3e4d7e
commit
52caee642e
|
|
@ -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 */}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue