Skip to content

Commit

Permalink
Merge pull request #34 from low-earth-orbit/history
Browse files Browse the repository at this point in the history
Implement canvas history undo & redo
  • Loading branch information
low-earth-orbit authored Sep 1, 2024
2 parents f8d54ef + ac48b25 commit dca4a07
Show file tree
Hide file tree
Showing 10 changed files with 330 additions and 79 deletions.
4 changes: 0 additions & 4 deletions .vscode/settings.json

This file was deleted.

7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,13 @@ Demo: [Click Me](https://whiteboard.leohong.dev)

## Tech

- React
- TypeScript
- React
- Next.js
- Redux
- Tailwind CSS
- MUI/Material UI (React component library)
- Konva (Canvas graphics library)
- MUI (Material UI)
- Konva.js

## Run

Expand Down
10 changes: 9 additions & 1 deletion app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
"use client";

import Canvas from "@/components/Canvas";
import store from "@/redux/store";
import { Provider } from "react-redux";

export default function Home() {
return <Canvas />;
return (
<Provider store={store}>
<Canvas />
</Provider>
);
}
159 changes: 88 additions & 71 deletions components/Canvas.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,23 @@
"use client";

import React, { useEffect, useRef, useState } from "react";
import Toolbar from "./Toolbar";
import LinesLayer from "./lines/LinesLayer";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { v4 as uuid } from "uuid";
import { Stage } from "react-konva";
import Toolbar from "./Toolbar";
import LinesLayer from "./lines/LinesLayer";
import ShapesLayer from "./shapes/ShapesLayer";
import ConfirmationDialog from "./ConfirmationDialog";
import TextFieldsLayer from "./textFields/TextFieldsLayer";
import { RootState } from "../redux/store";
import {
addCanvasObject,
updateCanvasObject,
deleteCanvasObject,
selectCanvasObject,
resetCanvas,
undo,
redo,
setCanvasObjects,
} from "../redux/canvasSlice";

export interface CanvasObjectType {
id: string;
Expand Down Expand Up @@ -39,29 +49,48 @@ export type ToolType = "eraser" | "pen";

export type ShapeName = "rectangle" | "line" | "ellipse";

const isBrowser = typeof window !== 'undefined';
const isBrowser = typeof window !== "undefined";

export default function Canvas() {
const [stageSize, setStageSize] = useState<StageSizeType>();
const dispatch = useDispatch();
const { canvasObjects, selectedObjectId } = useSelector(
(state: RootState) => state.canvas
);

const [stageSize, setStageSize] = useState<StageSizeType>();
const [tool, setTool] = useState<ToolType>("pen");
const [color, setColor] = useState<string>("#0000FF");
const [width, setWidth] = useState<number>(5);

const isFreeDrawing = useRef<boolean>(false);
const [open, setOpen] = useState(false); // confirmation modal for delete button - clear canvas

const [canvasObjects, setCanvasObjects] = useState<CanvasObjectType[]>(() => {
if (isBrowser) {
const savedState = localStorage.getItem("canvasState");
return savedState ? JSON.parse(savedState) : [];
// load from local storage
useEffect(() => {
const savedState = localStorage.getItem("canvasState");
if (savedState) {
const canvasObjects = JSON.parse(savedState);
dispatch(setCanvasObjects(canvasObjects));
}
return [];
});
}, [dispatch]);

const [selectedObjectId, setSelectedObjectId] = useState<string>("");
// Save state to local storage whenever it changes
useEffect(() => {
localStorage.setItem("canvasState", JSON.stringify(canvasObjects));
}, [canvasObjects]);

const handleDelete = useCallback(() => {
if (selectedObjectId === "") {
if (canvasObjects.length > 0) {
setOpen(true);
}
} else {
dispatch(deleteCanvasObject(selectedObjectId));
}
}, [dispatch, selectedObjectId, canvasObjects]);

// confirmation modal for delete button - clear canvas
const [open, setOpen] = useState(false);
const resetCanvasState = useCallback(() => {
dispatch(resetCanvas());
}, [dispatch]);

// update browser window size
useEffect(() => {
Expand All @@ -81,28 +110,29 @@ export default function Canvas() {
};
}, []);

// store to local storage so changes are not lost after refreshing the page
useEffect(() => {
// Save state to local storage whenever it changes
localStorage.setItem("canvasState", JSON.stringify(canvasObjects));
}, [canvasObjects]);

// keyboard shortcut
// keyboard shortcuts
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Delete" || event.key === "Backspace") {
handleDelete();
} else if (
(event.ctrlKey && event.key === "z") || // Ctrl+Z for Windows/Linux
(event.metaKey && event.key === "z" && !event.shiftKey) // Cmd+Z for macOS
) {
dispatch(undo());
} else if (
(event.ctrlKey && event.key === "y") || // Ctrl+Y for Windows/Linux
(event.metaKey && event.shiftKey && event.key === "z") // Cmd+Shift+Z for macOS
) {
dispatch(redo());
}
};

// Add event listener for keydown
document.addEventListener("keydown", handleKeyDown);

// Clean up event listener on component unmount
return () => {
document.removeEventListener("keydown", handleKeyDown);
};
}, [selectedObjectId, canvasObjects, handleDelete]);
}, [handleDelete, dispatch]);

