From cdf79a751607d6f02a5abc218078280e3e54996e Mon Sep 17 00:00:00 2001 From: Leo Hong <5917188+low-earth-orbit@users.noreply.github.com> Date: Mon, 26 Aug 2024 19:33:28 -0300 Subject: [PATCH] Add rectangle --- components/Canvas.tsx | 167 +++++++++++++++++++++----------- components/FreeDrawLayer.tsx | 34 +++++++ components/ShapesLayer.tsx | 60 ++++++++++++ components/Toolbar.tsx | 40 +++++--- components/shapes/Rectangle.tsx | 86 ++++++++++++++++ package-lock.json | 24 ++++- package.json | 4 +- 7 files changed, 343 insertions(+), 72 deletions(-) create mode 100644 components/FreeDrawLayer.tsx create mode 100644 components/ShapesLayer.tsx create mode 100644 components/shapes/Rectangle.tsx diff --git a/components/Canvas.tsx b/components/Canvas.tsx index 0f426d5..df2df71 100644 --- a/components/Canvas.tsx +++ b/components/Canvas.tsx @@ -1,61 +1,68 @@ "use client"; import React, { useEffect, useRef, useState } from "react"; -import { Stage, Layer, Line } from "react-konva"; import Toolbar from "./Toolbar"; +import FreeDrawLayer from "./FreeDrawLayer"; +import { v4 as uuid } from "uuid"; +import { Stage } from "react-konva"; +import ShapesLayer from "./ShapesLayer"; -interface LineType { +export interface LineType { tool: string; points: number[]; - color: string; + stroke: string; strokeWidth: number; } +export interface ShapeType { + id: string; + // shapeType: string; + x: number; + y: number; + width: number; + height: number; + stroke: string; + strokeWidth: number; +} + +export interface StageSizeType { + width: number; + height: number; +} + export default function Canvas() { const [tool, setTool] = useState("pen"); const [lines, setLines] = useState([]); + const [shapes, setShapes] = useState([]); + const [color, setColor] = useState("#0000FF"); const [strokeWidth, setStrokeWidth] = useState(5); - const isDrawing = useRef(false); - const [stageSize, setStageSize] = useState<{ - width: number; - height: number; - }>(); + const [stageSize, setStageSize] = useState(); - const handleMouseDown = (e: any) => { - isDrawing.current = true; - const pos = e.target.getStage().getPointerPosition(); - setLines([ - ...lines, + const isFreeDrawing = useRef(false); + + const [selectedShapeId, setSelectedShapeId] = useState(""); + + const addRectangle = () => { + setShapes([ + ...shapes, { - tool, - points: [pos.x, pos.y], - color: color, + id: uuid(), + x: stageSize ? stageSize.width / 2 - 100 : 0, + y: stageSize ? stageSize.height / 2 - 50 : 0, + width: 200, + height: 100, + stroke: color, strokeWidth: strokeWidth, }, ]); }; - const handleMouseMove = (e: any) => { - // no drawing - skipping - if (!isDrawing.current) { - return; - } - const stage = e.target.getStage(); - const point = stage.getPointerPosition(); - let lastLine = lines[lines.length - 1]; - // add point - lastLine.points = lastLine.points.concat([point.x, point.y]); - - // replace last - lines.splice(lines.length - 1, 1, lastLine); - setLines(lines.concat()); - }; - - const handleMouseUp = () => { - isDrawing.current = false; - }; + function resetCanvas() { + setLines([]); + setShapes([]); + } useEffect(() => { const handleResize = () => { @@ -79,40 +86,88 @@ export default function Canvas() { return null; } + const handleMouseDown = (e: any) => { + if (selectedShapeId === "") { + isFreeDrawing.current = true; + const pos = e.target.getStage().getPointerPosition(); + setLines([ + ...lines, + { + tool, + points: [pos.x, pos.y], + stroke: color, + strokeWidth: strokeWidth, + }, + ]); + } else { + // deselect shapes when clicked on empty area + const clickedOnEmpty = e.target === e.target.getStage(); + if (clickedOnEmpty) { + setSelectedShapeId(""); + } + } + }; + + const handleMouseMove = (e: any) => { + // no drawing - skipping + if (!isFreeDrawing.current || selectedShapeId !== "") { + return; + } + const stage = e.target.getStage(); + const point = stage.getPointerPosition(); + let lastLine = lines[lines.length - 1]; + // add point + lastLine.points = lastLine.points.concat([point.x, point.y]); + + // replace last + lines.splice(lines.length - 1, 1, lastLine); + setLines(lines.concat()); + }; + + const handleMouseUp = () => { + isFreeDrawing.current = false; + }; + return ( -
+ <> - - {lines.map((line, i) => ( - - ))} - + + setLines([])} + resetCanvas={resetCanvas} strokeWidth={strokeWidth} setStrokeWidth={setStrokeWidth} + handleAddRectangle={addRectangle} /> -
+ ); } diff --git a/components/FreeDrawLayer.tsx b/components/FreeDrawLayer.tsx new file mode 100644 index 0000000..b722206 --- /dev/null +++ b/components/FreeDrawLayer.tsx @@ -0,0 +1,34 @@ +import { MutableRefObject, useRef } from "react"; +import { Stage, Layer, Line, Rect } from "react-konva"; +import { LineType, StageSizeType } from "./Canvas"; + +type FreeDrawLayerProps = { + lines: LineType[]; + setLines: (newLines: LineType[]) => void; + tool: string; + color: string; + strokeWidth: number; + stageSize: StageSizeType; + isFreeDrawing: MutableRefObject; +}; + +export default function FreeDrawLayer({ lines }: FreeDrawLayerProps) { + return ( + + {lines.map((line, i) => ( + + ))} + + ); +} diff --git a/components/ShapesLayer.tsx b/components/ShapesLayer.tsx new file mode 100644 index 0000000..f5ae31b --- /dev/null +++ b/components/ShapesLayer.tsx @@ -0,0 +1,60 @@ +import { MutableRefObject, useState } from "react"; +import { Stage, Layer } from "react-konva"; +import { ShapeType, StageSizeType } from "./Canvas"; +import Rectangle from "./shapes/Rectangle"; + +type ShapesLayerProps = { + shapes: ShapeType[]; + setShapes: (newShapes: ShapeType[]) => void; + tool: string; + color: string; + strokeWidth: number; + stageSize: StageSizeType; + isFreeDrawing: MutableRefObject; + selectedShapeId: string; + setSelectedShapeId: (id: string) => void; +}; + +export default function ShapesLayer({ + tool, + shapes, + setShapes, + color, + strokeWidth, + stageSize, + isFreeDrawing, + selectedShapeId, + setSelectedShapeId, +}: ShapesLayerProps) { + return ( + + {shapes.map((shape, i) => { + const shapeProps = { + id: shape.id, + x: shape.x, + y: shape.y, + width: shape.width, + height: shape.height, + strokeWidth: shape.strokeWidth, + stroke: shape.stroke, + }; + + return ( + { + setSelectedShapeId(shape.id); + }} + onChange={(newAttrs: ShapeType) => { + const newShapes = shapes.slice(); // deep copy + newShapes[i] = newAttrs; + setShapes(newShapes); + }} + /> + ); + })} + + ); +} diff --git a/components/Toolbar.tsx b/components/Toolbar.tsx index 3e9cc85..a06a026 100644 --- a/components/Toolbar.tsx +++ b/components/Toolbar.tsx @@ -14,17 +14,9 @@ import PaletteIcon from "@mui/icons-material/Palette"; import { HexColorPicker } from "react-colorful"; import { useState } from "react"; import LineWeightRoundedIcon from "@mui/icons-material/LineWeightRounded"; +import CropSquareRoundedIcon from "@mui/icons-material/CropSquareRounded"; -type ToolbarProps = { - selectTool: (tool: string) => void; - resetCanvas: () => void; - color: string; - selectColor: (newColor: string) => void; - strokeWidth: number; - setStrokeWidth: (newWidth: number) => void; -}; - -function LineWeightSliderValueLabelComponent(props: SliderValueLabelProps) { +function LineWeightSliderValueLabel(props: SliderValueLabelProps) { const { children, value } = props; return ( @@ -34,6 +26,16 @@ function LineWeightSliderValueLabelComponent(props: SliderValueLabelProps) { ); } +type ToolbarProps = { + selectTool: (tool: string) => void; + resetCanvas: () => void; + color: string; + selectColor: (newColor: string) => void; + strokeWidth: number; + setStrokeWidth: (newWidth: number) => void; + handleAddRectangle: () => void; +}; + function Toolbar({ selectTool, resetCanvas, @@ -41,6 +43,7 @@ function Toolbar({ selectColor, strokeWidth, setStrokeWidth, + handleAddRectangle, }: ToolbarProps) { // color picker const [colorPickerAnchorEl, setColorPickerAnchorEl] = @@ -79,16 +82,22 @@ function Toolbar({ const isLineWeightSliderAnchorElOpen = Boolean(lineWeightAnchorEl); return ( -
+
+ {/* pen */} selectTool("pen")}> + {/* shapes */} + + + + {/* line weight */} + {/* eraser */} selectTool("eraser")}> - + + {/* delete */} + @@ -150,7 +162,7 @@ function Toolbar({ max={100} min={1} slots={{ - valueLabel: LineWeightSliderValueLabelComponent, + valueLabel: LineWeightSliderValueLabel, }} aria-label="custom thumb label" value={strokeWidth} diff --git a/components/shapes/Rectangle.tsx b/components/shapes/Rectangle.tsx new file mode 100644 index 0000000..f191722 --- /dev/null +++ b/components/shapes/Rectangle.tsx @@ -0,0 +1,86 @@ +import React, { useEffect } from "react"; +import { createRoot } from "react-dom/client"; +import { Stage, Layer, Rect, Transformer } from "react-konva"; +import { ShapeType } from "../Canvas"; +import { Node } from "konva/lib/Node"; +import Konva from "konva"; + +type RectangleProps = { + shapeProps: ShapeType; + isSelected: boolean; + onSelect: () => void; + onChange: (newAttrs: ShapeType) => void; +}; + +export default function Rectangle({ + shapeProps, + isSelected, + onSelect, + onChange, +}: RectangleProps) { + const shapeRef = React.useRef(null); + const trRef = React.useRef(null); + + useEffect(() => { + if (isSelected && trRef.current) { + // we need to attach transformer manually + trRef.current.nodes([shapeRef.current as unknown as Node]); + trRef.current.getLayer()?.batchDraw(); + } + }, [isSelected]); + + return ( + <> + { + onChange({ + ...shapeProps, + x: e.target.x(), + y: e.target.y(), + }); + }} + onTransformEnd={(e) => { + // transformer is changing scale of the node + // and NOT its width or height + // but in the store we have only width and height + // to match the data better we will reset scale on transform end + const node: Node = shapeRef.current as unknown as Node; + if (node) { + const scaleX = node.scaleX(); + const scaleY = node.scaleY(); + + // we will reset it back + node.scaleX(1); + node.scaleY(1); + onChange({ + ...shapeProps, + x: node.x(), + y: node.y(), + // set minimal value + width: Math.max(5, node.width() * scaleX), + height: Math.max(node.height() * scaleY), + }); + } + }} + /> + {isSelected && ( + { + // limit resize + if (Math.abs(newBox.width) < 5 || Math.abs(newBox.height) < 5) { + return oldBox; + } + return newBox; + }} + /> + )} + + ); +} diff --git a/package-lock.json b/package-lock.json index 2740921..1a471a7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,12 +20,14 @@ "react": "^18", "react-colorful": "^5.6.1", "react-dom": "^18", - "react-konva": "^18.2.10" + "react-konva": "^18.2.10", + "uuid": "^10.0.0" }, "devDependencies": { "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", + "@types/uuid": "^10.0.0", "eslint": "^8", "eslint-config-next": "14.2.6", "postcss": "^8", @@ -1203,6 +1205,13 @@ "@types/react": "*" } }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@typescript-eslint/parser": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.2.0.tgz", @@ -6359,6 +6368,19 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", diff --git a/package.json b/package.json index 7e60cc8..f767f3b 100644 --- a/package.json +++ b/package.json @@ -21,12 +21,14 @@ "react": "^18", "react-colorful": "^5.6.1", "react-dom": "^18", - "react-konva": "^18.2.10" + "react-konva": "^18.2.10", + "uuid": "^10.0.0" }, "devDependencies": { "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", + "@types/uuid": "^10.0.0", "eslint": "^8", "eslint-config-next": "14.2.6", "postcss": "^8",