diff --git a/src/components/Canvas/Canvas.tsx b/src/components/Canvas/Canvas.tsx index 8536fde..5730292 100644 --- a/src/components/Canvas/Canvas.tsx +++ b/src/components/Canvas/Canvas.tsx @@ -1,28 +1,40 @@ // Lib -import { forwardRef, useEffect, useImperativeHandle, useRef } from "react"; +import { + forwardRef, + useEffect, + useImperativeHandle, + useMemo, + useRef +} from "react"; import { parseColor } from "react-aria-components"; import { useShallow } from "zustand/react/shallow"; import useStoreSubscription from "@/state/hooks/useStoreSubscription"; import useStore from "@/state/hooks/useStore"; - -// Types -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"; +import ElementsStore from "@/state/stores/ElementsStore"; +import LayersStore from "@/state/stores/LayersStore"; +import ImageElementStore from "@/state/stores/ImageElementStore"; -// Styles using Tailwind +// Types +import type { + Dispatch, + MouseEvent as ReactMouseEvent, + SetStateAction +} from "react"; +import { type Coordinates, CanvasElementPath } from "@/types"; +import useStoreContext from "@/state/hooks/useStoreContext"; type CanvasProps = { - isGrabbing: boolean; + setLoading: Dispatch>; }; const THROTTLE_DELAY_MS = 10; // milliseconds const Canvas = forwardRef(function Canvas( - { isGrabbing }, + { setLoading }, ref ) { const { @@ -30,14 +42,16 @@ const Canvas = forwardRef(function Canvas( shape, width, height, - dpi, + drawPaperCanvas, changeMode, changeColor, createElement, getActiveLayer, pushHistory, getPointerPosition, - centerCanvas + centerCanvas, + setElements, + setLayers } = useStore( useShallow((state) => ({ mode: state.mode, @@ -52,15 +66,16 @@ const Canvas = forwardRef(function Canvas( getActiveLayer: state.getActiveLayer, pushHistory: state.pushHistory, getPointerPosition: state.getPointerPosition, - drawCanvas: state.drawCanvas, - centerCanvas: state.centerCanvas + drawPaperCanvas: state.drawPaperCanvas, + centerCanvas: state.centerCanvas, + setElements: state.setElements, + setLayers: state.setLayers })) ); + const store = useStoreContext(); const { setRef } = useCanvasRef(); const color = useStoreSubscription((state) => state.color); - const strokeWidth = useStoreSubscription((state) => state.strokeWidth); const shapeMode = useStoreSubscription((state) => state.shapeMode); - const opacity = useStoreSubscription((state) => state.opacity); const isDrawing = useRef(false); const canvasRef = useRef(null); @@ -79,7 +94,11 @@ const Canvas = forwardRef(function Canvas( const canvas = e.currentTarget; - if (!canvas) throw new Error("No active layer found. This is a bug."); + const onCanvas = e.target === canvas || canvas.contains(e.target as Node); + + if (!onCanvas) { + return; + } const ctx = canvas.getContext("2d", { willReadFrequently: true @@ -89,13 +108,6 @@ const Canvas = forwardRef(function Canvas( throw new Error("Couldn't get the 2D context of the canvas."); } - ctx.globalAlpha = mode === "eraser" ? 1 : opacity.current; - - // Clip the drawing to the bounds of the canvas - ctx.save(); - ctx.rect(0, 0, width, height); - ctx.clip(); - // Calculate the position of the mouse relative to the canvas. const { x, y } = getPointerPosition(canvas, e.clientX, e.clientY); const floorX = Math.floor(x); @@ -112,7 +124,7 @@ const Canvas = forwardRef(function Canvas( currentPath2D.current.moveTo(floorX, floorY); // Save the current path. currentPath.current.push({ x: floorX, y: floorY, startingPoint: true }); - } else if (mode === "eye_drop" && !isGrabbing) { + } else if (mode === "eye_drop") { // `getPointerPosition` gives us the position in world coordinates, // but we need the position in canvas coordinates for `getImageData`. const rect = canvas.getBoundingClientRect(); @@ -138,7 +150,7 @@ const Canvas = forwardRef(function Canvas( const onCanvas = e.target === canvas || canvas.contains(e.target as Node); - if (e.buttons !== 1 || !isDrawing.current || isGrabbing) { + if (e.buttons !== 1 || !isDrawing.current || !onCanvas) { return; } if (mode === "shapes" || !currentPath2D.current) { @@ -153,22 +165,26 @@ const Canvas = forwardRef(function Canvas( const floorX = Math.floor(x); const floorY = Math.floor(y); - ctx.globalCompositeOperation = - mode === "eraser" ? "destination-out" : "source-over"; - ctx.fillStyle = color.current; - ctx.strokeStyle = color.current; - ctx.lineWidth = strokeWidth.current * dpi; const currentShapeMode = shapeMode.current; + redrawCanvas(); + ctx.save(); + ctx.rect(0, 0, width, height); + ctx.clip(); + switch (mode) { case "brush": case "eraser": { - if (!onCanvas) return; - ctx.lineWidth = strokeWidth.current * dpi; - ctx.lineCap = "round"; - ctx.lineJoin = "round"; - - currentPath2D.current.lineTo(floorX, floorY); + 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 + ); ctx.stroke(currentPath2D.current); currentPath.current.push({ @@ -176,12 +192,12 @@ const Canvas = forwardRef(function Canvas( y: floorY, startingPoint: false }); - // drawCanvas(activeLayer); + + drawPaperCanvas(ctx, 0, 0); break; } case "shapes": { - redrawCanvas(); if (shape === "circle") { const width = x - initialPosition.current.x; const height = y - initialPosition.current.y; @@ -240,24 +256,28 @@ const Canvas = forwardRef(function Canvas( break; } } + ctx.restore(); }, THROTTLE_DELAY_MS); // Handler for when the mouse is moved on the canvas. // This should handle a majority of the drawing process. const onMouseUp = (e: ReactMouseEvent) => { - if (isGrabbing) return; isDrawing.current = false; const activeLayer = getActiveLayer(); const canvas = e.currentTarget; + const onCanvas = e.target === canvas || canvas.contains(e.target as Node); + + if (!onCanvas || activeLayer.hidden) { + return; + } + const ctx = canvas.getContext("2d"); if (!ctx) throw new Error("Couldn't get the 2D context of the canvas."); - ctx.restore(); - const { x, y } = getPointerPosition(canvas, e.clientX, e.clientY); const { x: initX, y: initY } = initialPosition.current; @@ -304,7 +324,9 @@ const Canvas = forwardRef(function Canvas( useImperativeHandle(ref, () => canvasRef.current!, []); - useCanvasRedrawListener(canvasRef); + const debounceRedraw = useMemo(() => false, []); + + useCanvasRedrawListener(canvasRef, undefined, debounceRedraw); useEffect(() => { document.addEventListener("mousemove", onMouseMove); @@ -317,8 +339,45 @@ const Canvas = forwardRef(function Canvas( const ref = canvasRef.current; if (!ref) return; - centerCanvas(ref); - }, [centerCanvas]); + const persistStoreName = store.persist.getOptions().name; + + if (!persistStoreName) return; + + const savedStateExists = localStorage.getItem(persistStoreName) !== null; + + if (!savedStateExists) { + centerCanvas(ref); + } + }, [centerCanvas, store]); + + useEffect(() => { + 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, + // and instead use the default layer state. + if (layers.length > 0) { + setLayers( + layers + .sort((a, b) => a[1].position - b[1].position) + .map(([id, { name }], i) => ({ + name, + id, + active: i === 0, + hidden: false + })) + ); + } + setElements(elements.map(([, element]) => element)); + setLoading(false); + redrawCanvas(); + } + + updateLayersAndElements(); + }, [setElements, setLayers, setLoading]); return ( ; - -function CanvasPane({ loading }: CanvasPaneProps): ReactNode { +function CanvasPane(): ReactNode { const { mode, scale, @@ -49,16 +45,10 @@ function CanvasPane({ loading }: CanvasPaneProps): ReactNode { ); const currentShape = useStoreSubscription((state) => state.shape); const currentColor = useStoreSubscription((state) => state.color); - const canvasSpaceRef = useRef(null); const clientPosition = useRef({ x: 0, y: 0 }); const startMovePosition = useRef({ x: 0, y: 0 }); - const [shiftKey, setShiftKey] = useState(false); - const [ctrlKey, setCtrlKey] = useState(false); - const [isGrabbing, setIsGrabbing] = useState(false); const canvasRef = useRef(null); - - const isPanning = mode === "pan"; - const isMoving = mode === "move"; + const [loading, setLoading] = useState(true); // Effect is getting ugly... Might be a good idea to split // this into multiple effects. @@ -66,6 +56,8 @@ function CanvasPane({ loading }: CanvasPaneProps): ReactNode { const canvasSpace = canvasRef.current; if (!canvasSpace) return; + const isPanning = mode === "pan"; + const isMoving = mode === "move"; const isClickingOnSpace = (e: MouseEvent) => e.target === canvasSpace || canvasSpace.contains(e.target as Node); @@ -74,40 +66,10 @@ function CanvasPane({ loading }: CanvasPaneProps): ReactNode { clientPosition.current = { x: e.clientX, y: e.clientY }; startMovePosition.current = { x: e.clientX, y: e.clientY }; - const isOnCanvas = isClickingOnSpace(e); - - if (!isOnCanvas) return; - - if ( - mode === "text" && - !shiftKey && - !document.activeElement?.classList.contains("element") && - !document.activeElement?.classList.contains("grid") && - !document.activeElement?.classList.contains("handle") - ) { - const layer = getActiveLayer(); - - if (!layer) throw new Error("No active layer found"); - - createElement("text", { - x: e.clientX, - y: e.clientY, - width: 100, - height: 30, - text: { - size: 25, - family: "Times New Roman", - content: "Text" - }, - layerId: layer.id - }); - return; - } - setIsGrabbing(isOnCanvas); } function handleMouseMove(e: MouseEvent) { - if (e.buttons !== 1 || !isGrabbing) return; + if (e.buttons !== 1) return; const canvas = canvasRef.current; const layer = getActiveLayer(); @@ -116,7 +78,7 @@ function CanvasPane({ loading }: CanvasPaneProps): ReactNode { let dx = e.clientX - clientPosition.current.x; let dy = e.clientY - clientPosition.current.y; - if (isPanning && isGrabbing) { + if (isPanning) { // TODO: Have to revisit the calculation to know how the canvas is considered off screen. // As a temporary solution, a button in the left toolbar pane is added to reset the canvas view. // const { left, top } = isCanvasOffscreen(canvas, dx, dy); @@ -178,12 +140,6 @@ function CanvasPane({ loading }: CanvasPaneProps): ReactNode { } }); } - setIsGrabbing(false); - } - - function handleKeyDown(e: KeyboardEvent) { - setShiftKey(e.shiftKey); - setCtrlKey(e.ctrlKey); } function handleZoom(e: Event) { @@ -192,6 +148,7 @@ function CanvasPane({ loading }: CanvasPaneProps): ReactNode { if (e instanceof WheelEvent) { if (e.ctrlKey) { // Ctrl key means we are zooming. + e.preventDefault(); performZoom(e.clientX, e.clientY, e.deltaY / 10); } else if (e.shiftKey) { // Shift key means we are panning horizontally. @@ -223,32 +180,20 @@ function CanvasPane({ loading }: CanvasPaneProps): ReactNode { document.addEventListener("wheel", handleZoom); document.addEventListener("click", handleZoom); - // Handle shift key press - document.addEventListener("keydown", handleKeyDown); - document.addEventListener("keyup", handleKeyDown); - return () => { document.removeEventListener("mousedown", handleMouseDown); document.removeEventListener("mousemove", handleMouseMove); document.removeEventListener("mouseup", handleMouseUp); document.removeEventListener("wheel", handleZoom); document.removeEventListener("click", handleZoom); - - document.removeEventListener("keydown", handleKeyDown); - document.removeEventListener("keyup", handleKeyDown); }; }, [ mode, - isMoving, - isPanning, - isGrabbing, changeElementProperties, createElement, changeX, changeY, getActiveLayer, - shiftKey, - ctrlKey, currentShape, currentColor, pushHistory, @@ -262,15 +207,12 @@ function CanvasPane({ loading }: CanvasPaneProps): ReactNode { data-testid="canvas-pane" > {(mode === "brush" || mode == "eraser") && ( - + )} diff --git a/src/components/CanvasPointerMarker/CanvasPointerMarker.tsx b/src/components/CanvasPointerMarker/CanvasPointerMarker.tsx index 7d984fd..036d875 100644 --- a/src/components/CanvasPointerMarker/CanvasPointerMarker.tsx +++ b/src/components/CanvasPointerMarker/CanvasPointerMarker.tsx @@ -2,32 +2,27 @@ import { useState, useEffect, useRef } from "react"; import useStore from "@/state/hooks/useStore"; import { useShallow } from "zustand/react/shallow"; -import * as Utils from "@/lib/utils"; + // Types import type { ReactNode, RefObject } from "react"; import type { Coordinates } from "@/types"; type CanvasPointerMarker = { - canvasSpaceReference: RefObject; - shiftKey: boolean; + canvasSpaceReference: RefObject; }; function CanvasPointerMarker({ - canvasSpaceReference, - shiftKey + canvasSpaceReference }: CanvasPointerMarker): ReactNode { - const { mode, scale, strokeWidth, deleteElement } = useStore( + const { scale, strokeWidth } = useStore( useShallow((state) => ({ - mode: state.mode, scale: state.scale, - strokeWidth: state.strokeWidth, - deleteElement: state.deleteElement + strokeWidth: state.strokeWidth })) ); const ref = useRef(null); const [position, setPosition] = useState({ x: 0, y: 0 }); - const [isVisible, setIsVisible] = useState(false); const POINTER_SIZE = strokeWidth * scale; @@ -35,17 +30,9 @@ function CanvasPointerMarker({ const canvasSpace = canvasSpaceReference.current; if (!canvasSpace) return; - const hoveringOverSpace = (e: MouseEvent) => - e.target === canvasSpace || canvasSpace.contains(e.target as Node); function computeCoordinates(e: MouseEvent) { if (!canvasSpace) return; - if (hoveringOverSpace(e)) { - setIsVisible(true); - } else { - setIsVisible(false); - } - const { left, top, width, height } = canvasSpace.getBoundingClientRect(); let newX; let newY; @@ -83,38 +70,7 @@ function CanvasPointerMarker({ return () => { document.removeEventListener("mousemove", computeCoordinates); }; - }, [canvasSpaceReference, POINTER_SIZE]); - - useEffect(() => { - const canvasSpace = canvasSpaceReference.current; - - if (!canvasSpace) return; - - const isIntersecting = (e: MouseEvent) => { - const m = ref.current; - - if (!m) return; - - const elements = document.getElementsByClassName("element"); - - for (let i = 0; i < elements.length; i++) { - const node = elements[i]; - if ( - Utils.isRectIntersecting(m, node) && - mode === "eraser" && - e.buttons === 1 - ) { - deleteElement((element) => element.id === node.id); - } - } - }; - - canvasSpace.addEventListener("mousemove", isIntersecting); - - return () => { - canvasSpace.removeEventListener("mousemove", isIntersecting); - }; - }, [deleteElement, mode, canvasSpaceReference]); + }, [POINTER_SIZE, canvasSpaceReference]); return (
); diff --git a/src/components/DrawingToolbar/DrawingToolbar.tsx b/src/components/DrawingToolbar/DrawingToolbar.tsx index 51eab43..a0aa7c0 100644 --- a/src/components/DrawingToolbar/DrawingToolbar.tsx +++ b/src/components/DrawingToolbar/DrawingToolbar.tsx @@ -1,22 +1,22 @@ // Lib import { SHAPES } from "@/state/store"; -import { memo, Fragment } from "react"; +import { memo, Fragment, useCallback } from "react"; import useStore from "@/state/hooks/useStore"; import { useShallow } from "zustand/react/shallow"; import cn from "@/lib/tailwind-utils"; // Type -import type { ReactNode, ChangeEvent, MouseEvent, ReactElement } from "react"; +import type { ReactNode, MouseEvent, ReactElement } from "react"; import type { Shape } from "@/types"; // Components import ShapeOption from "@/components/ShapeOption/ShapeOption"; +import DrawingToolbarRangeConfig from "../DrawingToolbarRangeConfig/DrawingToolbarRangeConfig"; // Icons import Square from "@/components/icons/Square/Square"; import Circle from "@/components/icons/Circle/Circle"; import Triangle from "../icons/Triangle/Triangle"; -import Brush from "../icons/Brush/Brush"; const MemoizedShapeOption = memo(ShapeOption); @@ -30,9 +30,7 @@ function DrawingToolbar(): ReactNode { const { mode, shape, - opacity, shapeMode, - strokeWidth, changeStrokeWidth, changeOpacity, changeShapeMode @@ -40,32 +38,13 @@ function DrawingToolbar(): ReactNode { useShallow((state) => ({ mode: state.mode, shape: state.shape, - opacity: state.opacity, shapeMode: state.shapeMode, - strokeWidth: state.strokeWidth, changeStrokeWidth: state.changeStrokeWidth, changeOpacity: state.changeOpacity, changeShapeMode: state.changeShapeMode })) ); - const strengthSettings = { - value: strokeWidth, - min: 1, - max: 100 - }; - - const handleStrengthChange = (e: React.ChangeEvent) => { - const strength = Number(e.target.value); - - changeStrokeWidth(strength); - }; - - const onOpacityChange = (e: ChangeEvent) => { - const value = Number(e.target.value); - changeOpacity(value); - }; - const renderedShapes = ( {SHAPES.map((s) => ( @@ -112,42 +91,33 @@ function DrawingToolbar(): ReactNode { ); const renderedStrength = ( - - Stroke Width - - - {strengthSettings.value} - - + state.strokeWidth, [])} + type="range" + min={1} + max={100} + step={1} + onFinalizedChange={useCallback( + (value: number) => changeStrokeWidth(value), + [changeStrokeWidth] + )} + /> ); const renderedOpacity = ( - - - - + state.opacity, [])} + type="range" + min={0.01} + max={1} + step={0.01} + onFinalizedChange={useCallback( + (value: number) => changeOpacity(value), + [changeOpacity] + )} + /> ); const additionalSettings: ReactNode[] = []; @@ -184,7 +154,7 @@ function DrawingToolbar(): ReactNode { return (
, + "value" | "onChange" | "disabled" +> & { + label: string; + selector: (state: SliceStores) => number; + onFinalizedChange?: (value: number) => void; +}; + +function DrawingToolbarRangeConfig({ + label, + selector, + onFinalizedChange, + min, + max, + step, + ...props +}: DrawingToolbarRangeConfigProps) { + const [configValue, setConfigValue] = useState(useStore(selector)); + const [isEditing, setIsEditing] = useState(false); + + function onDoubleClick() { + setIsEditing(true); + } + + function onUpdate() { + setIsEditing(false); + onFinalizedChange?.(configValue); + } + + function onEnterPress(e: KeyboardEvent) { + if (e.key === "Enter") { + onUpdate(); + } + } + + function onChange(e: ChangeEvent) { + const value = Number(e.target.value); + const minNum = Number(min ?? 0); + const maxNum = Number(max ?? 100); + setConfigValue(Math.max(Math.min(value, maxNum), minNum)); + } + + return ( + <> + {label} + + {isEditing ? ( + + ) : ( + + {configValue} + + )} + + ); +} + +export default DrawingToolbarRangeConfig; diff --git a/src/components/LayerPreview/LayerPreview.tsx b/src/components/LayerPreview/LayerPreview.tsx index 666ef80..f5730f1 100644 --- a/src/components/LayerPreview/LayerPreview.tsx +++ b/src/components/LayerPreview/LayerPreview.tsx @@ -1,6 +1,7 @@ // Lib -import { useRef } from "react"; +import { useEffect, useRef } from "react"; import useCanvasRedrawListener from "@/state/hooks/useCanvasRedrawListener"; +import useStore from "@/state/hooks/useStore"; // Types import type { ReactNode } from "react"; @@ -15,7 +16,16 @@ const PREVIEW_DRAW = true; function LayerPreview({ id }: LayerPreviewProps): ReactNode { const previewRef = useRef(null); + const drawCanvas = useStore((state) => state.drawCanvas); + // Initial draw + useEffect(() => { + const canvas = previewRef.current; + if (!canvas) return; + drawCanvas(canvas, canvas, { layerId: id, preview: PREVIEW_DRAW }); + }, [drawCanvas, id]); + + // Then listen for future redraws useCanvasRedrawListener(previewRef, id, DEBOUNCE_REDRAW, PREVIEW_DRAW); return ( diff --git a/src/components/Main/Main.tsx b/src/components/Main/Main.tsx index 663d6e8..813f743 100644 --- a/src/components/Main/Main.tsx +++ b/src/components/Main/Main.tsx @@ -1,9 +1,5 @@ // Lib -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"; @@ -13,54 +9,11 @@ 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"; -import ImageElementStore from "@/state/stores/ImageElementStore"; function Main(): ReactNode { - 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); - - useEffect(() => { - async function updateLayersAndElements() { - 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, - // and instead use the default layer state. - if (layers.length > 0) { - setLayers( - layers - .sort((a, b) => b[1].position - a[1].position) - .map(([id, { name }], i) => ({ - name, - id, - active: i === 0, - hidden: false - })) - ); - } - setElements(elements.map(([, element]) => element)); - setLoading(false); - redrawCanvas(); - } - - updateLayersAndElements(); - }, [setElements, setLayers, loadCanvasProperties]); + const refereceWindowEnabled = useStore( + (state) => state.referenceWindowEnabled + ); return (
- + {/* Reference window */} {refereceWindowEnabled && } diff --git a/src/components/Navbar/Navbar.tsx b/src/components/Navbar/Navbar.tsx index a72756c..b568e12 100644 --- a/src/components/Navbar/Navbar.tsx +++ b/src/components/Navbar/Navbar.tsx @@ -39,6 +39,7 @@ import { } from "@/components/ui/menubar"; import NavbarFileSaveStatus from "../NavbarFileSaveStatus/NavbarFileSaveStatus"; import ImageElementStore from "@/state/stores/ImageElementStore"; +import useStoreContext from "@/state/hooks/useStoreContext"; function Navbar(): ReactNode { const { @@ -67,6 +68,7 @@ function Navbar(): ReactNode { })) ); const { ref } = useCanvasRef(); + const store = useStoreContext(); const downloadRef = useRef(null); const openFileRef = useRef(null); const [saveStatus, setSaveStatus] = useState<"saving" | "saved" | "error">( @@ -209,7 +211,7 @@ function Navbar(): ReactNode { await LayersStore.clearStore(); await ElementsStore.clearStore(); await ImageElementStore.clearStore(); - window.localStorage.clear(); + store.persist.clearStorage(); resetLayersAndElements(); // Reset the Zustand state. // Upload the image. diff --git a/src/components/StoreContext/StoreContext.tsx b/src/components/StoreContext/StoreContext.tsx index 2de003f..1c4fcc1 100644 --- a/src/components/StoreContext/StoreContext.tsx +++ b/src/components/StoreContext/StoreContext.tsx @@ -1,11 +1,11 @@ // Lib -import { createContext, useRef } from "react"; -import { initializeStore } from "@/state/store"; +import { createContext, useEffect, useRef } from "react"; +import { initializeEditorStore } from "@/state/store"; // Types import type { ReactNode } from "react"; -type InitStore = ReturnType; +type InitStore = ReturnType; type StoreProviderProps = Readonly<{ store?: InitStore; @@ -15,11 +15,14 @@ type StoreProviderProps = Readonly<{ const StoreContext = createContext(undefined); function StoreProvider({ store, children }: StoreProviderProps): ReactNode { - const storeRef = useRef(null); - - if (!storeRef.current) { - storeRef.current = store ?? initializeStore(); - } + const storeRef = useRef(store ?? initializeEditorStore()); + + useEffect(() => { + const persist = storeRef.current.persist; + if (!persist.hasHydrated()) { + persist.rehydrate(); + } + }, []); return ( diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx new file mode 100644 index 0000000..7a24863 --- /dev/null +++ b/src/components/ui/input.tsx @@ -0,0 +1,46 @@ +// Lib +import cn from "@/lib/tailwind-utils"; + +// Types +import type { ComponentProps, ReactNode } from "react"; + +type InputProps = ComponentProps<"input"> & { + icon?: ReactNode; +}; + +function Input({ className, type, icon, ...props }: InputProps) { + if (icon) { + return ( +
+ + {icon} + + +
+ ); + } + return ( + + ); +} + +export default Input; diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 69ce15f..60e1ac7 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -224,7 +224,7 @@ function detectOperatingSystem(): OperatingSystem { /** * - * @param noChange Whether visually, nothing may have no changed. + * @param noChange Whether visually, nothing may have not changed. */ function redrawCanvas(noChange: boolean = false) { document.dispatchEvent( diff --git a/src/pages/index/index.page.tsx b/src/pages/index/index.page.tsx index 68bce03..b73670b 100644 --- a/src/pages/index/index.page.tsx +++ b/src/pages/index/index.page.tsx @@ -2,7 +2,7 @@ import { useEffect } from "react"; import LayersStore from "@/state/stores/LayersStore"; import ElementsStore from "@/state/stores/ElementsStore"; -import { initializeStore } from "@/state/store"; +import { initializeEditorStore } from "@/state/store"; import useInitialEditorState from "@/state/hooks/useInitialEditorState"; // Components @@ -50,7 +50,7 @@ function Page() { }, []); return ( - + diff --git a/src/renderer/_default.page.server.tsx b/src/renderer/_default.page.server.tsx index 2df7d4a..b18de1c 100644 --- a/src/renderer/_default.page.server.tsx +++ b/src/renderer/_default.page.server.tsx @@ -12,7 +12,7 @@ import { escapeInject } from "vite-plugin-ssr/server"; import logo from "@/assets/icons/IdeaDrawnNewLogo.png"; import type { PageContextServer } from "./types"; import { renderToStream } from "react-streaming/server"; -import { initializeStore } from "@/state/store"; +import { initializeEditorStore } from "@/state/store"; import type { SliceStores } from "@/types"; import { ThemeProvider } from "@/components/ThemeProvider/ThemeProvider"; @@ -22,7 +22,7 @@ async function render(pageContext: PageContextServer) { if (!Page) throw new Error("My render() hook expects pageContext.Page to be defined"); - const store = initializeStore(); + const store = initializeEditorStore(); const preloadedState = store.getState(); const stateWithoutFunctions: Partial = Object.fromEntries( Object.entries(preloadedState).filter( diff --git a/src/state/hooks/useCanvasRedrawListener.ts b/src/state/hooks/useCanvasRedrawListener.ts index 495e940..4493c8a 100644 --- a/src/state/hooks/useCanvasRedrawListener.ts +++ b/src/state/hooks/useCanvasRedrawListener.ts @@ -3,7 +3,6 @@ import useStore from "./useStore"; import useThrottle from "./useThrottle"; import useDebounceCallback from "./useDebounceCallback"; import { CanvasRedrawEvent } from "@/types"; -import useCanvasRef from "./useCanvasRef"; const DEBOUNCE_TIME_MS = 500; @@ -26,19 +25,15 @@ function useCanvasRedrawListener( preview: boolean = false ): void { const drawCanvas = useStore((state) => state.drawCanvas); - const { ref } = useCanvasRef(); const draw = useCallback( (canvas: HTMLCanvasElement, layerId?: string) => { - if (!ref) { - throw new Error("Canvas ref does not exist."); - } - drawCanvas(canvas, ref, { layerId, preview }); + drawCanvas(canvas, canvas, { layerId, preview }); // requestAnimationFrame(() => { // draw(canvas, layerId); // }); }, - [drawCanvas, preview, ref] + [drawCanvas, preview] ); const handleCanvasRedraw = useThrottle((e: CanvasRedrawEvent) => { diff --git a/src/state/slices/canvasElementsSlice.ts b/src/state/slices/canvasElementsSlice.ts index f4918a4..a503de0 100644 --- a/src/state/slices/canvasElementsSlice.ts +++ b/src/state/slices/canvasElementsSlice.ts @@ -7,7 +7,7 @@ import type { CanvasElementType } from "@/types"; -type Predicate = (element: CanvasElement) => boolean; +type Predicate = (arg: A) => boolean; export const createCanvasElementsSlice: StateCreator< SliceStores, @@ -43,7 +43,7 @@ export const createCanvasElementsSlice: StateCreator< layerId: layer.id, ...properties, // Override the default properties with the provided properties, if any. drawType: shapeMode, - strokeWidth, + strokeWidth: Math.max(1, strokeWidth), opacity: type === "eraser" ? 1 : opacity }; @@ -56,7 +56,7 @@ export const createCanvasElementsSlice: StateCreator< function changeElementProperties( callback: (el: CanvasElement) => CanvasElement, - predicate: Predicate + predicate: Predicate ) { const elements = get().elements; const newElements = elements.map((element) => { @@ -80,7 +80,7 @@ export const createCanvasElementsSlice: StateCreator< }); } - function deleteElement(predicate: Predicate) { + function deleteElement(predicate: Predicate) { const deletedIds: string[] = []; set((state) => ({ elements: state.elements.filter((element) => { @@ -99,7 +99,7 @@ export const createCanvasElementsSlice: StateCreator< set({ elements }); } - function copyElement(predicate: Predicate) { + function copyElement(predicate: Predicate) { const elements = get().elements; const copiedElements = elements.filter((element) => predicate(element)); set({ copiedElements }); diff --git a/src/state/slices/canvasSlice.ts b/src/state/slices/canvasSlice.ts index 7d61043..0af0258 100644 --- a/src/state/slices/canvasSlice.ts +++ b/src/state/slices/canvasSlice.ts @@ -10,8 +10,7 @@ import type { Shape, SliceStores, DrawOptions, - CanvasElement, - CanvasState + CanvasElement } from "@/types"; import * as Utils from "@/lib/utils"; import ImageElementStore from "../stores/ImageElementStore"; @@ -247,21 +246,6 @@ export const createCanvasSlice: StateCreator< return { layers, elements }; } - function loadCanvasProperties() { - const str = window.localStorage.getItem("canvas-properties"); - - if (!str) { - console.warn("Cannot load 'canvas-properties'"); - return; - } - - const { width, height, background } = JSON.parse(str) as Pick< - CanvasState, - "width" | "height" | "background" - >; - 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. @@ -417,11 +401,9 @@ export const createCanvasSlice: StateCreator< ctx: CanvasRenderingContext2D, x: number, y: number, - width: number, - height: number, - background: string, preview: boolean = false ) { + const { width, height, background } = get(); ctx.beginPath(); ctx.rect(x, y, width, height); ctx.globalCompositeOperation = "destination-over"; @@ -467,13 +449,16 @@ export const createCanvasSlice: StateCreator< options?: DrawOptions ) { const { + mode, elements, - background, layers, width: canvasWidth, height: canvasHeight, position: { x: posX, y: posY }, - scale + scale, + opacity, + strokeWidth, + color } = get(); if (layers.length === 0) { @@ -552,11 +537,16 @@ export const createCanvasSlice: StateCreator< case "brush": case "eraser": { ctx.beginPath(); - for (const point of element.path) { + for (let i = 0; i < element.path.length; i++) { + const point = element.path[i]; if (point.startingPoint) { ctx.moveTo(point.x, point.y); } else { - ctx.lineTo(point.x, point.y); + const lastPoint = element.path[i - 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); } } ctx.stroke(); @@ -626,17 +616,22 @@ export const createCanvasSlice: StateCreator< } // Finally, draw the paper canvas (background) - drawPaperCanvas( - ctx, - 0, - 0, - canvasWidth, - canvasHeight, - background, - options?.preview - ); + drawPaperCanvas(ctx, 0, 0, options?.preview); ctx.restore(); + + // After drawing everything, reset the styles back to the current settings + // if not in preview mode. This is for the main canvas where the user draws, + // and we want to keep style settings persistent. + if (!options?.preview) { + ctx.globalAlpha = opacity; + ctx.strokeStyle = color; + ctx.fillStyle = color; + ctx.lineWidth = strokeWidth; + ctx.globalCompositeOperation = + mode === "eraser" ? "destination-out" : "source-over"; + ctx.lineCap = "round"; + } } function resetLayersAndElements() { @@ -686,12 +681,12 @@ export const createCanvasSlice: StateCreator< changeX, changeY, prepareForSave, - loadCanvasProperties, prepareForExport, toggleReferenceWindow, getPointerPosition, isCanvasOffscreen, centerCanvas, + drawPaperCanvas, drawCanvas, resetLayersAndElements }; diff --git a/src/state/store.ts b/src/state/store.ts index 1c8afe2..c952d3f 100644 --- a/src/state/store.ts +++ b/src/state/store.ts @@ -3,17 +3,20 @@ import { subscribeWithSelector } from "zustand/middleware"; import { createCanvasSlice } from "./slices/canvasSlice"; import { createHistorySlice } from "./slices/historySlice"; import { createCanvasElementsSlice } from "./slices/canvasElementsSlice"; +import { persist } from "zustand/middleware"; import type { Modes, Shapes, SliceStores } from "../types"; -export type Store = ReturnType; +export type Store = ReturnType; /** * Creates a Zustand store that combines the canvas, history, and canvas elements slices. * @param preloadedState The preloaded state to initialize the store with. * @returns A Zustand store. */ -export function initializeStore(preloadedState: Partial = {}) { +export function initializeEditorStore( + preloadedState: Partial = {} +) { // `structuredClone` throws an error if the object contains functions as // functions are not serializable. We need to remove the functions from the // preloaded state before passing it to `structuredClone`. @@ -24,19 +27,34 @@ export function initializeStore(preloadedState: Partial = {}) { ); return createStore()( - subscribeWithSelector((...a) => ({ - ...createCanvasSlice(...a), - ...createHistorySlice(...a), - ...createCanvasElementsSlice(...a), - // We want to call structuredClone on the preloadedState to ensure that it is a deep clone. - // This ensures that the preloadedState is not mutated when the store is initialized. - // This is beneficial for testing purposes, but also ensures that the preloadedState is not mutated - // when the store is initialized. - ...preloadedState, // spread the state to pass the functions, if any. - // Spread the state without functions to ensure that the functions are not serialized and - // the values are not mutated. - ...structuredClone(stateWithoutFunctions) - })) + subscribeWithSelector( + persist( + (...a) => ({ + ...createCanvasSlice(...a), + ...createHistorySlice(...a), + ...createCanvasElementsSlice(...a), + // We want to call structuredClone on the preloadedState to ensure that it is a deep clone. + // This ensures that the preloadedState is not mutated when the store is initialized. + // This is beneficial for testing purposes, but also ensures that the preloadedState is not mutated + // when the store is initialized. + ...preloadedState, // spread the state to pass the functions, if any. + // Spread the state without functions to ensure that the functions are not serialized and + // the values are not mutated. + ...structuredClone(stateWithoutFunctions) + }), + { + name: "live-canvas-store", + partialize: (state) => ({ + width: state.width, + height: state.height, + position: state.position, + scale: state.scale + }), + // Need to skip hydration to avoid using browser-only APIs during SSR. + skipHydration: true + } + ) + ) ); } diff --git a/src/state/stores/ImageElementStore.ts b/src/state/stores/ImageElementStore.ts index a022623..cf3ca7e 100644 --- a/src/state/stores/ImageElementStore.ts +++ b/src/state/stores/ImageElementStore.ts @@ -75,11 +75,11 @@ export default class ImageElementStore extends BaseStore { } public static openStore() { - this.open(); + return this.open(); } public static closeStore() { - this.close(); + return this.close(); } public static clearStore() { diff --git a/src/tests/test-utils.tsx b/src/tests/test-utils.tsx index 0e8bc2b..1206f5c 100644 --- a/src/tests/test-utils.tsx +++ b/src/tests/test-utils.tsx @@ -11,7 +11,7 @@ import type { Store } from "@/state/store"; import { render, renderHook } from "@testing-library/react"; import { StoreProvider } from "@/components/StoreContext/StoreContext"; -import { initializeStore } from "@/state/store"; +import { initializeEditorStore } from "@/state/store"; import { ThemeProvider } from "@/components/ThemeProvider/ThemeProvider"; type ExtendedRenderOptions = Omit & { @@ -35,7 +35,7 @@ export function renderWithProviders( ui: ReactNode, { preloadedState = {}, - store = initializeStore(preloadedState), + store = initializeEditorStore(preloadedState), ...renderOptions }: ExtendedRenderOptions = {} ): RenderResult { @@ -58,7 +58,7 @@ export function renderHookWithProviders( hook: (props: Props) => Result, { preloadedState = {}, - store = initializeStore(preloadedState), + store = initializeEditorStore(preloadedState), ...renderOptions }: ExtendedRenderHookOptions = {} ): RenderHookResult { diff --git a/src/types/Slices.types.ts b/src/types/Slices.types.ts index f05804f..b475137 100644 --- a/src/types/Slices.types.ts +++ b/src/types/Slices.types.ts @@ -65,7 +65,6 @@ export type CanvasStore = CanvasState & { changeY: (payload: number) => void; toggleReferenceWindow: () => void; prepareForSave: () => SavedCanvasProperties; - loadCanvasProperties: () => void; prepareForExport: (ref: HTMLCanvasElement, quality?: number) => Promise; drawCanvas: ( baseCanvas: HTMLCanvasElement, @@ -86,6 +85,12 @@ export type CanvasStore = CanvasState & { top: boolean; }; centerCanvas: (ref: HTMLCanvasElement) => void; + drawPaperCanvas: ( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + preview?: boolean + ) => void; resetLayersAndElements: () => void; };