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 />

View file

@ -1,5 +1,6 @@
import React, { createContext, ReactNode, useMemo, useState } from "react";
import React, { createContext, ReactNode, useContext, useEffect, useMemo, useState } from "react";
import { HistoryContext } from "./History";
import welcomeBlocksData from "@/data/welcome.json";
interface Context {
@ -24,6 +25,8 @@ interface Props {
export const CanvasContext = createContext<Context>({} as Context);
export const CanvasProvider = ({ children }: Props) => {
const { addHistory } = useContext(HistoryContext);
const [stageSize, setStageSize] = useState<Dimension>({ width: 0, height: 0 });
const [blocks, setBlocks] = useState<Block[]>(welcomeBlocksData);
const [coords, setCoords] = useState<Position>({ x: 0, y: 0 });
@ -84,9 +87,30 @@ export const CanvasProvider = ({ children }: Props) => {
setCoords({ x: newX, y: newY });
};
useEffect(() => {
addHistory(
"New Canvas",
() => setBlocks(welcomeBlocksData),
() => setBlocks([])
);
}, []);
return (
<CanvasContext.Provider
value={{ stageSize, canvasSize, blocks, coords, scale, version, setStageSize, setBlocks, setCoords, setScale, setVersion, centerCanvas }}
value={{
stageSize,
canvasSize,
blocks,
coords,
scale,
version,
setStageSize,
setBlocks,
setCoords,
setScale,
setVersion,
centerCanvas,
}}
>
{children}
</CanvasContext.Provider>

86
src/context/History.tsx Normal file
View file

@ -0,0 +1,86 @@
import { createContext, ReactNode, useState } from "react";
interface Context {
history: HistoryEntry[];
currentIndex: number;
isUndoAvailable: boolean;
isRedoAvailable: boolean;
addHistory: (name: string, apply: () => void, revert: () => void) => void;
undo: () => void;
redo: () => void;
jumpTo: (index: number) => void;
}
interface Props {
children: ReactNode;
}
export const HistoryContext = createContext<Context>({} as Context);
export const HistoryProvider = ({ children }: Props) => {
const [history, setHistory] = useState<HistoryEntry[]>([]);
const [currentIndex, setCurrentIndex] = useState(-1);
const isUndoAvailable = currentIndex > 0;
const isRedoAvailable = currentIndex < history.length - 1;
const MAX_HISTORY = 20;
const addHistory = (name: string, apply: () => void, revert: () => void) => {
const newHistory = history.slice(0, currentIndex + 1);
newHistory.push({ name, apply, revert });
if (newHistory.length > MAX_HISTORY) {
newHistory.shift();
setCurrentIndex((prev) => Math.max(prev - 1, 0));
} else {
setCurrentIndex(newHistory.length - 1);
}
setHistory(newHistory);
};
const undo = () => {
if (!isUndoAvailable) return;
history[currentIndex].revert();
setCurrentIndex(currentIndex - 1);
};
const redo = () => {
if (!isRedoAvailable) return;
history[currentIndex + 1].apply();
setCurrentIndex(currentIndex + 1);
};
const jumpTo = (index: number) => {
if (index == currentIndex) return;
if (index > currentIndex) {
for (let i = currentIndex + 1; i <= index; i++) {
history[i].apply();
}
} else {
for (let i = currentIndex; i > index; i--) {
history[i].revert();
}
}
setCurrentIndex(index);
};
return (
<HistoryContext.Provider
value={{
history,
currentIndex,
isUndoAvailable,
isRedoAvailable,
addHistory,
undo,
redo,
jumpTo,
}}
>
{children}
</HistoryContext.Provider>
);
};

View file

@ -12,8 +12,9 @@ interface Props {
const defaultSettings: Settings = {
grid: true,
canvasBorder: false,
historyPanel: true,
colorPicker: false,
blockReplacer: true,
blockReplacer: false,
radiusChanger: true,
blockSelector: true,
};

View file

@ -1,4 +1,5 @@
import { CanvasProvider } from "@/context/Canvas";
import { HistoryProvider } from "@/context/History";
import { LoadingProvider } from "@/context/Loading";
import { SelectionProvider } from "@/context/Selection";
import { SettingsProvider } from "@/context/Settings";
@ -13,26 +14,28 @@ import ToolSettings from "@/components/tool-settings";
function AppPage() {
return (
<CanvasProvider>
<LoadingProvider>
<SelectionProvider>
<SettingsProvider>
<LoadingProvider>
<SettingsProvider>
<HistoryProvider>
<CanvasProvider>
<TexturesProvider>
<ToolProvider>
<MobileNotice />
<SelectionProvider>
<MobileNotice />
<main className="overflow-y-hidden h-screen grid grid-rows-[2.5rem_minmax(0,1fr)] grid-cols-[2.5rem_minmax(0,1fr)_auto]">
<Menubar />
<Toolbar />
<Canvas />
<ToolSettings />
</main>
<main className="overflow-y-hidden h-screen grid grid-rows-[2.5rem_minmax(0,1fr)] grid-cols-[2.5rem_minmax(0,1fr)_auto]">
<Menubar />
<Toolbar />
<Canvas />
<ToolSettings />
</main>
</SelectionProvider>
</ToolProvider>
</TexturesProvider>
</SettingsProvider>
</SelectionProvider>
</LoadingProvider>
</CanvasProvider>
</CanvasProvider>
</HistoryProvider>
</SettingsProvider>
</LoadingProvider>
);
}

7
src/types.d.ts vendored
View file

@ -28,6 +28,7 @@ type Tool = "hand" | "move" | "rectangle-select" | "lasso" | "magic-wand" | "pen
interface Settings {
grid: boolean;
canvasBorder: boolean;
historyPanel: boolean;
colorPicker: boolean;
blockReplacer: boolean;
radiusChanger: boolean;
@ -53,3 +54,9 @@ type BlockData = Record<
properties?: Record<string, string>;
}
>;
interface HistoryEntry {
name: string;
apply: () => void;
revert: () => void;
}