diff --git a/src/components/canvas/Canvas.tsx b/src/components/canvas/Canvas.tsx index 92ee41d..10856c2 100644 --- a/src/components/canvas/Canvas.tsx +++ b/src/components/canvas/Canvas.tsx @@ -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(); const [cssCursor, setCssCursor] = useState("crosshair"); + const startBlocksRef = useRef([]); + const startSelectionCoordsRef = useRef([]); + 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); }; diff --git a/src/components/menubar/ViewMenu.tsx b/src/components/menubar/ViewMenu.tsx index 1d768a5..abb19ce 100644 --- a/src/components/menubar/ViewMenu.tsx +++ b/src/components/menubar/ViewMenu.tsx @@ -31,6 +31,9 @@ function ViewMenu() { + + History Panel + Color Picker diff --git a/src/components/tool-settings/History.tsx b/src/components/tool-settings/History.tsx new file mode 100644 index 0000000..b75e91e --- /dev/null +++ b/src/components/tool-settings/History.tsx @@ -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 ( + +
+ {history.map(({ name }, index) => ( + + ))} +
+
+ ); +} + +export default History; diff --git a/src/components/tool-settings/index.tsx b/src/components/tool-settings/index.tsx index f0b07ce..f881076 100644 --- a/src/components/tool-settings/index.tsx +++ b/src/components/tool-settings/index.tsx @@ -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() { + {settings.historyPanel && ( + <> + {/* History */} + + + + )} + {settings.colorPicker && ( <> diff --git a/src/context/Canvas.tsx b/src/context/Canvas.tsx index 7917fd5..b1ab276 100644 --- a/src/context/Canvas.tsx +++ b/src/context/Canvas.tsx @@ -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({} as Context); export const CanvasProvider = ({ children }: Props) => { + const { addHistory } = useContext(HistoryContext); + const [stageSize, setStageSize] = useState({ width: 0, height: 0 }); const [blocks, setBlocks] = useState(welcomeBlocksData); const [coords, setCoords] = useState({ 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 ( {children} diff --git a/src/context/History.tsx b/src/context/History.tsx new file mode 100644 index 0000000..643fadf --- /dev/null +++ b/src/context/History.tsx @@ -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({} as Context); + +export const HistoryProvider = ({ children }: Props) => { + const [history, setHistory] = useState([]); + 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 ( + + {children} + + ); +}; diff --git a/src/context/Settings.tsx b/src/context/Settings.tsx index 42de7a2..e901cfb 100644 --- a/src/context/Settings.tsx +++ b/src/context/Settings.tsx @@ -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, }; diff --git a/src/pages/AppPage.tsx b/src/pages/AppPage.tsx index cb8a0f8..4b2c119 100644 --- a/src/pages/AppPage.tsx +++ b/src/pages/AppPage.tsx @@ -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 ( - - - - + + + + - + + -
- - - - -
+
+ + + + +
+
-
-
-
-
+ + + + ); } diff --git a/src/types.d.ts b/src/types.d.ts index 11b955a..f3aace4 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -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; } >; + +interface HistoryEntry { + name: string; + apply: () => void; + revert: () => void; +}