function updateStyle(property: keyof CanvasObjectType, value: any) {
// Dynamically update state
Expand All @@ -114,12 +144,11 @@ export default function Canvas() {

// Update object property
if (selectedObjectId !== "") {
setCanvasObjects((prevObjects) =>
prevObjects.map((object) =>
object.id === selectedObjectId
? { ...object, [property]: value } // Update the selected property
: object
)
dispatch(
updateCanvasObject({
id: selectedObjectId,
updates: { [property]: value },
})
);
}
}
Expand All @@ -128,13 +157,7 @@ export default function Canvas() {
newAttrs: Partial<CanvasObjectType>,
selectedObjectId: string
) {
setCanvasObjects((prevObjects) =>
prevObjects.map((object: CanvasObjectType) =>
object.id === selectedObjectId
? { ...object, ...newAttrs } // Merge newAttrs with the existing object
: object
)
);
dispatch(updateCanvasObject({ id: selectedObjectId, updates: newAttrs }));
}

const addTextField = () => {
Expand All @@ -152,8 +175,8 @@ export default function Canvas() {
fontSize: 28,
fontFamily: "Arial",
};
setCanvasObjects([...canvasObjects, newObject]);
setSelectedObjectId(newObjectId);
dispatch(addCanvasObject(newObject));
dispatch(selectCanvasObject(newObjectId));
};

const addShape = (shapeName: ShapeName) => {
Expand Down Expand Up @@ -202,28 +225,10 @@ export default function Canvas() {
return;
}

setCanvasObjects([...canvasObjects, newShape]);
setSelectedObjectId(newShapeId);
dispatch(addCanvasObject(newShape));
dispatch(selectCanvasObject(newShapeId));
};

function handleDelete() {
if (selectedObjectId === "") {
if (canvasObjects.length > 0) {
setOpen(true);
}
} else {
setCanvasObjects((prevObjects) =>
prevObjects.filter((obj) => obj.id !== selectedObjectId)
);
setSelectedObjectId("");
}
}

function resetCanvas() {
setCanvasObjects([]);
setSelectedObjectId("");
}

// component has not finished loading the window size
if (!stageSize) {
return null;
Expand All @@ -241,12 +246,12 @@ export default function Canvas() {
stroke: color,
strokeWidth: width,
};
setCanvasObjects([...canvasObjects, newLine]);
dispatch(addCanvasObject(newLine));
} else {
// deselect shapes when clicked on empty area
const clickedOnEmpty = e.target === e.target.getStage();
if (clickedOnEmpty) {
setSelectedObjectId("");
dispatch(selectCanvasObject(""));
}
}
};
Expand All @@ -257,11 +262,19 @@ export default function Canvas() {
}
const stage = e.target.getStage();
const point = stage.getPointerPosition();
let lastObject = canvasObjects[canvasObjects.length - 1];
const lastObject = canvasObjects[canvasObjects.length - 1];

if (lastObject.type === "line") {
lastObject.points = lastObject.points!.concat([point.x, point.y]);
setCanvasObjects(canvasObjects.concat());
// Create a new object that copies the lastObject and updates points
const updatedObject = {
...lastObject,
points: lastObject.points!.concat([point.x, point.y]),
};

// Dispatch the update with the new object
dispatch(
updateCanvasObject({ id: lastObject.id, updates: updatedObject })
);
}
};

Expand Down Expand Up @@ -289,12 +302,16 @@ export default function Canvas() {
stageSize={stageSize}
isFreeDrawing={isFreeDrawing}
selectedObjectId={selectedObjectId}
setSelectedShapeId={setSelectedObjectId}
setSelectedShapeId={(newObjectId) =>
dispatch(selectCanvasObject(newObjectId))
}
/>
<TextFieldsLayer
objects={canvasObjects}
selectedObjectId={selectedObjectId}
setSelectedObjectId={setSelectedObjectId}
setSelectedObjectId={(newObjectId) =>
dispatch(selectCanvasObject(newObjectId))
}
onChange={updateSelectedObject}
/>
</Stage>
Expand All @@ -312,7 +329,7 @@ export default function Canvas() {
<ConfirmationDialog
open={open}
onClose={() => setOpen(false)}
onConfirm={resetCanvas}
onConfirm={resetCanvasState}
title="Clear Canvas"
description="Are you sure you want to clear the canvas? This action cannot be undone."
/>
Expand Down
30 changes: 30 additions & 0 deletions components/Toolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,13 @@ import CircleOutlinedIcon from "@mui/icons-material/CircleOutlined";
import HorizontalRuleRoundedIcon from "@mui/icons-material/HorizontalRuleRounded";
import ShapeLineOutlinedIcon from "@mui/icons-material/ShapeLineOutlined";
import TextFieldsIcon from "@mui/icons-material/TextFields";
import UndoRoundedIcon from "@mui/icons-material/UndoRounded";
import RedoRoundedIcon from "@mui/icons-material/RedoRounded";

import { CanvasObjectType, ShapeName, ToolType } from "./Canvas";
import { useDispatch, useSelector } from "react-redux";
import { redo, undo } from "@/redux/canvasSlice";
import { RootState } from "@/redux/store";

function LineWeightSliderValueLabel(props: SliderValueLabelProps) {
const { children, value } = props;
Expand Down Expand Up @@ -54,6 +60,12 @@ function Toolbar({
handleAddShape,
handleAddTextField,
}: ToolbarProps) {
const dispatch = useDispatch();

const { undoStack, redoStack } = useSelector(
(state: RootState) => state.canvas
);

// color picker
const [colorPickerAnchorEl, setColorPickerAnchorEl] =
useState<HTMLButtonElement | null>(null);
Expand Down Expand Up @@ -152,6 +164,24 @@ function Toolbar({
<EraserIcon />
</IconButton>

{/* undo */}
<IconButton
aria-label="undo"
disabled={undoStack.length === 0}
onClick={() => dispatch(undo())}
>
<UndoRoundedIcon />
</IconButton>

{/* redo */}
<IconButton
aria-label="redo"
disabled={redoStack.length === 0}
onClick={() => dispatch(redo())}
>
<RedoRoundedIcon />
</IconButton>

{/* delete */}
<IconButton
aria-label="delete"
Expand Down
1 change: 1 addition & 0 deletions components/textFields/TextField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ export default function TextField({
document.body.appendChild(textarea);

// adjust the styles to match
textarea.id = `textarea-${node.id()}`;
textarea.value = node.text();
textarea.style.position = "absolute";
textarea.style.top = `${areaPosition.y}px`;
Expand Down
Loading

0 comments on commit dca4a07

Please sign in to comment.