feat: history/undo & redo

This commit is contained in:
trafficlunar 2025-02-07 20:17:30 +00:00
parent f02977b096
commit ac9ac3d454
9 changed files with 237 additions and 29 deletions

View file

@ -4,6 +4,7 @@ import * as PIXI from "pixi.js";
import { Container, Stage } from "@pixi/react";
import { CanvasContext } from "@/context/Canvas";
import { HistoryContext } from "@/context/History";
import { SelectionContext } from "@/context/Selection";
import { SettingsContext } from "@/context/Settings";
import { TexturesContext } from "@/context/Textures";
@ -41,6 +42,7 @@ PIXI.settings.SCALE_MODE = PIXI.SCALE_MODES.NEAREST;
function Canvas() {
const { stageSize, canvasSize, blocks, coords, scale, version, setStageSize, setBlocks, setCoords, setScale } = useContext(CanvasContext);
const { addHistory, undo, redo } = useContext(HistoryContext);
const { selectionCoords, selectionLayerBlocks, setSelectionCoords, setSelectionLayerBlocks } = useContext(SelectionContext);
const { settings } = useContext(SettingsContext);
const { missingTexture } = useContext(TexturesContext);
@ -62,6 +64,9 @@ function Canvas() {
const oldToolRef = useRef<Tool>();
const [cssCursor, setCssCursor] = useState("crosshair");
const startBlocksRef = useRef<Block[]>([]);
const startSelectionCoordsRef = useRef<CoordinateArray>([]);
const zoom = useCallback(
(newScale: number) => {
setScale(newScale);
@ -180,15 +185,40 @@ function Canvas() {
updateCssCursor();
dragStartCoordsRef.current = mouseCoords;
startBlocksRef.current = [...blocks];
startSelectionCoordsRef.current = [...selectionCoords];
// Clear selection on click
if (tool === "rectangle-select") setSelectionCoords([]);
}, [onToolUse, updateCssCursor, mouseCoords, tool, setSelectionCoords]);
}, [onToolUse, updateCssCursor, mouseCoords, blocks, selectionCoords, tool, setSelectionCoords]);
const onMouseUp = useCallback(() => {
setDragging(false);
updateCssCursor();
}, [updateCssCursor]);
// History entries for pencil and eraser
if (tool == "pencil" || tool == "eraser") {
// startBlocksRef will mutate if we pass it directly
const prevBlocks = [...startBlocksRef.current];
addHistory(
tool == "pencil" ? "Pencil" : "Eraser",
() => setBlocks([...blocks]),
() => setBlocks([...prevBlocks])
);
}
if (tool == "rectangle-select" || tool == "magic-wand" || tool == "lasso") {
// startSelectionCoordsRef will mutate if we pass it directly
const prevSelection = [...startSelectionCoordsRef.current];
addHistory(
tool == "rectangle-select" ? "Rectangle Select" : tool == "lasso" ? "Lasso" : "Magic Wand",
() => setSelectionCoords([...selectionCoords]),
() => setSelectionCoords([...prevSelection])
);
}
}, [updateCssCursor, blocks, tool, addHistory, setBlocks, selectionCoords, setSelectionCoords]);
const onWheel = useCallback(
(e: React.WheelEvent) => {
@ -233,10 +263,18 @@ function Canvas() {
holdingAltRef.current = true;
if (tool === "zoom") setCssCursor("zoom-out");
break;
case "Delete": {
setBlocks((prev) => prev.filter((b) => !selectionCoords.some(([x2, y2]) => x2 === b.x && y2 === b.y)));
case "Delete":
setBlocks((prev) => {
const deletedBlocks = prev.filter((b) => !selectionCoords.some(([x2, y2]) => x2 === b.x && y2 === b.y));
addHistory(
"Delete",
() => setBlocks(deletedBlocks),
() => setBlocks(prev)
);
return deletedBlocks;
});
break;
}
case "a": {
if (!e.ctrlKey) return;
e.preventDefault();
@ -252,16 +290,22 @@ function Canvas() {
setSelectionCoords(newSelection);
break;
}
case "c": {
case "z":
if (!e.ctrlKey) return;
undo();
break;
case "y":
if (!e.ctrlKey) return;
redo();
break;
case "c":
if (!e.ctrlKey) return;
clipboard.copy(selectionCoords, blocks);
break;
}
case "v": {
case "v":
if (!e.ctrlKey) return;
clipboard.paste(setSelectionLayerBlocks, setSelectionCoords, setTool);
break;
}
case "1":
setTool("hand");
break;
@ -321,6 +365,8 @@ function Canvas() {
setSelectionCoords,
setSelectionLayerBlocks,
setTool,
redo,
undo,
]
);
@ -383,7 +429,6 @@ function Canvas() {
};
window.addEventListener("beforeunload", onBeforeUnload);
return () => {
window.removeEventListener("beforeunload", onBeforeUnload);
};

View file

@ -31,6 +31,9 @@ function ViewMenu() {
</MenubarCheckboxItem>
<MenubarSeparator />
<MenubarCheckboxItem checked={settings.historyPanel} onCheckedChange={onCheckedChange("historyPanel")}>
History Panel
</MenubarCheckboxItem>
<MenubarCheckboxItem checked={settings.colorPicker} onCheckedChange={onCheckedChange("colorPicker")}>
Color Picker
</MenubarCheckboxItem>

View file

@ -0,0 +1,30 @@
import { useContext } from "react";
import { HistoryContext } from "@/context/History";
import { ScrollArea } from "@/components/ui/scroll-area";
function History() {
const { history, currentIndex, jumpTo } = useContext(HistoryContext);
return (
<ScrollArea key={history.length} className="h-48 border border-zinc-200 dark:border-zinc-800 rounded-md">
<div className="flex flex-col">
{history.map(({ name }, index) => (
<button
key={index}
onClick={() => jumpTo(index)}
className={`w-full border-b border-zinc-200 dark:border-zinc-800
// Current entry
${index == currentIndex ? "bg-zinc-200 dark:bg-zinc-800 cursor-auto" : "bg-zinc-100 dark:bg-zinc-900"}
// Ghost entries
${index > currentIndex ? "bg-zinc-300 dark:bg-zinc-950 text-zinc-500 dark:text-zinc-600" : ""}
`}
>
<span>{name}</span>
</button>
))}
</div>
</ScrollArea>
);
}
export default History;

View file

@ -1,4 +1,5 @@
import { useContext, useEffect, useRef, useState } from "react";
import { GripVerticalIcon } from "lucide-react";
import { SettingsContext } from "@/context/Settings";
@ -6,11 +7,11 @@ import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { ScrollArea } from "@/components/ui/scroll-area";
import History from "./History";
import ColorPicker from "./ColorPicker";
import Replace from "./Replace";
import Radius from "./Radius";
import BlockSelector from "./BlockSelector";
import { GripVerticalIcon } from "lucide-react";
function ToolSettings() {
const { settings } = useContext(SettingsContext);
@ -82,6 +83,14 @@ function ToolSettings() {
<GripVerticalIcon />
</div>
{settings.historyPanel && (
<>
{/* <span className="text-xs text-zinc-600">History</span> */}
<History />
<Separator />
</>
)}
{settings.colorPicker && (
<>
<ColorPicker />