feat: history/undo & redo
This commit is contained in:
parent
f02977b096
commit
ac9ac3d454
9 changed files with 237 additions and 29 deletions
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
30
src/components/tool-settings/History.tsx
Normal file
30
src/components/tool-settings/History.tsx
Normal 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;
|
||||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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
86
src/context/History.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
7
src/types.d.ts
vendored
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue