From 22049b9159bfe8f01f0814ae789c6da0851d03ec Mon Sep 17 00:00:00 2001 From: Mark Evola Date: Sat, 1 Nov 2025 19:36:32 -0500 Subject: [PATCH] add undo and redo in Edit Menu; move to using 2 dim vectors instead of objects --- src/components/Canvas/Canvas.tsx | 102 ++++--------- src/components/CanvasPane/CanvasPane.tsx | 32 ++-- .../CanvasPointerMarker.tsx | 11 +- src/components/Navbar/Navbar.tsx | 46 +++++- .../ReferenceWindow/ReferenceWindow.tsx | 34 ++--- .../ReferenceWindowHeader.tsx | 35 +++-- src/components/ResizeGrid/ResizeGrid.tsx | 136 ----------------- src/components/ResizeHandle/ResizeHandle.tsx | 143 ------------------ .../ToolbarButton/ToolbarButton.tsx | 8 +- src/lib/utils.ts | 38 +---- src/state/slices/canvasSlice.ts | 74 ++++----- src/state/slices/historySlice.ts | 28 ++-- src/tests/unit/useStore.test.ts | 10 +- src/types/Canvas.types.ts | 13 +- src/types/Slices.types.ts | 2 + 15 files changed, 206 insertions(+), 506 deletions(-) delete mode 100644 src/components/ResizeGrid/ResizeGrid.tsx delete mode 100644 src/components/ResizeHandle/ResizeHandle.tsx diff --git a/src/components/Canvas/Canvas.tsx b/src/components/Canvas/Canvas.tsx index 5730292..790cbf9 100644 --- a/src/components/Canvas/Canvas.tsx +++ b/src/components/Canvas/Canvas.tsx @@ -1,11 +1,5 @@ // Lib -import { - forwardRef, - useEffect, - useImperativeHandle, - useMemo, - useRef -} from "react"; +import { forwardRef, useEffect, useImperativeHandle, useRef } from "react"; import { parseColor } from "react-aria-components"; import { useShallow } from "zustand/react/shallow"; import useStoreSubscription from "@/state/hooks/useStoreSubscription"; @@ -13,10 +7,11 @@ import useStore from "@/state/hooks/useStore"; import useThrottle from "@/state/hooks/useThrottle"; import useCanvasRedrawListener from "@/state/hooks/useCanvasRedrawListener"; import useCanvasRef from "@/state/hooks/useCanvasRef"; -import { redrawCanvas } from "@/lib/utils"; +import { redrawCanvas, updateVector2 } from "@/lib/utils"; import ElementsStore from "@/state/stores/ElementsStore"; import LayersStore from "@/state/stores/LayersStore"; import ImageElementStore from "@/state/stores/ImageElementStore"; +import useStoreContext from "@/state/hooks/useStoreContext"; // Types import type { @@ -24,8 +19,7 @@ import type { MouseEvent as ReactMouseEvent, SetStateAction } from "react"; -import { type Coordinates, CanvasElementPath } from "@/types"; -import useStoreContext from "@/state/hooks/useStoreContext"; +import { CanvasElementPath, Vector } from "@/types"; type CanvasProps = { setLoading: Dispatch>; @@ -81,10 +75,7 @@ const Canvas = forwardRef(function Canvas( const canvasRef = useRef(null); const currentPath2D = useRef(null); const currentPath = useRef([]); - const initialPosition = useRef({ - x: 0, - y: 0 - }); + const initialPosition = useRef>([0, 0]); // Handler for when the mouse is pressed down on the canvas. // This should initiate the drawing process. @@ -114,7 +105,7 @@ const Canvas = forwardRef(function Canvas( const floorY = Math.floor(y); if (!isDrawing.current) { - initialPosition.current = { x: floorX, y: floorY }; + updateVector2(initialPosition.current, floorX, floorY); } const activeLayer = getActiveLayer(); isDrawing.current = !activeLayer.hidden; @@ -123,7 +114,7 @@ const Canvas = forwardRef(function Canvas( currentPath2D.current = new Path2D(); currentPath2D.current.moveTo(floorX, floorY); // Save the current path. - currentPath.current.push({ x: floorX, y: floorY, startingPoint: true }); + currentPath.current.push([floorX, floorY]); } else if (mode === "eye_drop") { // `getPointerPosition` gives us the position in world coordinates, // but we need the position in canvas coordinates for `getImageData`. @@ -175,42 +166,35 @@ const Canvas = forwardRef(function Canvas( switch (mode) { case "brush": case "eraser": { - const lastPoint = currentPath.current[currentPath.current.length - 1]; - const midPointX = lastPoint.x + (floorX - lastPoint.x) / 2; - const midPointY = lastPoint.y + (floorY - lastPoint.y) / 2; - - currentPath2D.current.quadraticCurveTo( - lastPoint.x, - lastPoint.y, - midPointX, - midPointY - ); + const lastPoint = currentPath.current[currentPath.current.length - 1]; + const lastPointX = lastPoint[0]; + const lastPointY = lastPoint[1]; + const midPointX = lastPointX + (floorX - lastPointX) / 2; + const midPointY = lastPointY + (floorY - lastPointY) / 2; + + currentPath2D.current.quadraticCurveTo( + lastPointX, + lastPointY, + midPointX, + midPointY + ); ctx.stroke(currentPath2D.current); - currentPath.current.push({ - x: floorX, - y: floorY, - startingPoint: false - }); + currentPath.current.push([floorX, floorY]); drawPaperCanvas(ctx, 0, 0); break; } case "shapes": { + const initX = initialPosition.current[0]; + const initY = initialPosition.current[1]; + const width = x - initX; + const height = y - initY; if (shape === "circle") { - const width = x - initialPosition.current.x; - const height = y - initialPosition.current.y; - currentPath2D.current.ellipse( - Math.min( - x + Math.abs(width) / 2, - initialPosition.current.x + Math.abs(width) / 2 - ), - Math.min( - y + Math.abs(height) / 2, - initialPosition.current.y + Math.abs(height) / 2 - ), + Math.min(x + Math.abs(width) / 2, initX + Math.abs(width) / 2), + Math.min(y + Math.abs(height) / 2, initY + Math.abs(height) / 2), Math.abs(width) / 2, Math.abs(height) / 2, 0, @@ -218,29 +202,11 @@ const Canvas = forwardRef(function Canvas( Math.PI * 2 ); } else if (shape === "rectangle") { - const width = x - initialPosition.current.x; - const height = y - initialPosition.current.y; - currentPath2D.current.rect( - initialPosition.current.x, - initialPosition.current.y, - width, - height - ); + currentPath2D.current.rect(initX, initY, width, height); } else if (shape === "triangle") { - const width = x - initialPosition.current.x; - const height = y - initialPosition.current.y; - currentPath2D.current.moveTo( - initialPosition.current.x + width / 2, - initialPosition.current.y - ); - currentPath2D.current.lineTo( - initialPosition.current.x, - initialPosition.current.y + height - ); - currentPath2D.current.lineTo( - initialPosition.current.x + width, - initialPosition.current.y + height - ); + currentPath2D.current.moveTo(initX + width / 2, initY); + currentPath2D.current.lineTo(initX, initY + height); + currentPath2D.current.lineTo(initX + width, initY + height); } currentPath2D.current.closePath(); @@ -279,7 +245,7 @@ const Canvas = forwardRef(function Canvas( if (!ctx) throw new Error("Couldn't get the 2D context of the canvas."); const { x, y } = getPointerPosition(canvas, e.clientX, e.clientY); - const { x: initX, y: initY } = initialPosition.current; + const [initX, initY] = initialPosition.current; let elementType; let elementPayload; @@ -306,7 +272,7 @@ const Canvas = forwardRef(function Canvas( const properties = createElement(elementType, elementPayload); - initialPosition.current = { x: 0, y: 0 }; + updateVector2(initialPosition.current, 0, 0); pushHistory({ type: "add_element", @@ -324,9 +290,7 @@ const Canvas = forwardRef(function Canvas( useImperativeHandle(ref, () => canvasRef.current!, []); - const debounceRedraw = useMemo(() => false, []); - - useCanvasRedrawListener(canvasRef, undefined, debounceRedraw); + useCanvasRedrawListener(canvasRef); useEffect(() => { document.addEventListener("mousemove", onMouseMove); diff --git a/src/components/CanvasPane/CanvasPane.tsx b/src/components/CanvasPane/CanvasPane.tsx index faa4ef5..10d5a7a 100644 --- a/src/components/CanvasPane/CanvasPane.tsx +++ b/src/components/CanvasPane/CanvasPane.tsx @@ -3,7 +3,7 @@ import { useRef, useEffect, useState, memo } from "react"; import useStore from "@/state/hooks/useStore"; import useStoreSubscription from "@/state/hooks/useStoreSubscription"; import { useShallow } from "zustand/react/shallow"; -import { redrawCanvas } from "@/lib/utils"; +import { redrawCanvas, updateVector2 } from "@/lib/utils"; // Components import DrawingToolbar from "@/components/DrawingToolbar/DrawingToolbar"; @@ -13,7 +13,7 @@ import ScaleIndicator from "@/components/ScaleIndicator/ScaleIndicator"; // Types import type { ReactNode } from "react"; -import type { Coordinates } from "@/types"; +import type { Vector } from "@/types"; const MemoizedCanvas = memo(Canvas); const MemoizedDrawingToolbar = memo(DrawingToolbar); @@ -45,8 +45,8 @@ function CanvasPane(): ReactNode { ); const currentShape = useStoreSubscription((state) => state.shape); const currentColor = useStoreSubscription((state) => state.color); - const clientPosition = useRef({ x: 0, y: 0 }); - const startMovePosition = useRef({ x: 0, y: 0 }); + const clientPosition = useRef>([0, 0]); + const startMovePosition = useRef>([0, 0]); const canvasRef = useRef(null); const [loading, setLoading] = useState(true); @@ -64,8 +64,8 @@ function CanvasPane(): ReactNode { function handleMouseDown(e: MouseEvent) { if (e.buttons !== 1) return; - clientPosition.current = { x: e.clientX, y: e.clientY }; - startMovePosition.current = { x: e.clientX, y: e.clientY }; + clientPosition.current = [e.clientX, e.clientY]; + startMovePosition.current = [e.clientX, e.clientY]; } function handleMouseMove(e: MouseEvent) { @@ -75,8 +75,10 @@ function CanvasPane(): ReactNode { const layer = getActiveLayer(); if (!canvas || layer.hidden) return; - let dx = e.clientX - clientPosition.current.x; - let dy = e.clientY - clientPosition.current.y; + const initX = clientPosition.current[0]; + const initY = clientPosition.current[1]; + let dx = e.clientX - initX; + let dy = e.clientY - initY; if (isPanning) { // TODO: Have to revisit the calculation to know how the canvas is considered off screen. @@ -103,11 +105,7 @@ function CanvasPane(): ReactNode { if (state.type === "brush" || state.type === "eraser") { return { ...state, - path: state.path.map((point) => ({ - ...point, - x: point.x + dx, - y: point.y + dy - })) + path: state.path.map((point) => [point[0] + dx, point[1] + dy]) }; } else { return { @@ -121,13 +119,15 @@ function CanvasPane(): ReactNode { ); redrawCanvas(); } - clientPosition.current = { x: e.clientX, y: e.clientY }; + updateVector2(clientPosition.current, e.clientX, e.clientY); } function handleMouseUp(e: MouseEvent) { if (isMoving && isClickingOnSpace(e)) { - const dx = e.clientX - startMovePosition.current.x; // total change in x - const dy = e.clientY - startMovePosition.current.y; // total change in y + const initX = startMovePosition.current[0]; + const initY = startMovePosition.current[1]; + const dx = e.clientX - initX; // total change in x + const dy = e.clientY - initY; // total change in y const layer = getActiveLayer(); diff --git a/src/components/CanvasPointerMarker/CanvasPointerMarker.tsx b/src/components/CanvasPointerMarker/CanvasPointerMarker.tsx index 036d875..91a3c0d 100644 --- a/src/components/CanvasPointerMarker/CanvasPointerMarker.tsx +++ b/src/components/CanvasPointerMarker/CanvasPointerMarker.tsx @@ -3,10 +3,9 @@ import { useState, useEffect, useRef } from "react"; import useStore from "@/state/hooks/useStore"; import { useShallow } from "zustand/react/shallow"; - // Types import type { ReactNode, RefObject } from "react"; -import type { Coordinates } from "@/types"; +import type { Vector } from "@/types"; type CanvasPointerMarker = { canvasSpaceReference: RefObject; @@ -22,7 +21,9 @@ function CanvasPointerMarker({ })) ); const ref = useRef(null); - const [position, setPosition] = useState({ x: 0, y: 0 }); + const [position, setPosition] = useState>([0, 0]); + const positionX = position[0]; + const positionY = position[1]; const POINTER_SIZE = strokeWidth * scale; @@ -62,7 +63,7 @@ function CanvasPointerMarker({ newY = computedY; } - setPosition({ x: newX, y: newY }); + setPosition([newX, newY]); } document.addEventListener("mousemove", computeCoordinates); @@ -82,7 +83,7 @@ function CanvasPointerMarker({ borderRadius: "50%", left: -POINTER_SIZE, top: -POINTER_SIZE, - transform: `translate(${position.x}px, ${position.y}px)`, + transform: `translate(${positionX}px, ${positionY}px)`, zIndex: 100, width: POINTER_SIZE, height: POINTER_SIZE, diff --git a/src/components/Navbar/Navbar.tsx b/src/components/Navbar/Navbar.tsx index b568e12..83b106f 100644 --- a/src/components/Navbar/Navbar.tsx +++ b/src/components/Navbar/Navbar.tsx @@ -40,6 +40,8 @@ import { import NavbarFileSaveStatus from "../NavbarFileSaveStatus/NavbarFileSaveStatus"; import ImageElementStore from "@/state/stores/ImageElementStore"; import useStoreContext from "@/state/hooks/useStoreContext"; +import Undo from "../icons/Undo/Undo"; +import Redo from "../icons/Redo/Redo"; function Navbar(): ReactNode { const { @@ -52,7 +54,11 @@ function Navbar(): ReactNode { resetLayersAndElements, createElement, changeDimensions, - clearHistory + clearHistory, + undo, + redo, + canUndo, + canRedo } = useStore( useShallow((state) => ({ prepareForExport: state.prepareForExport, @@ -64,11 +70,15 @@ function Navbar(): ReactNode { resetLayersAndElements: state.resetLayersAndElements, createElement: state.createElement, changeDimensions: state.changeDimensions, - clearHistory: state.clearHistory + clearHistory: state.clearHistory, + undo: state.undo, + redo: state.redo, + canUndo: state.undoStack.length > 0, + canRedo: state.redoStack.length > 0 })) ); const { ref } = useCanvasRef(); - const store = useStoreContext(); + const store = useStoreContext(); const downloadRef = useRef(null); const openFileRef = useRef(null); const [saveStatus, setSaveStatus] = useState<"saving" | "saved" | "error">( @@ -82,6 +92,7 @@ function Navbar(): ReactNode { action: (() => void) | (() => Promise); icon?: (props: ComponentProps<"svg">) => ReactElement; shortcut?: string; + disabled?: boolean; }[]; }; @@ -211,7 +222,7 @@ function Navbar(): ReactNode { await LayersStore.clearStore(); await ElementsStore.clearStore(); await ImageElementStore.clearStore(); - store.persist.clearStorage(); + store.persist.clearStorage(); resetLayersAndElements(); // Reset the Zustand state. // Upload the image. @@ -238,6 +249,16 @@ function Navbar(): ReactNode { } } + function handleUndo() { + undo(); + redrawCanvas(); + } + + function handleRedo() { + redo(); + redrawCanvas(); + } + const menuOptions: MenuOptions = { File: [ { @@ -258,6 +279,22 @@ function Navbar(): ReactNode { icon: Export } ], + Edit: [ + { + text: "Undo", + action: handleUndo, + shortcut: "Z", + icon: Undo, + disabled: !canUndo + }, + { + text: "Redo", + action: handleRedo, + shortcut: "Shift+Z", + icon: Redo, + disabled: !canRedo + } + ], View: [ /** * TODO: This button is a temporary fallback for resetting the canvas for when it goes off screen. The better solution @@ -342,6 +379,7 @@ function Navbar(): ReactNode { {option.icon && ( diff --git a/src/components/ReferenceWindow/ReferenceWindow.tsx b/src/components/ReferenceWindow/ReferenceWindow.tsx index 07448e1..3f2708b 100644 --- a/src/components/ReferenceWindow/ReferenceWindow.tsx +++ b/src/components/ReferenceWindow/ReferenceWindow.tsx @@ -9,7 +9,8 @@ import ReferenceWindowControls from "@/components/ReferenceWindowControls/Refere // Types import type { CSSProperties, ReactNode } from "react"; -import { Coordinates } from "@/types"; +import { Vector } from "@/types"; +import { updateVector2 } from "@/lib/utils"; const MemoizedReferenceWindowHeader = memo(ReferenceWindowHeader); const MemoizedReferenceWindowContent = memo(ReferenceWindowContent); @@ -26,7 +27,8 @@ function ReferenceWindow(): ReactNode { const [scale, setScale] = useState(50); const windowRef = useRef(null); - const [position, setPosition] = useState(() => { + const [position, setPosition] = useState>(() => { + const pos: Vector<2> = [0, 0]; if (typeof window !== "undefined") { const { innerWidth, innerHeight } = window; @@ -35,28 +37,24 @@ function ReferenceWindow(): ReactNode { if (windowRefCurrent) { const { offsetWidth, offsetHeight } = windowRefCurrent; - return { - x: (innerWidth - offsetWidth) / 2, - y: (innerHeight - offsetHeight) / 2 - }; + updateVector2( + pos, + (innerWidth - offsetWidth) / 2, + (innerHeight - offsetHeight) / 2 + ); + } else { + updateVector2(pos, innerWidth / 2, innerHeight / 2); } - - return { - x: innerWidth / 2, - y: innerHeight / 2 - }; } - - return { - x: 0, - y: 0 - }; + return pos; }); + const positionX = position[0]; + const positionY = position[1]; const styles: CSSProperties = !pinned ? { - left: position.x, - top: position.y + left: positionX, + top: positionY } : { left: 0, diff --git a/src/components/ReferenceWindowHeader/ReferenceWindowHeader.tsx b/src/components/ReferenceWindowHeader/ReferenceWindowHeader.tsx index 9581248..801636e 100644 --- a/src/components/ReferenceWindowHeader/ReferenceWindowHeader.tsx +++ b/src/components/ReferenceWindowHeader/ReferenceWindowHeader.tsx @@ -9,14 +9,15 @@ import type { ReactNode, MouseEvent as ReactMouseEvent } from "react"; -import type { Coordinates } from "@/types"; +import type { Vector } from "@/types"; // Icons import Close from "@/components/icons/Close/Close"; +import { updateVector2 } from "@/lib/utils"; type ReferenceWindowHeaderProps = Readonly<{ isPinned: boolean; - setPosition: Dispatch>; + setPosition: Dispatch>>; children: ReactNode; }>; @@ -31,11 +32,12 @@ function ReferenceWindowHeader({ const isDraggingWindow = useRef(false); const headerRef = useRef(null); - const clientPosition = useRef({ x: 0, y: 0 }); + const clientPosition = useRef>([0, 0]); function handleMouseDown(e: ReactMouseEvent) { isDraggingWindow.current = !isPinned; - clientPosition.current = { x: e.clientX, y: e.clientY }; + + updateVector2(clientPosition.current, e.clientX, e.clientY); } useEffect(() => { @@ -49,23 +51,24 @@ function ReferenceWindowHeader({ const x = e.clientX; const y = e.clientY; + const [prevClientX, prevClientY] = clientPosition.current; - const dx = x - clientPosition.current.x; - const dy = y - clientPosition.current.y; + const dx = x - prevClientX; + const dy = y - prevClientY; - setPosition((prev) => ({ - ...prev, - x: Math.min( - Math.max(prev.x + dx, 0), + setPosition((prev) => { + const nextX = Math.min( + Math.max(prev[0] + dx, 0), window.innerWidth - header.offsetWidth - ), - y: Math.min( - Math.max(prev.y + dy, 0), + ); + const nextY = Math.min( + Math.max(prev[1] + dy, 0), window.innerHeight - header.offsetHeight - ) - })); + ); + return [nextX, nextY]; + }); - clientPosition.current = { x, y }; + updateVector2(clientPosition.current, x, y); } document.addEventListener("mousemove", handleMouseMove); diff --git a/src/components/ResizeGrid/ResizeGrid.tsx b/src/components/ResizeGrid/ResizeGrid.tsx deleted file mode 100644 index 22cafb6..0000000 --- a/src/components/ResizeGrid/ResizeGrid.tsx +++ /dev/null @@ -1,136 +0,0 @@ -// Lib -import { useCallback, useState, forwardRef } from "react"; -import cn from "@/lib/tailwind-utils"; - -// Types -import type { CSSProperties, ReactNode } from "react"; -import type { ResizePosition } from "@/types"; - -// Components -import ResizeHandle from "@/components/ResizeHandle/ResizeHandle"; - -type ResizeGridProps = Readonly<{ - children: ReactNode; - x: number; - y: number; - width: number; - height: number; - focused: boolean; - zIndex?: number; - elementId: string; -}>; - -const ResizeGrid = forwardRef( - function ResizeGrid( - { x, y, width, height, focused, zIndex, elementId, children }, - ref - ) { - const [resizing, setResizing] = useState(null); - const OFFSET = 6; - const resizeHandles = []; - const styles: CSSProperties = { - left: !isNaN(x) ? x : "auto", - top: !isNaN(y) ? y : "auto", - width: width, - height: height, - outlineOffset: OFFSET, - zIndex: zIndex ?? 99 - }; - const onResizeStart = (pos?: ResizePosition) => { - if (!pos) throw new Error("Cannot resize without a position."); - setResizing(pos); - }; - - // using useCallback as this function is used in an effect in the ResizeHandle component - const onResizeEnd = useCallback(() => { - setResizing(null); - }, [setResizing]); - - // Create the resize handles. - // Visually, the grid is divided into a 3x3 grid. - for (let i = 0; i < 3; i++) { - for (let j = 0; j < 3; j++) { - if (i === 1 && j === 1) continue; - - let placement: ResizePosition | undefined = undefined; - - switch (i) { - case 0: { - switch (j) { - case 0: - placement = "nw"; - break; - case 1: - placement = "n"; - break; - case 2: - placement = "ne"; - break; - } - break; - } - - case 1: { - switch (j) { - case 0: - placement = "w"; - break; - case 2: - placement = "e"; - break; - } - break; - } - - case 2: { - switch (j) { - case 0: - placement = "sw"; - break; - case 1: - placement = "s"; - break; - case 2: - placement = "se"; - break; - } - break; - } - } - - if (!placement) throw new Error("Invalid placement"); - - resizeHandles.push( - { - onResizeStart(placement); - }} - elementId={elementId} - onResizeEnd={onResizeEnd} - /> - ); - } - } - - return ( -
- {focused && resizeHandles} - {children} -
- ); - } -); - -export default ResizeGrid; diff --git a/src/components/ResizeHandle/ResizeHandle.tsx b/src/components/ResizeHandle/ResizeHandle.tsx deleted file mode 100644 index b0fb6ae..0000000 --- a/src/components/ResizeHandle/ResizeHandle.tsx +++ /dev/null @@ -1,143 +0,0 @@ -// Lib -import { useEffect } from "react"; -import useStore from "@/state/hooks/useStore"; - -// Types -import type { CSSProperties, MouseEvent, ReactNode } from "react"; -import type { ResizePosition } from "@/types"; - -type ResizeHandleProps = Readonly<{ - placement: ResizePosition; - resizeOffset: number; - onResizeStart?: () => void; - onResizeEnd?: () => void; - elementId: string; -}>; - -function ResizeHandle({ - placement, - resizeOffset, - onResizeStart, - onResizeEnd, - elementId -}: ResizeHandleProps): ReactNode { - const changeElementProperties = useStore( - (state) => state.changeElementProperties - ); - const WIDTH = 10; - const HEIGHT = 10; - - const styles: CSSProperties = { - width: `${WIDTH}px`, - height: `${HEIGHT}px`, - cursor: `${placement}-resize` - }; - - const HALF_WIDTH = WIDTH / 2; - const HALF_HEIGHT = HEIGHT / 2; - switch (placement) { - case "nw": { - styles.left = -HALF_WIDTH - resizeOffset + "px"; - styles.top = -HALF_HEIGHT - resizeOffset + "px"; - break; - } - case "n": { - styles.left = "50%"; - styles.transform = "translateX(-50%)"; - styles.top = -HALF_HEIGHT - resizeOffset + "px"; - break; - } - - case "ne": { - styles.right = -HALF_WIDTH - resizeOffset + "px"; - styles.top = -HALF_HEIGHT - resizeOffset + "px"; - break; - } - - case "w": { - styles.left = -HALF_WIDTH - resizeOffset + "px"; - styles.top = "50%"; - styles.transform = "translateY(-50%)"; - break; - } - - case "e": { - styles.right = -HALF_WIDTH - resizeOffset + "px"; - styles.top = "50%"; - styles.transform = "translateY(-50%)"; - break; - } - - case "sw": { - styles.left = -HALF_WIDTH - resizeOffset + "px"; - styles.bottom = -HALF_HEIGHT - resizeOffset + "px"; - break; - } - - case "s": { - styles.left = "50%"; - styles.transform = "translateX(-50%)"; - styles.bottom = -HALF_HEIGHT - resizeOffset + "px"; - break; - } - - case "se": { - styles.right = -HALF_WIDTH - resizeOffset + "px"; - styles.bottom = -HALF_HEIGHT - resizeOffset + "px"; - break; - } - - default: { - break; - } - } - - const onMouseDown = (e: MouseEvent) => { - if (e.buttons !== 1) return; - onResizeStart && onResizeStart(); - }; - - const onBlur = () => { - changeElementProperties( - (state) => ({ - ...state, - focused: false - }), - (element) => element.id === elementId - ); - }; - - // We add this event listener to the document to ensure that the resize ends even if the user releases the mouse outside the resize handle. - useEffect(() => { - const onMouseUp = () => { - onResizeEnd && onResizeEnd(); - }; - - document.addEventListener("mouseup", onMouseUp); - - return () => { - document.removeEventListener("mouseup", onMouseUp); - }; - }, [onResizeEnd]); - - return ( - - - - ); -} - -export default ResizeHandle; diff --git a/src/components/ToolbarButton/ToolbarButton.tsx b/src/components/ToolbarButton/ToolbarButton.tsx index 84dc49f..949283b 100644 --- a/src/components/ToolbarButton/ToolbarButton.tsx +++ b/src/components/ToolbarButton/ToolbarButton.tsx @@ -55,10 +55,10 @@ function ToolbarButton({ redo: state.redo })) ); - const { undoLength, redoLength } = useStore( + const { canUndo, canRedo } = useStore( useShallow((state) => ({ - undoLength: state.undoStack.length, - redoLength: state.redoStack.length + canUndo: state.undoStack.length > 0, + canRedo: state.redoStack.length > 0 })) ); const tooltip = @@ -126,7 +126,7 @@ function ToolbarButton({ data-testid={`tool-${name}`} onClick={performAction} disabled={ - name === "undo" ? !undoLength : name === "redo" ? !redoLength : false + name === "undo" ? !canUndo : name === "redo" ? !canRedo : false } > {ICONS[name]} diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 60e1ac7..c915948 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,5 +1,5 @@ import { v4 as uuidv4 } from "uuid"; -import type { Layer, Coordinates } from "../types"; +import type { Layer, Vector } from "../types"; type CapitalizeOptions = { titleCase: boolean; @@ -83,35 +83,6 @@ function swapElements(arr: T[], from: number, to: number): T[] { }); } -function getCanvasScale(canvas: HTMLCanvasElement) { - const rect = canvas.getBoundingClientRect(); - const scaleX = canvas.width / rect.width; - const scaleY = canvas.height / rect.height; - - return { scaleX, scaleY }; -} - -/** - * Get the position of the given X and Y coordinate relative to the given HTMLCanvasElement. - * @param x The x-coordinate to calculate. - * @param y The y-coordinate to calculate. - * @param canvas The canvas element. - * @returns The an X and Y coordinate relative to the canvas. - */ -function getCanvasPosition( - x: number, - y: number, - canvas: HTMLCanvasElement -): Coordinates { - const rect = canvas.getBoundingClientRect(); - const { scaleX, scaleY } = getCanvasScale(canvas); - - const computedX = (x - rect.left) * scaleX; - const computedY = (y - rect.top) * scaleY; - - return { x: computedX, y: computedY }; -} - /** * Navigate to a new route. This function is merely a wrapper around `window.location.href`, mainly * for convenience and readability. @@ -202,6 +173,11 @@ function getCookie(name: string): string | null { return match ? decodeURIComponent(match[2]) : null; } +function updateVector2(vector: Vector<2>, x: number, y: number) { + vector[0] = x; + vector[1] = y; +} + type OperatingSystem = "Windows" | "MacOS" | "Linux"; /** @@ -236,11 +212,11 @@ export { capitalize, createLayer, swapElements, - getCanvasPosition, navigateTo, isRectIntersecting, debounce, getCookie, + updateVector2, detectOperatingSystem, redrawCanvas }; diff --git a/src/state/slices/canvasSlice.ts b/src/state/slices/canvasSlice.ts index 0af0258..7188c46 100644 --- a/src/state/slices/canvasSlice.ts +++ b/src/state/slices/canvasSlice.ts @@ -172,48 +172,39 @@ export const createCanvasSlice: StateCreator< } function performZoom(clientX: number, clientY: number, factor: number) { - const { position, scale } = get(); + const { + position: [posX, posY], + scale + } = get(); const localX = clientX; const localY = clientY; const newScale = scale + factor * -0.01; - const newX = localX - (localX - position.x) * (newScale / scale); - const newY = localY - (localY - position.y) * (newScale / scale); + const newX = localX - (localX - posX) * (newScale / scale); + const newY = localY - (localY - posY) * (newScale / scale); set({ scale: Math.min(Math.max(newScale, 0.1), 3), - position: { - x: newX, - y: newY - } + position: [newX, newY] }); } function setPosition(payload: Partial) { set((state) => ({ - position: { - x: payload.x ?? state.position.x, - y: payload.y ?? state.position.y - } + position: [payload.x ?? state.position[0], payload.y ?? state.position[1]] })); } function changeX(payload: number) { set((state) => ({ - position: { - x: state.position.x + payload, - y: state.position.y - } + position: [state.position[0] + payload, state.position[1]] })); } function changeY(payload: number) { set((state) => ({ - position: { - x: state.position.x, - y: state.position.y + payload - } + position: [state.position[0], state.position[1] + payload] })); } @@ -232,16 +223,7 @@ export const createCanvasSlice: StateCreator< * layers and elements themselves. */ function prepareForSave(): SavedCanvasProperties { - const { layers, elements, width, height, background } = get(); - - window.localStorage.setItem( - "canvas-properties", - JSON.stringify({ - width, - height, - background - }) - ); + const { layers, elements } = get(); return { layers, elements }; } @@ -340,10 +322,13 @@ export const createCanvasSlice: StateCreator< clientX: number, clientY: number ): Coordinates { - const { position, scale } = get(); + const { + position: [posX, posY], + scale + } = get(); const rect = canvas.getBoundingClientRect(); - const x = (clientX - rect.left - position.x) / scale; - const y = (clientY - rect.top - position.y) / scale; + const x = (clientX - rect.left - posX) / scale; + const y = (clientY - rect.top - posY) / scale; return { x, y }; } @@ -357,7 +342,7 @@ export const createCanvasSlice: StateCreator< top: boolean; } { const { width: canvasWidth, height: canvasHeight, position, scale } = get(); - const { x: posX, y: posY } = position; + const [posX, posY] = position; const rect = canvas.getBoundingClientRect(); @@ -390,10 +375,7 @@ export const createCanvasSlice: StateCreator< const posX = viewportWidth / 2 - canvasWidth / 2; const posY = viewportHeight / 2 - canvasHeight / 2; set({ - position: { - x: posX, - y: posY - } + position: [posX, posY] }); } @@ -454,7 +436,7 @@ export const createCanvasSlice: StateCreator< layers, width: canvasWidth, height: canvasHeight, - position: { x: posX, y: posY }, + position: [posX, posY], scale, opacity, strokeWidth, @@ -539,14 +521,18 @@ export const createCanvasSlice: StateCreator< ctx.beginPath(); for (let i = 0; i < element.path.length; i++) { const point = element.path[i]; - if (point.startingPoint) { - ctx.moveTo(point.x, point.y); + const x = point[0]; + const y = point[1]; + if (i === 0) { + ctx.moveTo(x, y); } else { const lastPoint = element.path[i - 1]; + const lastX = lastPoint[0]; + const lastY = lastPoint[1]; // Add a quadratic curve for smoother lines - const midX = (lastPoint.x + point.x) / 2; - const midY = (lastPoint.y + point.y) / 2; - ctx.quadraticCurveTo(lastPoint.x, lastPoint.y, midX, midY); + const midX = (lastX + x) / 2; + const midY = (lastY + y) / 2; + ctx.quadraticCurveTo(lastX, lastY, midX, midY); } } ctx.stroke(); @@ -655,7 +641,7 @@ export const createCanvasSlice: StateCreator< currentLayer: 0, scale: 1, dpi: 1, - position: { x: 0, y: 0 }, + position: [0, 0], // => [x, y] referenceWindowEnabled: false, changeDimensions, changeColor, diff --git a/src/state/slices/historySlice.ts b/src/state/slices/historySlice.ts index 5b35bb0..47edc23 100644 --- a/src/state/slices/historySlice.ts +++ b/src/state/slices/historySlice.ts @@ -40,11 +40,10 @@ export const createHistorySlice: StateCreator< if (state.type === "brush" || state.type === "eraser") { return { ...state, - path: state.path.map((point) => ({ - ...point, - x: point.x - dx / scale, - y: point.y - dy / scale - })) + path: state.path.map((point) => [ + point[0] - dx / scale, + point[1] - dy / scale + ]) }; } return { @@ -58,11 +57,10 @@ export const createHistorySlice: StateCreator< if (state.type === "brush" || state.type === "eraser") { return { ...state, - path: state.path.map((point) => ({ - ...point, - x: point.x + dx / scale, - y: point.y + dy / scale - })) + path: state.path.map((point) => [ + point[0] + dx / scale, + point[1] + dy / scale + ]) }; } return { @@ -120,6 +118,14 @@ export const createHistorySlice: StateCreator< }); } + function canUndo() { + return get().undoStack.length > 0; + } + + function canRedo() { + return get().redoStack.length > 0; + } + function clearHistory() { set({ undoStack: [], @@ -133,6 +139,8 @@ export const createHistorySlice: StateCreator< pushHistory, undo, redo, + canUndo, + canRedo, clearHistory }; }; diff --git a/src/tests/unit/useStore.test.ts b/src/tests/unit/useStore.test.ts index 72dfaca..466ea96 100644 --- a/src/tests/unit/useStore.test.ts +++ b/src/tests/unit/useStore.test.ts @@ -19,7 +19,7 @@ const exampleStore: SliceStores = { dpi: 1, color: "#000000", scale: 1, - position: { x: 0, y: 0 }, + position: [0, 0], layers: [ { name: "Layer 1", id: expect.any(String), active: true, hidden: false } ], @@ -291,28 +291,28 @@ describe("useStore functionality", () => { act(() => { result.result.current.changeX(5); }); - expect(result.result.current.position.x).toBe(5); + expect(result.result.current.position[0]).toBe(5); }); it("should decrease the X position by 5", () => { act(() => { result.result.current.changeX(-5); }); - expect(result.result.current.position.x).toBe(-5); + expect(result.result.current.position[0]).toBe(-5); }); it("should increase the Y position by 5", () => { act(() => { result.result.current.changeY(5); }); - expect(result.result.current.position.y).toBe(5); + expect(result.result.current.position[1]).toBe(5); }); it("should decrease the Y position by 5", () => { act(() => { result.result.current.changeY(-5); }); - expect(result.result.current.position.y).toBe(-5); + expect(result.result.current.position[1]).toBe(-5); }); it("should return the initial DPI", () => { diff --git a/src/types/Canvas.types.ts b/src/types/Canvas.types.ts index c45b047..ae7d4d9 100644 --- a/src/types/Canvas.types.ts +++ b/src/types/Canvas.types.ts @@ -14,7 +14,7 @@ export type CanvasState = { currentLayer: number; scale: number; dpi: number; - position: Coordinates; + position: Vector<2>; referenceWindowEnabled: boolean; }; @@ -69,10 +69,13 @@ export type FontProperties = { content: string; }; -export type CanvasElementPath = Coordinates & { - // Indicates if the path is the starting point of the element. - startingPoint: boolean; -}; +type BuildTuple< + L extends number, + T extends unknown[] = [] +> = T["length"] extends L ? T : BuildTuple; + +export type Vector = BuildTuple; +export type CanvasElementPath = Vector<2>; export type CanvasElement = { x: number; diff --git a/src/types/Slices.types.ts b/src/types/Slices.types.ts index b475137..5784758 100644 --- a/src/types/Slices.types.ts +++ b/src/types/Slices.types.ts @@ -100,6 +100,8 @@ export type HistoryStore = { pushHistory: (action: HistoryAction) => void; undo: () => void; redo: () => void; + canUndo: () => boolean; + canRedo: () => boolean; clearHistory: () => void; };