From 9510adb2edaf5c7296d48b5071830e9e8171994a Mon Sep 17 00:00:00 2001 From: Mark Evola Date: Fri, 29 Aug 2025 20:26:16 -0500 Subject: [PATCH 01/17] basic paper canvas --- src/components/Canvas/Canvas.tsx | 13 +++------ src/components/CanvasPane/CanvasPane.tsx | 32 ++++++++++++---------- src/components/Main/Main.tsx | 10 +++++-- src/state/hooks/useCanvasRedrawListener.ts | 12 +++++++- src/state/slices/canvasSlice.ts | 26 +++++++++++++++++- 5 files changed, 65 insertions(+), 28 deletions(-) diff --git a/src/components/Canvas/Canvas.tsx b/src/components/Canvas/Canvas.tsx index eba3e4c..dc4388b 100644 --- a/src/components/Canvas/Canvas.tsx +++ b/src/components/Canvas/Canvas.tsx @@ -319,16 +319,11 @@ const Canvas = forwardRef(function Canvas( return ( ; + +function CanvasPane({ loading }: CanvasPaneProps): ReactNode { const { mode, scale, @@ -287,20 +291,18 @@ function CanvasPane(): ReactNode { )} -
- -
- - + + + {!loading ? ( + + ) : ( +
+ Loading... +
+ )} ); } diff --git a/src/components/Main/Main.tsx b/src/components/Main/Main.tsx index 303f0ef..0bf8224 100644 --- a/src/components/Main/Main.tsx +++ b/src/components/Main/Main.tsx @@ -1,7 +1,7 @@ // Lib import ElementsStore from "@/state/stores/ElementsStore"; import LayersStore from "@/state/stores/LayersStore"; -import { useEffect } from "react"; +import { useEffect, useRef, useState } from "react"; import useStore from "@/state/hooks/useStore"; import useStoreEffect from "@/state/hooks/useStoreEffect"; @@ -22,6 +22,8 @@ function Main(): ReactNode { const refereceWindowEnabled = useStore( (store) => store.referenceWindowEnabled ); + const [loading, setLoading] = useState(true); + const firstRender = useRef(true); useEffect(() => { async function updateLayersAndElements() { @@ -71,6 +73,10 @@ function Main(): ReactNode { } }) ); + if (firstRender.current) { + firstRender.current = false; + setLoading(false); + } } ); @@ -82,7 +88,7 @@ function Main(): ReactNode { > - + {/* Reference window */} {refereceWindowEnabled && } diff --git a/src/state/hooks/useCanvasRedrawListener.ts b/src/state/hooks/useCanvasRedrawListener.ts index 32b5d98..ac17da5 100644 --- a/src/state/hooks/useCanvasRedrawListener.ts +++ b/src/state/hooks/useCanvasRedrawListener.ts @@ -57,10 +57,20 @@ function useCanvasRedrawListener( ); } + function onResize() { + const e = new CustomEvent("canvas:redraw", { + detail: { noChange: true } + }); + handleCanvasRedrawDebounced(e); + } + document.addEventListener("canvas:redraw", handleCanvasRedraw); + window.addEventListener("resize", onResize); - return () => + return () => { document.removeEventListener("canvas:redraw", handleCanvasRedraw); + window.removeEventListener("resize", onResize); + }; }, [handleCanvasRedraw, debounce, handleCanvasRedrawDebounced]); } diff --git a/src/state/slices/canvasSlice.ts b/src/state/slices/canvasSlice.ts index 50912e9..35bdbc7 100644 --- a/src/state/slices/canvasSlice.ts +++ b/src/state/slices/canvasSlice.ts @@ -355,10 +355,33 @@ export const createCanvasSlice: StateCreator< return aPosition - bPosition; }); + const rect = canvas.getBoundingClientRect(); + + canvas.width = rect.width; + canvas.height = rect.height; + + console.log("drawing..."); + const canvasX = 0; const canvasY = 0; ctx.clearRect(canvasX, canvasY, canvas.width, canvas.height); + // Draw the container. + // First, skew the origin to the center of the canvas. + ctx.translate(canvas.width / 2, canvas.height / 2); + + // Then draw the actual canvas. + ctx.beginPath(); + ctx.rect(-canvasWidth / 2, -canvasHeight / 2, canvasWidth, canvasHeight); + ctx.fillStyle = background; + ctx.fill(); + + // Clip to the canvas area so that drawings outside the canvas are not visible. + ctx.clip(); + + // Finally, reset the origin back to the top-left corner. + ctx.translate(-rect.width / 2, -rect.height / 2); + const isPreviewCanvas = canvas.width < canvasWidth * dpi; if (isPreviewCanvas) { @@ -450,13 +473,14 @@ export const createCanvasSlice: StateCreator< if (isPreviewCanvas) { ctx.restore(); // Restore the previous transform state. } + // Finally, draw the background behind all elements. ctx.fillStyle = background; // 'destination-over' changes the way the background is drawn // by drawing behind existing content. ctx.globalCompositeOperation = "destination-over"; - ctx.fillRect(canvasX, canvasY, canvas.width, canvas.height); + // ctx.fillRect(canvasX, canvasY, canvas.width, canvas.height); } return { From e4d6c95295583aa6481ffbe0ed5d6fa7af324341 Mon Sep 17 00:00:00 2001 From: Mark Evola Date: Fri, 5 Sep 2025 01:21:19 -0500 Subject: [PATCH 02/17] dont stroke paths in loop --- src/components/Canvas/Canvas.tsx | 6 -- src/components/CanvasPane/CanvasPane.tsx | 62 ++++++++----------- src/components/LayerInfo/LayerInfo.tsx | 2 +- src/components/Main/Main.tsx | 13 ++-- .../ToolbarButton/ToolbarButton.tsx | 2 - src/state/hooks/useCanvasRedrawListener.ts | 14 ++++- src/state/hooks/useStoreSubscription.ts | 11 ++-- src/state/slices/canvasSlice.ts | 45 ++++++++++---- src/state/stores/ElementsStore.ts | 8 +-- src/types/Slices.types.ts | 1 + 10 files changed, 82 insertions(+), 82 deletions(-) diff --git a/src/components/Canvas/Canvas.tsx b/src/components/Canvas/Canvas.tsx index dc4388b..d23be4e 100644 --- a/src/components/Canvas/Canvas.tsx +++ b/src/components/Canvas/Canvas.tsx @@ -314,21 +314,15 @@ const Canvas = forwardRef(function Canvas( return () => document.removeEventListener("mousemove", onMouseMove); }, [onMouseMove]); - const transform = `translate(${position.x}px, ${position.y}px) scale(${scale})`; - return ( ); }); diff --git a/src/components/CanvasPane/CanvasPane.tsx b/src/components/CanvasPane/CanvasPane.tsx index 6007e67..eac3fad 100644 --- a/src/components/CanvasPane/CanvasPane.tsx +++ b/src/components/CanvasPane/CanvasPane.tsx @@ -28,11 +28,10 @@ function CanvasPane({ loading }: CanvasPaneProps): ReactNode { scale, changeX, changeY, - increaseScale, - decreaseScale, changeElementProperties, createElement, getActiveLayer, + performZoom, pushHistory } = useStore( useShallow((state) => ({ @@ -40,11 +39,10 @@ function CanvasPane({ loading }: CanvasPaneProps): ReactNode { scale: state.scale, changeX: state.changeX, changeY: state.changeY, - increaseScale: state.increaseScale, - decreaseScale: state.decreaseScale, changeElementProperties: state.changeElementProperties, createElement: state.createElement, getActiveLayer: state.getActiveLayer, + performZoom: state.performZoom, pushHistory: state.pushHistory })) ); @@ -64,14 +62,14 @@ function CanvasPane({ loading }: CanvasPaneProps): ReactNode { // Effect is getting ugly... Might be a good idea to split // this into multiple effects. useEffect(() => { - const canvasSpace = canvasSpaceRef.current; + const canvasSpace = canvasRef.current; if (!canvasSpace) return; const isClickingOnSpace = (e: MouseEvent) => e.target === canvasSpace || canvasSpace.contains(e.target as Node); function handleMouseDown(e: MouseEvent) { - if (e.buttons !== 1 || !canvasSpace) return; + if (e.buttons !== 1) return; clientPosition.current = { x: e.clientX, y: e.clientY }; startMovePosition.current = { x: e.clientX, y: e.clientY }; @@ -108,7 +106,7 @@ function CanvasPane({ loading }: CanvasPaneProps): ReactNode { } function handleMouseMove(e: MouseEvent) { - if (e.buttons !== 1 || !isGrabbing || !canvasSpace) return; + if (e.buttons !== 1 || !isGrabbing) return; const canvas = canvasRef.current; const layer = getActiveLayer(); @@ -146,9 +144,15 @@ function CanvasPane({ loading }: CanvasPaneProps): ReactNode { // Apply the changes. changeX(dx); changeY(dy); + document.dispatchEvent(new CustomEvent("canvas:redraw")); } else if (isMoving) { // Move the shapes for the current layer. + // divide the dx and dy by the scale to get the correct + // 'mouse feel' like movement. + dx /= scale; + dy /= scale; + changeElementProperties( (state) => { if (state.type === "brush" || state.type === "eraser") { @@ -196,47 +200,32 @@ function CanvasPane({ loading }: CanvasPaneProps): ReactNode { function handleKeyDown(e: KeyboardEvent) { setShiftKey(e.shiftKey); setCtrlKey(e.ctrlKey); - - if (e.key === "+") { - e.preventDefault(); - increaseScale(); - } else if (e.key === "_") { - e.preventDefault(); - decreaseScale(); - } - - if (e.type === "keyup") { - return; - } } function handleZoom(e: Event) { if (!canvasSpace) return; if (e instanceof WheelEvent) { - if (!e.shiftKey) return; - - if (e.deltaY > 0) { - decreaseScale(); - } else { - increaseScale(); - } + console.log(e.deltaY); + performZoom(e.clientX, e.clientY, e.deltaY / 10); // Handle the click event } else if (e instanceof MouseEvent) { if (!isClickingOnSpace(e)) return; // Shift key means we are moving. We don't want to zoom in this case. - if (e.buttons === 0 && !e.shiftKey) { + if ( + e.buttons === 0 && + !e.shiftKey && + (mode === "zoom_in" || mode === "zoom_out") + ) { // Left click - if (mode === "zoom_in") { - increaseScale(); - } else if (mode === "zoom_out") { - decreaseScale(); - } else { - return; // We don't want to zoom if the mode is not zoom - } + let factor = 25; + if (mode === "zoom_in") factor *= -1; + performZoom(e.clientX, e.clientY, factor); } } + + document.dispatchEvent(new CustomEvent("canvas:redraw")); } document.addEventListener("mousedown", handleMouseDown); @@ -266,8 +255,6 @@ function CanvasPane({ loading }: CanvasPaneProps): ReactNode { isGrabbing, changeElementProperties, createElement, - increaseScale, - decreaseScale, changeX, changeY, getActiveLayer, @@ -275,7 +262,8 @@ function CanvasPane({ loading }: CanvasPaneProps): ReactNode { ctrlKey, currentShape, currentColor, - pushHistory + pushHistory, + performZoom ]); return ( diff --git a/src/components/LayerInfo/LayerInfo.tsx b/src/components/LayerInfo/LayerInfo.tsx index f9fbff2..9cfa021 100644 --- a/src/components/LayerInfo/LayerInfo.tsx +++ b/src/components/LayerInfo/LayerInfo.tsx @@ -201,7 +201,7 @@ function LayerInfo({ ) : (
diff --git a/src/components/Main/Main.tsx b/src/components/Main/Main.tsx index 0bf8224..3d6a5ef 100644 --- a/src/components/Main/Main.tsx +++ b/src/components/Main/Main.tsx @@ -45,12 +45,7 @@ function Main(): ReactNode { })) ); } - setElements( - elements.map(([, element]) => ({ - ...element, - focused: false - })) - ); + setElements(elements.map(([, element]) => element)); } updateLayersAndElements(); @@ -74,9 +69,9 @@ function Main(): ReactNode { }) ); if (firstRender.current) { - firstRender.current = false; - setLoading(false); - } + firstRender.current = false; + setLoading(false); + } } ); diff --git a/src/components/ToolbarButton/ToolbarButton.tsx b/src/components/ToolbarButton/ToolbarButton.tsx index 3ce24eb..6f2516e 100644 --- a/src/components/ToolbarButton/ToolbarButton.tsx +++ b/src/components/ToolbarButton/ToolbarButton.tsx @@ -92,8 +92,6 @@ function ToolbarButton({ chosenShortcut += e.key.toLowerCase(); } - console.log(chosenShortcut, shortcut); - if (chosenShortcut === shortcut) { performAction(); } diff --git a/src/state/hooks/useCanvasRedrawListener.ts b/src/state/hooks/useCanvasRedrawListener.ts index ac17da5..7c3a3a2 100644 --- a/src/state/hooks/useCanvasRedrawListener.ts +++ b/src/state/hooks/useCanvasRedrawListener.ts @@ -1,4 +1,4 @@ -import { RefObject, useEffect } from "react"; +import { RefObject, useEffect, useRef } from "react"; import useStore from "./useStore"; import useThrottle from "./useThrottle"; import useDebounceCallback from "./useDebounceCallback"; @@ -23,6 +23,7 @@ function useCanvasRedrawListener( debounce: boolean = false ): void { const drawCanvas = useStore((state) => state.drawCanvas); + const drawInProgress = useRef(false); const handleCanvasRedraw = useThrottle((e: CanvasRedrawEvent) => { const noChange = e.detail?.noChange; @@ -37,8 +38,15 @@ function useCanvasRedrawListener( } const canvas = canvasRef.current; + if (drawInProgress.current) { + return; + } if (canvas) { - drawCanvas(canvas, layerId); + requestAnimationFrame(() => { + drawInProgress.current = true; + drawCanvas(canvas, layerId); + drawInProgress.current = false; + }); } }, 10); @@ -61,7 +69,7 @@ function useCanvasRedrawListener( const e = new CustomEvent("canvas:redraw", { detail: { noChange: true } }); - handleCanvasRedrawDebounced(e); + handleCanvasRedrawDebounced(e); } document.addEventListener("canvas:redraw", handleCanvasRedraw); diff --git a/src/state/hooks/useStoreSubscription.ts b/src/state/hooks/useStoreSubscription.ts index e40dab5..2968071 100644 --- a/src/state/hooks/useStoreSubscription.ts +++ b/src/state/hooks/useStoreSubscription.ts @@ -3,6 +3,7 @@ import { StoreContext } from "../../components/StoreContext/StoreContext"; import type { SliceStores } from "../../types"; import type { RefObject } from "react"; +import useStoreEffect from "./useStoreEffect"; /** * A custom hook that listens for changes to the store and returns the newest state. @@ -24,13 +25,9 @@ function useStoreSubscription( const state = useRef(selector(store.getState())); - useEffect(() => { - const unsubscribe = store.subscribe(selector, (newState) => { - state.current = newState; - }); - - return unsubscribe; - }, [store, selector]); + useStoreEffect(selector, (newState) => { + state.current = newState; + }); return state; } diff --git a/src/state/slices/canvasSlice.ts b/src/state/slices/canvasSlice.ts index 35bdbc7..cec9283 100644 --- a/src/state/slices/canvasSlice.ts +++ b/src/state/slices/canvasSlice.ts @@ -174,6 +174,25 @@ export const createCanvasSlice: StateCreator< })); } + function performZoom(clientX: number, clientY: number, factor: number) { + const { position, 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); + + set({ + scale: Math.min(Math.max(newScale, 0.1), 3), + position: { + x: newX, + y: newY + } + }); + } + function setPosition(payload: Partial) { set((state) => ({ position: { @@ -322,7 +341,9 @@ export const createCanvasSlice: StateCreator< layers, dpi, width: canvasWidth, - height: canvasHeight + height: canvasHeight, + position: { x: posX, y: posY }, + scale } = get(); if (layers.length === 0) { @@ -360,12 +381,17 @@ export const createCanvasSlice: StateCreator< canvas.width = rect.width; canvas.height = rect.height; - console.log("drawing..."); - const canvasX = 0; const canvasY = 0; + + ctx.setTransform(1, 0, 0, 1, 0, 0); // Reset any existing transforms + ctx.clearRect(canvasX, canvasY, canvas.width, canvas.height); + // Apply scaling and translation for panning and zooming. + ctx.setTransform(scale, 0, 0, scale, posX, posY); + + ctx.save(); // Draw the container. // First, skew the origin to the center of the canvas. ctx.translate(canvas.width / 2, canvas.height / 2); @@ -408,15 +434,15 @@ export const createCanvasSlice: StateCreator< switch (element.type) { case "brush": case "eraser": { + ctx.beginPath(); for (const point of element.path) { if (point.startingPoint) { - ctx.beginPath(); ctx.moveTo(point.x, point.y); } else { ctx.lineTo(point.x, point.y); - ctx.stroke(); } } + ctx.stroke(); break; } case "circle": { @@ -474,13 +500,7 @@ export const createCanvasSlice: StateCreator< ctx.restore(); // Restore the previous transform state. } - // Finally, draw the background behind all elements. - ctx.fillStyle = background; - - // 'destination-over' changes the way the background is drawn - // by drawing behind existing content. - ctx.globalCompositeOperation = "destination-over"; - // ctx.fillRect(canvasX, canvasY, canvas.width, canvas.height); + ctx.restore(); } return { @@ -519,6 +539,7 @@ export const createCanvasSlice: StateCreator< getActiveLayer, increaseScale, decreaseScale, + performZoom, setPosition, changeX, changeY, diff --git a/src/state/stores/ElementsStore.ts b/src/state/stores/ElementsStore.ts index 2550f89..24bd6c8 100644 --- a/src/state/stores/ElementsStore.ts +++ b/src/state/stores/ElementsStore.ts @@ -1,8 +1,6 @@ import BaseStore from "./BaseStore"; import { CanvasElement } from "@/types"; -type Element = Omit; - /** * A class for interacting with the Elements store of IndexedDB. */ @@ -15,7 +13,7 @@ export default class ElementsStore extends BaseStore { * @param elements Elements to add to the store. * @returns */ - public static addElements(elements: Element[]) { + public static addElements(elements: CanvasElement[]) { return this.add(elements); } @@ -25,7 +23,7 @@ export default class ElementsStore extends BaseStore { * @returns A singular element or undefined if not found */ public static getElement(id: string) { - return this.get(id); + return this.get(id); } /** @@ -33,7 +31,7 @@ export default class ElementsStore extends BaseStore { * @returns The entries */ public static getElements() { - return this.get(); + return this.get(); } /** diff --git a/src/types/Slices.types.ts b/src/types/Slices.types.ts index df9d70d..79a88a8 100644 --- a/src/types/Slices.types.ts +++ b/src/types/Slices.types.ts @@ -52,6 +52,7 @@ export type CanvasStore = CanvasState & { getActiveLayer: () => Layer; increaseScale: () => void; decreaseScale: () => void; + performZoom: (clientX: number, clientY: number, factor: number) => void; setPosition: (payload: Partial) => void; changeX: (payload: number) => void; changeY: (payload: number) => void; From e206589a2dff06a6b3cb45e2a267171f93646259 Mon Sep 17 00:00:00 2001 From: Mark Evola Date: Sun, 7 Sep 2025 00:08:58 -0500 Subject: [PATCH 03/17] correctly determine pointer location, exporting broken --- src/components/Canvas/Canvas.tsx | 50 +++-- src/components/CanvasPane/CanvasPane.tsx | 32 +--- src/components/LayerPreview/LayerPreview.tsx | 2 +- src/state/hooks/useCanvasRedrawListener.ts | 22 ++- src/state/hooks/useStoreContext.ts | 18 ++ src/state/hooks/useStoreSubscription.ts | 9 +- src/state/slices/canvasSlice.ts | 190 +++++++++++++------ src/types/Slices.types.ts | 23 ++- 8 files changed, 229 insertions(+), 117 deletions(-) create mode 100644 src/state/hooks/useStoreContext.ts diff --git a/src/components/Canvas/Canvas.tsx b/src/components/Canvas/Canvas.tsx index d23be4e..b9fd2ff 100644 --- a/src/components/Canvas/Canvas.tsx +++ b/src/components/Canvas/Canvas.tsx @@ -4,7 +4,6 @@ import { parseColor } from "react-aria-components"; import { useShallow } from "zustand/react/shallow"; import useStoreSubscription from "@/state/hooks/useStoreSubscription"; import useStore from "@/state/hooks/useStore"; -import * as Utils from "@/lib/utils"; // Types import type { MouseEvent as ReactMouseEvent } from "react"; @@ -26,33 +25,30 @@ const Canvas = forwardRef(function Canvas( ) { const { mode, - background, shape, width, height, dpi, - scale, - position, changeMode, changeColor, createElement, getActiveLayer, - pushHistory + pushHistory, + getPointerPosition } = useStore( useShallow((state) => ({ mode: state.mode, - background: state.background, shape: state.shape, width: state.width, height: state.height, dpi: state.dpi, - scale: state.scale, position: state.position, changeMode: state.changeMode, changeColor: state.changeColor, createElement: state.createElement, getActiveLayer: state.getActiveLayer, - pushHistory: state.pushHistory + pushHistory: state.pushHistory, + getPointerPosition: state.getPointerPosition })) ); const color = useStoreSubscription((state) => state.color); @@ -89,27 +85,37 @@ const Canvas = forwardRef(function Canvas( ctx.globalAlpha = opacity.current; + // Clip the drawing to the bounds of the canvas + ctx.save(); + const canvasRect = canvas.getBoundingClientRect(); + ctx.translate(canvasRect.width / 2, canvasRect.height / 2); + ctx.rect(-width / 2, -height / 2, width, height); + ctx.clip(); + ctx.translate(-canvasRect.width / 2, -canvasRect.height / 2); + // Calculate the position of the mouse relative to the canvas. - const { x, y } = Utils.getCanvasPosition(e.clientX, e.clientY, canvas); + const { x, y } = getPointerPosition(canvas, e.clientX, e.clientY); + const floorX = Math.floor(x); + const floorY = Math.floor(y); if (!isDrawing.current) { - initialPosition.current = { x, y }; + initialPosition.current = { x: floorX, y: floorY }; } const activeLayer = getActiveLayer(); isDrawing.current = !activeLayer.hidden; if (mode === "brush" || mode === "eraser") { currentPath2D.current = new Path2D(); - currentPath2D.current.moveTo(x, y); + currentPath2D.current.moveTo(floorX, floorY); // Save the current path. - currentPath.current.push({ x, y, startingPoint: true }); + currentPath.current.push({ x: floorX, y: floorY, startingPoint: true }); } else if (mode === "eye_drop" && !isGrabbing) { // `.getImageData()` retreives the x and y coordinates of the pixel // differently if the canvas is scaled. So, we need to multiply the // x and y coordinates by the DPI to get the correct pixel. const pixel = ctx.getImageData( - Math.floor(x * dpi), - Math.floor(y * dpi), + Math.floor(floorX * dpi), + Math.floor(floorY * dpi), 1, 1 ).data; @@ -158,7 +164,9 @@ const Canvas = forwardRef(function Canvas( if (!ctx) throw new Error("Couldn't get the 2D context of the canvas."); // Calculate the position of the mouse relative to the canvas. - const { x, y } = Utils.getCanvasPosition(e.clientX, e.clientY, activeLayer); + const { x, y } = getPointerPosition(activeLayer, e.clientX, e.clientY); + const floorX = Math.floor(x); + const floorY = Math.floor(y); ctx.globalCompositeOperation = mode === "eraser" ? "destination-out" : "source-over"; @@ -180,10 +188,14 @@ const Canvas = forwardRef(function Canvas( ctx.lineCap = "round"; ctx.lineJoin = "round"; - currentPath2D.current.lineTo(x, y); + currentPath2D.current.lineTo(floorX, floorY); ctx.stroke(currentPath2D.current); - currentPath.current.push({ x, y, startingPoint: false }); + currentPath.current.push({ + x: floorX, + y: floorY, + startingPoint: false + }); break; } @@ -262,7 +274,9 @@ const Canvas = forwardRef(function Canvas( if (!ctx) throw new Error("Couldn't get the 2D context of the canvas."); - const { x, y } = Utils.getCanvasPosition(e.clientX, e.clientY, canvas); + ctx.restore(); + + const { x, y } = getPointerPosition(canvas, e.clientX, e.clientY); const { x: initX, y: initY } = initialPosition.current; let elementType; diff --git a/src/components/CanvasPane/CanvasPane.tsx b/src/components/CanvasPane/CanvasPane.tsx index eac3fad..e5d42b3 100644 --- a/src/components/CanvasPane/CanvasPane.tsx +++ b/src/components/CanvasPane/CanvasPane.tsx @@ -32,7 +32,8 @@ function CanvasPane({ loading }: CanvasPaneProps): ReactNode { createElement, getActiveLayer, performZoom, - pushHistory + pushHistory, + isCanvasOffscreen } = useStore( useShallow((state) => ({ mode: state.mode, @@ -43,7 +44,8 @@ function CanvasPane({ loading }: CanvasPaneProps): ReactNode { createElement: state.createElement, getActiveLayer: state.getActiveLayer, performZoom: state.performZoom, - pushHistory: state.pushHistory + pushHistory: state.pushHistory, + isCanvasOffscreen: state.isCanvasOffscreen })) ); const currentShape = useStoreSubscription((state) => state.shape); @@ -115,31 +117,11 @@ function CanvasPane({ loading }: CanvasPaneProps): ReactNode { let dx = e.clientX - clientPosition.current.x; let dy = e.clientY - clientPosition.current.y; - const { - left: sLeft, - width: sWidth, - height: sHeight, - top: sTop - } = canvasSpace.getBoundingClientRect(); - if (isPanning && isGrabbing) { - const { - left: cLeft, - top: cTop, - width: cWidth, - height: cHeight - } = canvas.getBoundingClientRect(); - - // Check if the layer is outside the canvas space. - // If it is, we don't want to move it. - // Note: We add 20 so that we can still see the layer when it's almost outside the canvas space. - if (cLeft + dx <= -cWidth + sLeft + 20 || cLeft + dx >= sWidth + 20) { - dx = 0; // Set to 0 so that the layer doesn't move. - } + const { left, top } = isCanvasOffscreen(canvas, dx, dy); - if (cTop + dy <= -cHeight + sTop + 20 || cTop + dy >= sHeight + 20) { - dy = 0; // Set to 0 so that the layer doesn't move. - } + if (left) dx = 0; + if (top) dy = 0; // Apply the changes. changeX(dx); diff --git a/src/components/LayerPreview/LayerPreview.tsx b/src/components/LayerPreview/LayerPreview.tsx index 8faeae4..afa6ae8 100644 --- a/src/components/LayerPreview/LayerPreview.tsx +++ b/src/components/LayerPreview/LayerPreview.tsx @@ -15,7 +15,7 @@ const DEBOUNCE_REDRAW = true; function LayerPreview({ id }: LayerPreviewProps): ReactNode { const previewRef = useRef(null); - useCanvasRedrawListener(previewRef, id, DEBOUNCE_REDRAW); + // useCanvasRedrawListener(previewRef, id, DEBOUNCE_REDRAW); return ( state.drawCanvas); - const drawInProgress = useRef(false); + + const draw = useCallback( + (canvas: HTMLCanvasElement, layerId?: string) => { + drawCanvas(canvas, { layerId }); + // requestAnimationFrame(() => { + // draw(canvas, layerId); + // }); + }, + [drawCanvas] + ); const handleCanvasRedraw = useThrottle((e: CanvasRedrawEvent) => { const noChange = e.detail?.noChange; @@ -38,15 +47,8 @@ function useCanvasRedrawListener( } const canvas = canvasRef.current; - if (drawInProgress.current) { - return; - } if (canvas) { - requestAnimationFrame(() => { - drawInProgress.current = true; - drawCanvas(canvas, layerId); - drawInProgress.current = false; - }); + draw(canvas, layerId); } }, 10); diff --git a/src/state/hooks/useStoreContext.ts b/src/state/hooks/useStoreContext.ts new file mode 100644 index 0000000..f8e5cd5 --- /dev/null +++ b/src/state/hooks/useStoreContext.ts @@ -0,0 +1,18 @@ +import { StoreContext } from "@/components/StoreContext/StoreContext"; +import { useContext } from "react"; + +/** + * Retrieves the Zustand store from the StoreContext. + * @returns The Zustand store. + */ +function useStoreContext() { + const context = useContext(StoreContext); + + if (!context) { + throw new Error("useStoreContext must be used within a StoreProvider."); + } + + return context; +} + +export default useStoreContext; diff --git a/src/state/hooks/useStoreSubscription.ts b/src/state/hooks/useStoreSubscription.ts index 2968071..6764544 100644 --- a/src/state/hooks/useStoreSubscription.ts +++ b/src/state/hooks/useStoreSubscription.ts @@ -4,6 +4,7 @@ import { StoreContext } from "../../components/StoreContext/StoreContext"; import type { SliceStores } from "../../types"; import type { RefObject } from "react"; import useStoreEffect from "./useStoreEffect"; +import useStoreContext from "./useStoreContext"; /** * A custom hook that listens for changes to the store and returns the newest state. @@ -15,13 +16,7 @@ import useStoreEffect from "./useStoreEffect"; function useStoreSubscription( selector: (state: SliceStores) => T ): RefObject { - const store = useContext(StoreContext); - - if (!store) { - throw new Error( - "useStoreSubscription must be used within a StoreProvider." - ); - } + const store = useStoreContext(); const state = useRef(selector(store.getState())); diff --git a/src/state/slices/canvasSlice.ts b/src/state/slices/canvasSlice.ts index cec9283..e34e3c5 100644 --- a/src/state/slices/canvasSlice.ts +++ b/src/state/slices/canvasSlice.ts @@ -8,7 +8,8 @@ import type { CanvasStore, SavedCanvasProperties, Shape, - SliceStores + SliceStores, + DrawOptions } from "@/types"; import * as Utils from "@/lib/utils"; @@ -290,10 +291,8 @@ export const createCanvasSlice: StateCreator< } function prepareForExport(quality: number = 1): Promise { - const accountForDPI = true; // Consider DPI for better quality exports - const substituteCanvas = document.createElement("canvas"); - const { width, height, dpi } = get(); + const { width, height } = get(); substituteCanvas.width = width; substituteCanvas.height = height; @@ -305,13 +304,7 @@ export const createCanvasSlice: StateCreator< ); } - if (accountForDPI) { - substituteCanvas.width *= dpi; - substituteCanvas.height *= dpi; - ctx.scale(dpi, dpi); - } - - drawCanvas(substituteCanvas); + drawCanvas(substituteCanvas, { export: true }); return new Promise((resolve) => { requestAnimationFrame(() => { @@ -334,12 +327,94 @@ export const createCanvasSlice: StateCreator< })); } - function drawCanvas(canvas: HTMLCanvasElement, layerId?: string) { + function getPointerPosition( + canvas: HTMLCanvasElement, + clientX: number, + clientY: number + ): Coordinates { + const { position, scale } = get(); + const rect = canvas.getBoundingClientRect(); + const x = (clientX - rect.left - position.x) / scale; + const y = (clientY - rect.top - position.y) / scale; + + return { x, y }; + } + + function isCanvasOffscreen( + canvas: HTMLCanvasElement, + dx: number, + dy: number + ): { + left: boolean; + top: boolean; + } { + const { width: canvasWidth, height: canvasHeight, position, scale } = get(); + const { x: posX, y: posY } = position; + + const rect = canvas.getBoundingClientRect(); + + const canvasLeft = posX + dx; + const canvasRight = posX + dx + canvasWidth * scale; + const canvasTop = posY + dy; + const canvasBottom = posY + dy + canvasHeight * scale; + const viewportLeft = 0; + const viewportRight = rect.width; + const viewportTop = 0; + const viewportBottom = rect.height; + + return { + left: + canvasRight < viewportLeft || + canvasLeft + rect.width / 4 > viewportRight / scale, + top: + canvasBottom < viewportTop || + canvasTop + rect.height / 4 > viewportBottom / scale + }; + } + + function drawPaperCanvas( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + width: number, + height: number, + background: string, + exporting: boolean = false + ) { + ctx.beginPath(); + ctx.rect(x, y, width, height); + + if (background === "transparent" && !exporting) { + // If the background is transparent, fill with a checkerboard pattern. + const pattern = document.createElement("canvas"); + const pctx = pattern.getContext("2d"); + if (!pctx) { + throw new Error("Failed to get 2D context for pattern."); + } + pattern.width = 20; + pattern.height = 20; + pctx.fillStyle = "#ccc"; + pctx.fillRect(0, 0, 20, 20); + pctx.fillStyle = "#fff"; + pctx.fillRect(0, 0, 10, 10); + pctx.fillRect(10, 10, 10, 10); + const checkerPattern = ctx.createPattern(pattern, "repeat"); + if (checkerPattern) { + ctx.fillStyle = checkerPattern; + } else { + ctx.fillStyle = "#fff"; // Fallback to white if pattern creation fails + } + } else { + ctx.fillStyle = background; + } + ctx.fill(); + } + + function drawCanvas(canvas: HTMLCanvasElement, options?: DrawOptions) { let elements = get().elements; const { background, layers, - dpi, width: canvasWidth, height: canvasHeight, position: { x: posX, y: posY }, @@ -364,8 +439,8 @@ export const createCanvasSlice: StateCreator< positionMap.set(layer.id, layers.length - 1 - i); } elements = elements.filter((element) => { - if (layerId) { - return element.layerId === layerId; + if (options?.layerId) { + return element.layerId === options.layerId; } return !visibilityMap.get(element.layerId); }); @@ -376,49 +451,51 @@ export const createCanvasSlice: StateCreator< return aPosition - bPosition; }); - const rect = canvas.getBoundingClientRect(); - - canvas.width = rect.width; - canvas.height = rect.height; - - const canvasX = 0; - const canvasY = 0; - - ctx.setTransform(1, 0, 0, 1, 0, 0); // Reset any existing transforms - - ctx.clearRect(canvasX, canvasY, canvas.width, canvas.height); + if (!options?.export) { + const rect = canvas.getBoundingClientRect(); - // Apply scaling and translation for panning and zooming. - ctx.setTransform(scale, 0, 0, scale, posX, posY); + canvas.width = rect.width; + canvas.height = rect.height; - ctx.save(); - // Draw the container. - // First, skew the origin to the center of the canvas. - ctx.translate(canvas.width / 2, canvas.height / 2); + ctx.setTransform(1, 0, 0, 1, 0, 0); // Reset any existing transforms - // Then draw the actual canvas. - ctx.beginPath(); - ctx.rect(-canvasWidth / 2, -canvasHeight / 2, canvasWidth, canvasHeight); - ctx.fillStyle = background; - ctx.fill(); - - // Clip to the canvas area so that drawings outside the canvas are not visible. - ctx.clip(); + // Apply scaling and translation for panning and zooming. + ctx.setTransform(scale, 0, 0, scale, posX, posY); - // Finally, reset the origin back to the top-left corner. - ctx.translate(-rect.width / 2, -rect.height / 2); + ctx.save(); + // Draw the container. + // First, skew the origin to the center of the canvas. + ctx.translate(canvas.width / 2, canvas.height / 2); + drawPaperCanvas( + ctx, + -canvasWidth / 2, + -canvasHeight / 2, + canvasWidth, + canvasHeight, + background + ); + // Clip to the canvas area so that drawings outside the canvas are not visible. + ctx.clip(); + + // Finally, reset the origin back to the top-left corner. + ctx.translate(-rect.width / 2, -rect.height / 2); + } else { + // We don't need to apply any transforms when exporting. + // Just draw the canvas at its natural size. + drawPaperCanvas(ctx, 0, 0, canvasWidth, canvasHeight, background, true); + } - const isPreviewCanvas = canvas.width < canvasWidth * dpi; + // const isPreviewCanvas = canvas.width < canvasWidth * dpi; - if (isPreviewCanvas) { - // If the canvas is a preview canvas, scale it down. - const scaleX = canvas.width / (canvasWidth * dpi); - const scaleY = canvas.height / (canvasHeight * dpi); - // Save the current transform state. - ctx.save(); + // if (isPreviewCanvas) { + // // If the canvas is a preview canvas, scale it down. + // const scaleX = canvas.width / (canvasWidth * dpi); + // const scaleY = canvas.height / (canvasHeight * dpi); + // // Save the current transform state. + // ctx.save(); - ctx.scale(scaleX, scaleY); - } + // ctx.scale(scaleX, scaleY); + // } for (const element of elements) { const { x, y, width, height } = element; @@ -496,9 +573,9 @@ export const createCanvasSlice: StateCreator< } } - if (isPreviewCanvas) { - ctx.restore(); // Restore the previous transform state. - } + // if (isPreviewCanvas) { + // ctx.restore(); // Restore the previous transform state. + // } ctx.restore(); } @@ -507,7 +584,8 @@ export const createCanvasSlice: StateCreator< width: 400, height: 400, mode: "move", - background: "#ffffff", + // background: "#ffffff", + background: "transparent", shape: "rectangle", shapeMode: "fill", color: "#000000", @@ -546,6 +624,8 @@ export const createCanvasSlice: StateCreator< prepareForSave, prepareForExport, toggleReferenceWindow, + getPointerPosition, + isCanvasOffscreen, drawCanvas }; }; diff --git a/src/types/Slices.types.ts b/src/types/Slices.types.ts index 79a88a8..2e8a80c 100644 --- a/src/types/Slices.types.ts +++ b/src/types/Slices.types.ts @@ -11,6 +11,14 @@ import type { CanvasElementType } from "../types"; +export type DrawOptions = Partial<{ + /* The specific layer to draw. */ + layerId: string; + + /* Whether to skip certain drawing options (such as positioning the canvas) */ + export: boolean; +}>; + export type CanvasElementsStore = { elements: CanvasElement[]; copiedElements: CanvasElement[]; @@ -59,7 +67,20 @@ export type CanvasStore = CanvasState & { toggleReferenceWindow: () => void; prepareForSave: () => SavedCanvasProperties; prepareForExport: (quality?: number) => Promise; - drawCanvas: (canvas: HTMLCanvasElement, layerId?: string) => void; + drawCanvas: (canvas: HTMLCanvasElement, options?: DrawOptions) => void; + getPointerPosition: ( + canvas: HTMLCanvasElement, + clientX: number, + clientY: number + ) => Coordinates; + isCanvasOffscreen: ( + canvas: HTMLCanvasElement, + dx: number, + dy: number + ) => { + left: boolean; + top: boolean; + }; }; export type HistoryStore = { From 2aa29d87ebc98ac16882921683c6257e7dbe910b Mon Sep 17 00:00:00 2001 From: Mark Evola Date: Sun, 5 Oct 2025 21:45:57 -0500 Subject: [PATCH 04/17] temp solution to off screen canvas --- src/components/Canvas/Canvas.tsx | 62 +++++------- src/components/CanvasPane/CanvasPane.tsx | 13 ++- .../CanvasReferenceProvider.tsx | 29 ++++++ src/components/LayerPreview/LayerPreview.tsx | 2 +- src/components/LeftToolbar/LeftToolbar.tsx | 45 ++++++++- src/components/Main/Main.tsx | 48 +++++---- src/components/Navbar/Navbar.tsx | 29 ++++-- .../ToolbarButton/ToolbarButton.tsx | 4 +- src/lib/utils.ts | 13 ++- src/pages/index/index.page.tsx | 11 ++- src/state/hooks/useCanvasRedrawListener.ts | 7 +- src/state/hooks/useCanvasRef.ts | 14 +++ src/state/hooks/useStore.ts | 10 +- src/state/hooks/useStoreEffect.ts | 10 +- src/state/hooks/useStoreSubscription.ts | 7 +- src/state/slices/canvasSlice.ts | 99 ++++++++----------- src/tests/unit/useStore.test.ts | 36 ------- src/types/Slices.types.ts | 5 +- 18 files changed, 240 insertions(+), 204 deletions(-) create mode 100644 src/components/CanvasReferenceProvider/CanvasReferenceProvider.tsx create mode 100644 src/state/hooks/useCanvasRef.ts diff --git a/src/components/Canvas/Canvas.tsx b/src/components/Canvas/Canvas.tsx index b9fd2ff..5279ef9 100644 --- a/src/components/Canvas/Canvas.tsx +++ b/src/components/Canvas/Canvas.tsx @@ -10,6 +10,8 @@ import type { MouseEvent as ReactMouseEvent } from "react"; import { type Coordinates, CanvasElementPath } from "@/types"; import useThrottle from "@/state/hooks/useThrottle"; import useCanvasRedrawListener from "@/state/hooks/useCanvasRedrawListener"; +import useCanvasRef from "@/state/hooks/useCanvasRef"; +import { redrawCanvas } from "@/lib/utils"; // Styles using Tailwind @@ -34,7 +36,8 @@ const Canvas = forwardRef(function Canvas( createElement, getActiveLayer, pushHistory, - getPointerPosition + getPointerPosition, + drawCanvas } = useStore( useShallow((state) => ({ mode: state.mode, @@ -48,9 +51,11 @@ const Canvas = forwardRef(function Canvas( createElement: state.createElement, getActiveLayer: state.getActiveLayer, pushHistory: state.pushHistory, - getPointerPosition: state.getPointerPosition + getPointerPosition: state.getPointerPosition, + drawCanvas: state.drawCanvas })) ); + const { setRef } = useCanvasRef(); const color = useStoreSubscription((state) => state.color); const strokeWidth = useStoreSubscription((state) => state.strokeWidth); const shapeMode = useStoreSubscription((state) => state.shapeMode); @@ -87,14 +92,16 @@ const Canvas = forwardRef(function Canvas( // Clip the drawing to the bounds of the canvas ctx.save(); - const canvasRect = canvas.getBoundingClientRect(); - ctx.translate(canvasRect.width / 2, canvasRect.height / 2); - ctx.rect(-width / 2, -height / 2, width, height); + ctx.translate(canvas.width / 2 - width / 2, canvas.height / 2 - height / 2); + ctx.rect(0, 0, width, height); ctx.clip(); - ctx.translate(-canvasRect.width / 2, -canvasRect.height / 2); + ctx.translate( + -canvas.width / 2 + width / 2, + -canvas.height / 2 + height / 2 + ); // Calculate the position of the mouse relative to the canvas. - const { x, y } = getPointerPosition(canvas, e.clientX, e.clientY); + const { x, y } = getPointerPosition(canvas, e.clientX, e.clientY); const floorX = Math.floor(x); const floorY = Math.floor(y); @@ -113,29 +120,11 @@ const Canvas = forwardRef(function Canvas( // `.getImageData()` retreives the x and y coordinates of the pixel // differently if the canvas is scaled. So, we need to multiply the // x and y coordinates by the DPI to get the correct pixel. - const pixel = ctx.getImageData( - Math.floor(floorX * dpi), - Math.floor(floorY * dpi), - 1, - 1 - ).data; + const pixel = ctx.getImageData(floorX * dpi, floorY * dpi, 1, 1).data; const colorStr = `rgb(${pixel[0]}, ${pixel[1]}, ${pixel[2]})`; - let color; - - if (colorStr === "rgb(0, 0, 0)") { - // If the color is transparent, we want to assume - // that the user wanted to select the background color - // which visually is white. For the color to be - // transparent is correct, but from a UX perspective, - // it's not what the user would expect. So, - // we'll set the color to white. - color = parseColor("rgb(255, 255, 255)"); - } else { - color = parseColor(colorStr); - } - changeColor(color.toString("hex")); + changeColor(parseColor(colorStr).toString("hex")); changeMode("move"); } }; @@ -155,9 +144,8 @@ const Canvas = forwardRef(function Canvas( if (e.buttons !== 1 || !isDrawing.current || isGrabbing) { return; } - if (mode === "shapes") { + if (mode === "shapes" || !currentPath2D.current) { currentPath2D.current = new Path2D(); - document.dispatchEvent(new CustomEvent("canvas:redraw")); } const ctx = activeLayer.getContext("2d"); @@ -168,17 +156,13 @@ const Canvas = forwardRef(function Canvas( const floorX = Math.floor(x); const floorY = Math.floor(y); - ctx.globalCompositeOperation = - mode === "eraser" ? "destination-out" : "source-over"; + // ctx.globalCompositeOperation = + // mode === "eraser" ? "destination-out" : "source-over"; ctx.fillStyle = color.current; - ctx.strokeStyle = color.current; + ctx.strokeStyle = mode === "eraser" ? "rgba(0, 0,0, 0)" : color.current; ctx.lineWidth = strokeWidth.current * dpi; const currentShapeMode = shapeMode.current; - if (!currentPath2D.current) { - currentPath2D.current = new Path2D(); - } - switch (mode) { case "brush": case "eraser": { @@ -196,10 +180,12 @@ const Canvas = forwardRef(function Canvas( y: floorY, startingPoint: false }); + // drawCanvas(activeLayer); break; } case "shapes": { + redrawCanvas(); if (shape === "circle") { const width = x - initialPosition.current.x; const height = y - initialPosition.current.y; @@ -311,6 +297,7 @@ const Canvas = forwardRef(function Canvas( properties }); currentPath.current = []; + redrawCanvas(); }; const onMouseEnter = (e: ReactMouseEvent) => { @@ -325,8 +312,9 @@ const Canvas = forwardRef(function Canvas( useEffect(() => { document.addEventListener("mousemove", onMouseMove); + setRef(canvasRef.current); return () => document.removeEventListener("mousemove", onMouseMove); - }, [onMouseMove]); + }, [onMouseMove, setRef]); return ( >; +} | null>(null); + +type CanvasReferenceProviderProps = Readonly<{ + children: ReactNode; +}>; + +function CanvasReferenceProvider({ + children +}: CanvasReferenceProviderProps): ReactNode { + const [ref, setRef] = useState(null); + + const value = useMemo(() => ({ ref, setRef }), [ref]); + + return ( + + {children} + + ); +} + +export { CanvasReferenceContext, CanvasReferenceProvider }; diff --git a/src/components/LayerPreview/LayerPreview.tsx b/src/components/LayerPreview/LayerPreview.tsx index afa6ae8..0be66d7 100644 --- a/src/components/LayerPreview/LayerPreview.tsx +++ b/src/components/LayerPreview/LayerPreview.tsx @@ -15,7 +15,7 @@ const DEBOUNCE_REDRAW = true; function LayerPreview({ id }: LayerPreviewProps): ReactNode { const previewRef = useRef(null); - // useCanvasRedrawListener(previewRef, id, DEBOUNCE_REDRAW); + useCanvasRedrawListener(previewRef, id, DEBOUNCE_REDRAW, true); return ( state.mode); + const { currentMode, setPosition, setZoom } = useStore( + useShallow((state) => ({ + currentMode: state.mode, + setPosition: state.setPosition, + setZoom: state.setZoom + })) + ); const renderedModes = MODES.map((mode) => { return ( - {renderedModes} +
{renderedModes}
+ + {/* + TODO: This button is a temporary fallback for resetting the canvas for when it goes off screen. The better solution + is to prevent the canvas from going off screen, but the math is currently unknown for how to do that at the moment. This + is a temporary solution. + */} + + + ); } diff --git a/src/components/Main/Main.tsx b/src/components/Main/Main.tsx index 3d6a5ef..0d49d2c 100644 --- a/src/components/Main/Main.tsx +++ b/src/components/Main/Main.tsx @@ -1,7 +1,7 @@ // Lib import ElementsStore from "@/state/stores/ElementsStore"; import LayersStore from "@/state/stores/LayersStore"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useState } from "react"; import useStore from "@/state/hooks/useStore"; import useStoreEffect from "@/state/hooks/useStoreEffect"; @@ -13,6 +13,7 @@ import CanvasPane from "@/components/CanvasPane/CanvasPane"; import LeftToolbar from "@/components/LeftToolbar/LeftToolbar"; import LayerPane from "@/components/LayerPane/LayerPane"; import ReferenceWindow from "@/components/ReferenceWindow/ReferenceWindow"; +import { redrawCanvas } from "@/lib/utils"; function Main(): ReactNode { const { setElements, setLayers } = useStore((store) => ({ @@ -23,7 +24,6 @@ function Main(): ReactNode { (store) => store.referenceWindowEnabled ); const [loading, setLoading] = useState(true); - const firstRender = useRef(true); useEffect(() => { async function updateLayersAndElements() { @@ -46,40 +46,38 @@ function Main(): ReactNode { ); } setElements(elements.map(([, element]) => element)); + setLoading(false); + redrawCanvas(); } updateLayersAndElements(); }, [setElements, setLayers]); - useStoreEffect( - (state) => ({ layers: state.layers, elements: state.elements }), - (current, previous) => { - const changeInLayerToggle = - current.layers.length === previous.layers.length && - current.layers.some( - (layer, index) => layer.active !== previous.layers[index].active - ); - const layerAdded = current.layers.length > previous.layers.length; + // useStoreEffect( + // (state) => ({ layers: state.layers, elements: state.elements }), + // (current, previous) => { + // const changeInLayerToggle = + // current.layers.length === previous.layers.length && + // current.layers.some( + // (layer, index) => layer.active !== previous.layers[index].active + // ); + // const layerAdded = current.layers.length > previous.layers.length; - document.dispatchEvent( - new CustomEvent("canvas:redraw", { - detail: { - noChange: changeInLayerToggle || layerAdded - } - }) - ); - if (firstRender.current) { - firstRender.current = false; - setLoading(false); - } - } - ); + // document.dispatchEvent( + // new CustomEvent("canvas:redraw", { + // detail: { + // noChange: changeInLayerToggle || layerAdded + // } + // }) + // ); + // } + // ); return (
diff --git a/src/components/Navbar/Navbar.tsx b/src/components/Navbar/Navbar.tsx index d0c5ce1..7693679 100644 --- a/src/components/Navbar/Navbar.tsx +++ b/src/components/Navbar/Navbar.tsx @@ -28,25 +28,25 @@ import { } from "@/components/ui/menubar"; import NavbarFileSaveStatus from "../NavbarFileSaveStatus/NavbarFileSaveStatus"; import ZoomIn from "../icons/ZoomIn/ZoomIn"; -import { detectOperatingSystem } from "@/lib/utils"; +import { detectOperatingSystem, redrawCanvas } from "@/lib/utils"; import ZoomOut from "../icons/ZoomOut/ZoomOut"; +import useCanvasRef from "@/state/hooks/useCanvasRef"; function Navbar(): ReactNode { const { prepareForExport, prepareForSave, toggleReferenceWindow, - increaseScale, - decreaseScale + performZoom } = useStore( useShallow((state) => ({ prepareForExport: state.prepareForExport, prepareForSave: state.prepareForSave, toggleReferenceWindow: state.toggleReferenceWindow, - increaseScale: state.increaseScale, - decreaseScale: state.decreaseScale + performZoom: state.performZoom })) ); + const { ref } = useCanvasRef(); const downloadRef = useRef(null); const [saveStatus, setSaveStatus] = useState<"saving" | "saved" | "error">( "saved" @@ -93,9 +93,12 @@ function Navbar(): ReactNode { const handleExportFile = async () => { if (!downloadRef.current) throw new Error("Download ref not found"); + if (!ref) { + alert("Canvas ref not found."); + } try { - const blob = await prepareForExport(); + const blob = await prepareForExport(ref); const url = URL.createObjectURL(blob); @@ -110,6 +113,16 @@ function Navbar(): ReactNode { } }; + function increaseZoom() { + performZoom(window.innerWidth / 2, window.innerHeight / 2, -100); + redrawCanvas(); + } + + function decreaseZoom() { + performZoom(window.innerWidth / 2, window.innerHeight / 2, 0.5); + redrawCanvas(); + } + const toggleFullScreen = () => { const doc = window.document; const docEl = doc.documentElement; @@ -138,13 +151,13 @@ function Navbar(): ReactNode { View: [ { text: "Zoom In", - action: increaseScale, + action: increaseZoom, icon: ZoomIn, shortcut: "Plus" }, { text: "Zoom Out", - action: decreaseScale, + action: decreaseZoom, icon: ZoomOut, shortcut: "Minus" }, diff --git a/src/components/ToolbarButton/ToolbarButton.tsx b/src/components/ToolbarButton/ToolbarButton.tsx index 6f2516e..725f537 100644 --- a/src/components/ToolbarButton/ToolbarButton.tsx +++ b/src/components/ToolbarButton/ToolbarButton.tsx @@ -70,8 +70,10 @@ function ToolbarButton({ const performAction = useCallback(() => { if (name === "undo") { undo(); + UTILS.redrawCanvas(); } else if (name === "redo") { redo(); + UTILS.redrawCanvas(); } else { changeMode(name); } @@ -111,7 +113,7 @@ function ToolbarButton({ > - ); } diff --git a/src/components/Navbar/Navbar.tsx b/src/components/Navbar/Navbar.tsx index e7fe12a..a28d9d7 100644 --- a/src/components/Navbar/Navbar.tsx +++ b/src/components/Navbar/Navbar.tsx @@ -5,16 +5,27 @@ import { useShallow } from "zustand/shallow"; import useStore from "@/state/hooks/useStore"; import LayersStore from "@/state/stores/LayersStore"; import ElementsStore from "@/state/stores/ElementsStore"; +import { detectOperatingSystem, redrawCanvas } from "@/lib/utils"; +import useCanvasRef from "@/state/hooks/useCanvasRef"; // Icons import Fullscreen from "@/components/icons/Fullscreen/Fullscreen"; -import Image from "@/components/icons/Image/Image"; +import { default as ImageIcon } from "@/components/icons/Image/Image"; import Export from "@/components/icons/Export/Export"; import FloppyDisk from "@/components/icons/FloppyDisk/FloppyDisk"; import Close from "@/components/icons/Close/Close"; +import ZoomOut from "../icons/ZoomOut/ZoomOut"; +import ZoomIn from "../icons/ZoomIn/ZoomIn"; +import Rotate from "../icons/Rotate/Rotate"; +import FolderOpen from "../icons/FolderOpen/FolderOpen"; // Types -import type { ComponentProps, ReactElement, ReactNode } from "react"; +import type { + ChangeEvent, + ComponentProps, + ReactElement, + ReactNode +} from "react"; // Components import { @@ -27,27 +38,35 @@ import { MenubarShortcut } from "@/components/ui/menubar"; import NavbarFileSaveStatus from "../NavbarFileSaveStatus/NavbarFileSaveStatus"; -import ZoomIn from "../icons/ZoomIn/ZoomIn"; -import { detectOperatingSystem, redrawCanvas } from "@/lib/utils"; -import ZoomOut from "../icons/ZoomOut/ZoomOut"; -import useCanvasRef from "@/state/hooks/useCanvasRef"; +import ImageElementStore from "@/state/stores/ImageElementStore"; function Navbar(): ReactNode { const { prepareForExport, prepareForSave, toggleReferenceWindow, - performZoom + performZoom, + setPosition, + setZoom, + resetLayersAndElements, + createElement, + changeDimensions } = useStore( useShallow((state) => ({ prepareForExport: state.prepareForExport, prepareForSave: state.prepareForSave, toggleReferenceWindow: state.toggleReferenceWindow, - performZoom: state.performZoom + performZoom: state.performZoom, + setPosition: state.setPosition, + setZoom: state.setZoom, + resetLayersAndElements: state.resetLayersAndElements, + createElement: state.createElement, + changeDimensions: state.changeDimensions })) ); const { ref } = useCanvasRef(); const downloadRef = useRef(null); + const openFileRef = useRef(null); const [saveStatus, setSaveStatus] = useState<"saving" | "saved" | "error">( "saved" ); @@ -95,7 +114,7 @@ function Navbar(): ReactNode { if (!downloadRef.current) throw new Error("Download ref not found"); if (!ref) { alert("Canvas ref not found."); - return; + return; } try { @@ -150,8 +169,74 @@ function Navbar(): ReactNode { } }; + function resetCanvasView() { + setPosition({ x: 0, y: 0 }); + setZoom(1); + redrawCanvas(); + } + + const openFile = useCallback(() => { + if (!openFileRef.current) { + throw new Error("Download ref does not exist."); + } + + openFileRef.current.click(); + }, []); + + async function handleOpeningFile(e: ChangeEvent) { + const file = e.target.files?.[0]; + + if (!file) { + alert("No file was inputted."); + return; + } + + if (!ref) { + throw new Error("Canvas ref not found."); + } + + const shouldLoad = window.confirm( + "Loading this file will erase all current data. Proceed?" + ); + if (shouldLoad) { + // Erase everything... + await LayersStore.clearStore(); + await ElementsStore.clearStore(); + resetLayersAndElements(); // Reset the Zustand state. + + // Upload the image. + const image = new Image(); + + image.onload = function () { + URL.revokeObjectURL(image.src); + const { width, height } = ref.getBoundingClientRect(); + + changeDimensions({ + width: image.naturalWidth, + height: image.naturalHeight + }); + const element = createElement("image", { + x: width / 2 - image.naturalWidth / 2, + y: height / 2 - image.naturalHeight / 2, + width: image.naturalWidth, + height: image.naturalHeight + }); + ImageElementStore.putImage(element.id, image); + redrawCanvas(); + }; + + image.src = URL.createObjectURL(file); + } + } + const menuOptions: MenuOptions = { File: [ + { + text: "Open File", + action: openFile, + icon: FolderOpen, + shortcut: "O" + }, { text: "Save File", action: handleSaveFile, @@ -165,6 +250,16 @@ function Navbar(): ReactNode { } ], View: [ + /** + * TODO: This button is a temporary fallback for resetting the canvas for when it goes off screen. The better solution + is to prevent the canvas from going off screen, but the math is currently unknown for how to do that at the moment. This + is a temporary solution. + */ + { + text: "Reset Canvas View", + action: resetCanvasView, + icon: Rotate + }, { text: "Zoom In", action: increaseZoom, @@ -180,7 +275,7 @@ function Navbar(): ReactNode { { text: "Reference Window", action: toggleReferenceWindow, - icon: Image + icon: ImageIcon }, { text: "Toggle Full Screen", @@ -195,6 +290,9 @@ function Navbar(): ReactNode { if (e.key === "s" && e.ctrlKey) { e.preventDefault(); handleSaveFile(); + } else if (e.key === "o" && e.ctrlKey) { + e.preventDefault(); + openFile(); } }; @@ -203,7 +301,7 @@ function Navbar(): ReactNode { return () => { document.removeEventListener("keydown", handleKeyDown); }; - }, [handleSaveFile]); + }, [handleSaveFile, openFile]); return (
@@ -278,6 +376,12 @@ function Navbar(): ReactNode { style={{ display: "none" }} data-testid="export-link" /> +
); } diff --git a/src/components/icons/FolderOpen/FolderOpen.tsx b/src/components/icons/FolderOpen/FolderOpen.tsx new file mode 100644 index 0000000..c5073a4 --- /dev/null +++ b/src/components/icons/FolderOpen/FolderOpen.tsx @@ -0,0 +1,11 @@ +import { FolderOpen as LucideFolderOpen } from "lucide-react"; +import type { ComponentProps } from "react"; + +const FolderOpen = (props: ComponentProps<"svg">) => ( + +); + +export default FolderOpen; diff --git a/src/state/slices/canvasSlice.ts b/src/state/slices/canvasSlice.ts index 776571e..6d5d01e 100644 --- a/src/state/slices/canvasSlice.ts +++ b/src/state/slices/canvasSlice.ts @@ -13,6 +13,7 @@ import type { CanvasElement } from "@/types"; import * as Utils from "@/lib/utils"; +import ImageElementStore from "../stores/ImageElementStore"; export const createCanvasSlice: StateCreator< SliceStores, @@ -585,12 +586,31 @@ export const createCanvasSlice: StateCreator< } break; } + case "image": { + const img = ImageElementStore.getImage(element.id); + if (!img) { + console.error( + "Tried to render an image element of id " + + element.id + + ", but no image existed in the ImageElementStore." + ); + } else { + ctx.drawImage(img, x, y); + } + } } } ctx.restore(); } + function resetLayersAndElements() { + set({ + layers: [{ name: "Layer 1", id: uuidv4(), active: true, hidden: false }], + elements: [] + }); + } + return { width: 400, height: 400, @@ -636,6 +656,7 @@ export const createCanvasSlice: StateCreator< toggleReferenceWindow, getPointerPosition, isCanvasOffscreen, - drawCanvas + drawCanvas, + resetLayersAndElements }; }; diff --git a/src/state/stores/ImageElementStore.ts b/src/state/stores/ImageElementStore.ts new file mode 100644 index 0000000..8f4c820 --- /dev/null +++ b/src/state/stores/ImageElementStore.ts @@ -0,0 +1,16 @@ +/** A class for storing the images of any elements that are images. This class is to be used alongside the CanvasElementSlice in the Zustand store. */ +export default class ImageElementStore { + public static images: Map = new Map(); + + public static putImage(imageId: string, image: HTMLImageElement) { + this.images.set(imageId, image); + } + + public static getImage(imageId: string) { + return this.images.get(imageId); + } + + public static deleteImage(imageId: string) { + this.images.delete(imageId); + } +} diff --git a/src/types/Canvas.types.ts b/src/types/Canvas.types.ts index 6be3e6e..c45b047 100644 --- a/src/types/Canvas.types.ts +++ b/src/types/Canvas.types.ts @@ -60,6 +60,7 @@ export type ResizePosition = "nw" | "n" | "ne" | "w" | "e" | "sw" | "s" | "se"; export type CanvasElementType = | Shape | "text" + | "image" | Extract; export type FontProperties = { diff --git a/src/types/Slices.types.ts b/src/types/Slices.types.ts index 28c1ba2..50160c1 100644 --- a/src/types/Slices.types.ts +++ b/src/types/Slices.types.ts @@ -84,6 +84,7 @@ export type CanvasStore = CanvasState & { left: boolean; top: boolean; }; + resetLayersAndElements: () => void; }; export type HistoryStore = { From 90b6147759666cb28fe422a165c3975dd6d8ed97 Mon Sep 17 00:00:00 2001 From: Mark Evola Date: Tue, 7 Oct 2025 01:07:18 -0500 Subject: [PATCH 08/17] feat(images): save and load images --- src/components/Main/Main.tsx | 2 + src/components/Navbar/Navbar.tsx | 3 +- src/pages/index/index.page.tsx | 2 + src/state/slices/canvasSlice.ts | 4 +- src/state/stores/BaseStore.ts | 4 +- src/state/stores/ImageElementStore.ts | 70 ++++++++++++++++++++++++++- 6 files changed, 79 insertions(+), 6 deletions(-) diff --git a/src/components/Main/Main.tsx b/src/components/Main/Main.tsx index 74bebf6..a562a8e 100644 --- a/src/components/Main/Main.tsx +++ b/src/components/Main/Main.tsx @@ -13,6 +13,7 @@ import LeftToolbar from "@/components/LeftToolbar/LeftToolbar"; import LayerPane from "@/components/LayerPane/LayerPane"; import ReferenceWindow from "@/components/ReferenceWindow/ReferenceWindow"; import { redrawCanvas } from "@/lib/utils"; +import ImageElementStore from "@/state/stores/ImageElementStore"; function Main(): ReactNode { const { setElements, setLayers } = useStore((store) => ({ @@ -28,6 +29,7 @@ function Main(): ReactNode { async function updateLayersAndElements() { const elements = await ElementsStore.getElements(); const layers = await LayersStore.getLayers(); + await ImageElementStore.loadImages(); // There must always be at least one layer. // If there are no layers, do not update, diff --git a/src/components/Navbar/Navbar.tsx b/src/components/Navbar/Navbar.tsx index a28d9d7..eda0da0 100644 --- a/src/components/Navbar/Navbar.tsx +++ b/src/components/Navbar/Navbar.tsx @@ -99,7 +99,8 @@ function Navbar(): ReactNode { position: i })) ), - ElementsStore.addElements(elements) + ElementsStore.addElements(elements), + ImageElementStore.saveImages() ); await Promise.all(promises); diff --git a/src/pages/index/index.page.tsx b/src/pages/index/index.page.tsx index 392ad09..5d26fbe 100644 --- a/src/pages/index/index.page.tsx +++ b/src/pages/index/index.page.tsx @@ -11,6 +11,7 @@ import Main from "@/components/Main/Main"; import ErrorBoundary from "@/components/ErrorBoundary/ErrorBoundary"; import { StoreProvider } from "@/components/StoreContext/StoreContext"; import { CanvasReferenceProvider } from "@/components/CanvasReferenceProvider/CanvasReferenceProvider"; +import ImageElementStore from "@/state/stores/ImageElementStore"; // The tags // eslint-disable-next-line @@ -45,6 +46,7 @@ function Page() { LayersStore.openStore(); ElementsStore.openStore(); + ImageElementStore.openStore(); }, []); return ( diff --git a/src/state/slices/canvasSlice.ts b/src/state/slices/canvasSlice.ts index 6d5d01e..3d3ca1e 100644 --- a/src/state/slices/canvasSlice.ts +++ b/src/state/slices/canvasSlice.ts @@ -595,13 +595,13 @@ export const createCanvasSlice: StateCreator< ", but no image existed in the ImageElementStore." ); } else { - ctx.drawImage(img, x, y); + ctx.drawImage(img, x, y, width, height); } } } } - ctx.restore(); + ctx.restore(); } function resetLayersAndElements() { diff --git a/src/state/stores/BaseStore.ts b/src/state/stores/BaseStore.ts index 1c94720..91e7241 100644 --- a/src/state/stores/BaseStore.ts +++ b/src/state/stores/BaseStore.ts @@ -1,6 +1,6 @@ const DATABASE_NAME = "canvas"; -const STORES = ["layers", "elements"]; -const VERSION = 1; +const STORES = ["layers", "elements", "images"]; +const VERSION = 2; export default abstract class BaseStore { protected static storeName: string; diff --git a/src/state/stores/ImageElementStore.ts b/src/state/stores/ImageElementStore.ts index 8f4c820..98c3da4 100644 --- a/src/state/stores/ImageElementStore.ts +++ b/src/state/stores/ImageElementStore.ts @@ -1,5 +1,10 @@ +import BaseStore from "./BaseStore"; + +type ImageEntry = { id: string; blob: Blob }; + /** A class for storing the images of any elements that are images. This class is to be used alongside the CanvasElementSlice in the Zustand store. */ -export default class ImageElementStore { +export default class ImageElementStore extends BaseStore { + protected static override storeName: string = "images"; public static images: Map = new Map(); public static putImage(imageId: string, image: HTMLImageElement) { @@ -13,4 +18,67 @@ export default class ImageElementStore { public static deleteImage(imageId: string) { this.images.delete(imageId); } + + public static async saveImages() { + const promises: Promise[] = []; + + for (const [id, image] of this.images.entries()) { + promises.push( + new Promise((resolve) => { + const cvas = document.createElement("canvas"); + cvas.width = image.naturalWidth; + cvas.height = image.naturalHeight; + + const ctx = cvas.getContext("2d"); + ctx?.drawImage(image, 0, 0, image.naturalWidth, image.naturalHeight); + cvas.toBlob((blob) => { + if (!blob) { + return; + } + + resolve({ + id, + blob + }); + }); + }) + ); + } + + const result = await Promise.all(promises); + return this.add(result); + } + + public static async loadImages() { + //eslint-disable-next-line + const self = this; + const entries = await this.get(); + const promises: Promise[] = []; + + for (const [id, entry] of entries) { + promises.push( + new Promise((resolve) => { + const img = new Image(); + + img.onload = function () { + URL.revokeObjectURL(img.src); + self.putImage(id, img); + resolve(); + }; + + img.src = URL.createObjectURL(entry.blob); + }) + ); + } + + return Promise.all(promises); + } + + public static openStore() { + this.open(); + } + + public static closeStore() { + this.close(); + } } From c4ef333777772958952b706fc35108d93255c81c Mon Sep 17 00:00:00 2001 From: Mark Evola Date: Tue, 7 Oct 2025 06:33:11 -0500 Subject: [PATCH 09/17] load canvas properties from local storage --- src/components/Main/Main.tsx | 17 +++++++++------ src/components/Navbar/Navbar.tsx | 3 ++- src/state/slices/canvasSlice.ts | 31 ++++++++++++++++++++++++--- src/state/stores/ImageElementStore.ts | 6 +++++- src/types/Slices.types.ts | 1 + 5 files changed, 46 insertions(+), 12 deletions(-) diff --git a/src/components/Main/Main.tsx b/src/components/Main/Main.tsx index a562a8e..b12b767 100644 --- a/src/components/Main/Main.tsx +++ b/src/components/Main/Main.tsx @@ -3,6 +3,7 @@ import ElementsStore from "@/state/stores/ElementsStore"; import LayersStore from "@/state/stores/LayersStore"; import { useEffect, useState } from "react"; import useStore from "@/state/hooks/useStore"; +import { useShallow } from "zustand/react/shallow"; // Types import type { ReactNode } from "react"; @@ -16,12 +17,13 @@ import { redrawCanvas } from "@/lib/utils"; import ImageElementStore from "@/state/stores/ImageElementStore"; function Main(): ReactNode { - const { setElements, setLayers } = useStore((store) => ({ - setElements: store.setElements, - setLayers: store.setLayers - })); - const refereceWindowEnabled = useStore( - (store) => store.referenceWindowEnabled + const { setElements, setLayers, refereceWindowEnabled, loadCanvasProperties } = useStore( + useShallow((store) => ({ + setElements: store.setElements, + setLayers: store.setLayers, + refereceWindowEnabled: store.referenceWindowEnabled, + loadCanvasProperties: store.loadCanvasProperties + })) ); const [loading, setLoading] = useState(true); @@ -30,6 +32,7 @@ function Main(): ReactNode { const elements = await ElementsStore.getElements(); const layers = await LayersStore.getLayers(); await ImageElementStore.loadImages(); + loadCanvasProperties(); // There must always be at least one layer. // If there are no layers, do not update, @@ -52,7 +55,7 @@ function Main(): ReactNode { } updateLayersAndElements(); - }, [setElements, setLayers]); + }, [setElements, setLayers, loadCanvasProperties]); return (
; + set({ width, height, background }); + } + /** * A helper function that returns an array of lines of the given text that fit within the given width. * @param text The text to split into lines. @@ -601,7 +625,7 @@ export const createCanvasSlice: StateCreator< } } - ctx.restore(); + ctx.restore(); } function resetLayersAndElements() { @@ -652,6 +676,7 @@ export const createCanvasSlice: StateCreator< changeX, changeY, prepareForSave, + loadCanvasProperties, prepareForExport, toggleReferenceWindow, getPointerPosition, diff --git a/src/state/stores/ImageElementStore.ts b/src/state/stores/ImageElementStore.ts index 98c3da4..a022623 100644 --- a/src/state/stores/ImageElementStore.ts +++ b/src/state/stores/ImageElementStore.ts @@ -2,7 +2,7 @@ import BaseStore from "./BaseStore"; type ImageEntry = { id: string; blob: Blob }; -/** A class for storing the images of any elements that are images. This class is to be used alongside the CanvasElementSlice in the Zustand store. */ +/** A class for storing the images of any canvas elements that are images. This class is to be used alongside the CanvasElementSlice in the Zustand store. */ export default class ImageElementStore extends BaseStore { protected static override storeName: string = "images"; public static images: Map = new Map(); @@ -81,4 +81,8 @@ export default class ImageElementStore extends BaseStore { public static closeStore() { this.close(); } + + public static clearStore() { + return this.remove(); + } } diff --git a/src/types/Slices.types.ts b/src/types/Slices.types.ts index 50160c1..222945d 100644 --- a/src/types/Slices.types.ts +++ b/src/types/Slices.types.ts @@ -65,6 +65,7 @@ export type CanvasStore = CanvasState & { changeY: (payload: number) => void; toggleReferenceWindow: () => void; prepareForSave: () => SavedCanvasProperties; + loadCanvasProperties: () => void; prepareForExport: (ref: HTMLCanvasElement, quality?: number) => Promise; drawCanvas: ( baseCanvas: HTMLCanvasElement, From 013f3cceb6df96d0650533cd028782b457b7a87a Mon Sep 17 00:00:00 2001 From: Mark Evola Date: Wed, 8 Oct 2025 13:22:44 -0500 Subject: [PATCH 10/17] refactor(tool): fix eraser tool --- src/components/Canvas/Canvas.tsx | 14 ++++---- src/components/CanvasPane/CanvasPane.tsx | 9 +++-- src/components/LayerInfo/LayerInfo.tsx | 2 +- src/components/Navbar/Navbar.tsx | 7 ++-- .../ToolbarButton/ToolbarButton.tsx | 2 +- src/state/slices/canvasSlice.ts | 35 ++++++++++++++----- tailwind.config.js | 2 +- 7 files changed, 43 insertions(+), 28 deletions(-) diff --git a/src/components/Canvas/Canvas.tsx b/src/components/Canvas/Canvas.tsx index 133c052..c604db0 100644 --- a/src/components/Canvas/Canvas.tsx +++ b/src/components/Canvas/Canvas.tsx @@ -36,8 +36,7 @@ const Canvas = forwardRef(function Canvas( createElement, getActiveLayer, pushHistory, - getPointerPosition, - drawCanvas + getPointerPosition } = useStore( useShallow((state) => ({ mode: state.mode, @@ -138,8 +137,7 @@ const Canvas = forwardRef(function Canvas( throw new Error("Canvas Ref is not set. This is a bug."); } - const onCanvas = - e.target === canvas || canvas.contains(e.target as Node); + const onCanvas = e.target === canvas || canvas.contains(e.target as Node); if (e.buttons !== 1 || !isDrawing.current || isGrabbing) { return; @@ -156,10 +154,11 @@ const Canvas = forwardRef(function Canvas( const floorX = Math.floor(x); const floorY = Math.floor(y); - // ctx.globalCompositeOperation = - // mode === "eraser" ? "destination-out" : "source-over"; + ctx.globalCompositeOperation = + mode === "eraser" ? "destination-out" : "source-over"; ctx.fillStyle = color.current; - ctx.strokeStyle = mode === "eraser" ? "rgba(0, 0,0, 0)" : color.current; + ctx.strokeStyle = color.current; + // ctx.globalAlpha = mode === "eraser" ? 0 : opacity.current; ctx.lineWidth = strokeWidth.current * dpi; const currentShapeMode = shapeMode.current; @@ -167,7 +166,6 @@ const Canvas = forwardRef(function Canvas( case "brush": case "eraser": { if (!onCanvas) return; - ctx.strokeStyle = color.current; ctx.lineWidth = strokeWidth.current * dpi; ctx.lineCap = "round"; ctx.lineJoin = "round"; diff --git a/src/components/CanvasPane/CanvasPane.tsx b/src/components/CanvasPane/CanvasPane.tsx index 0bcbe18..78aba4b 100644 --- a/src/components/CanvasPane/CanvasPane.tsx +++ b/src/components/CanvasPane/CanvasPane.tsx @@ -33,8 +33,7 @@ function CanvasPane({ loading }: CanvasPaneProps): ReactNode { createElement, getActiveLayer, performZoom, - pushHistory, - isCanvasOffscreen + pushHistory } = useStore( useShallow((state) => ({ mode: state.mode, @@ -45,8 +44,7 @@ function CanvasPane({ loading }: CanvasPaneProps): ReactNode { createElement: state.createElement, getActiveLayer: state.getActiveLayer, performZoom: state.performZoom, - pushHistory: state.pushHistory, - isCanvasOffscreen: state.isCanvasOffscreen + pushHistory: state.pushHistory })) ); const currentShape = useStoreSubscription((state) => state.shape); @@ -249,7 +247,8 @@ function CanvasPane({ loading }: CanvasPaneProps): ReactNode { currentShape, currentColor, pushHistory, - performZoom + performZoom, + scale ]); return ( diff --git a/src/components/LayerInfo/LayerInfo.tsx b/src/components/LayerInfo/LayerInfo.tsx index 419fdb7..9a86d02 100644 --- a/src/components/LayerInfo/LayerInfo.tsx +++ b/src/components/LayerInfo/LayerInfo.tsx @@ -123,7 +123,7 @@ function LayerInfo({ "flex items-center w-full max-w-full h-[2.6rem] py-[0.2rem] px-[0.5rem] whitespace-nowrap border border-[rgb(56,55,55)] last:rounded-b-[5px]", "group", { - "bg-[#d1836a]": active, + "bg-accent": active, "bg-[rgb(36,36,36)]": !active } )} diff --git a/src/components/Navbar/Navbar.tsx b/src/components/Navbar/Navbar.tsx index 69d5acc..fc2f34a 100644 --- a/src/components/Navbar/Navbar.tsx +++ b/src/components/Navbar/Navbar.tsx @@ -102,7 +102,7 @@ function Navbar(): ReactNode { ElementsStore.addElements(elements), ImageElementStore.saveImages() ); - + await Promise.all(promises); setSaveStatus("saved"); @@ -203,7 +203,8 @@ function Navbar(): ReactNode { // Erase everything... await LayersStore.clearStore(); await ElementsStore.clearStore(); - await ImageElementStore.clearStore(); + await ImageElementStore.clearStore(); + window.localStorage.clear(); resetLayersAndElements(); // Reset the Zustand state. // Upload the image. @@ -307,7 +308,7 @@ function Navbar(): ReactNode { return (
-