diff --git a/README.md b/README.md index dc06ee3..7063e8e 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A whiteboard app made with Konva library. -Latest deployed version: [Click Me](https://whiteboard.leohong.dev) +Demo: [Click Me](https://whiteboard.leohong.dev) ## Tech diff --git a/components/Canvas.tsx b/components/Canvas.tsx index df2df71..c9ced6b 100644 --- a/components/Canvas.tsx +++ b/components/Canvas.tsx @@ -15,12 +15,16 @@ export interface LineType { } export interface ShapeType { + shapeName: string; id: string; - // shapeType: string; - x: number; - y: number; - width: number; - height: number; + x?: number; + y?: number; + width?: number; + height?: number; + radiusX?: number; + radiusY?: number; + points?: number[]; + fill?: string; stroke: string; strokeWidth: number; } @@ -35,7 +39,7 @@ export default function Canvas() { const [lines, setLines] = useState([]); const [shapes, setShapes] = useState([]); - const [color, setColor] = useState("#0000FF"); + const [strokeColor, setStrokeColor] = useState("#0000FF"); const [strokeWidth, setStrokeWidth] = useState(5); const [stageSize, setStageSize] = useState(); @@ -44,19 +48,88 @@ export default function Canvas() { const [selectedShapeId, setSelectedShapeId] = useState(""); - const addRectangle = () => { - setShapes([ - ...shapes, - { - id: uuid(), - x: stageSize ? stageSize.width / 2 - 100 : 0, - y: stageSize ? stageSize.height / 2 - 50 : 0, - width: 200, - height: 100, - stroke: color, - strokeWidth: strokeWidth, - }, - ]); + function updateShapeProperty(property: keyof ShapeType, value: any) { + // Dynamically update state + if (property === "strokeWidth") { + setStrokeWidth(value); + } else if (property === "stroke") { + setStrokeColor(value); + } + + // Update shape property + if (selectedShapeId !== "") { + setShapes((prevShapes) => { + return prevShapes.map((shape) => + shape.id === selectedShapeId + ? { ...shape, [property]: value } // Update the selected shape property + : shape + ); + }); + } + } + + const addShape = (shapeName: string) => { + const newShapeId = uuid(); + + switch (shapeName) { + case "rectangle": + setShapes([ + ...shapes, + { + shapeName: shapeName, + id: newShapeId, + x: stageSize ? stageSize.width / 2 - 100 : 0, + y: stageSize ? stageSize.height / 2 - 50 : 0, + width: 200, + height: 100, + stroke: strokeColor, + strokeWidth: strokeWidth, + }, + ]); + break; + + case "ellipse": + console.log("inside ellipse case"); + console.log("shapes = ", shapes); + setShapes([ + ...shapes, + { + shapeName: shapeName, + id: newShapeId, + x: stageSize ? stageSize.width / 2 : 0, + y: stageSize ? stageSize.height / 2 : 0, + radiusX: 100, + radiusY: 100, + stroke: strokeColor, + strokeWidth: strokeWidth, + }, + ]); + break; + + case "line": + setShapes([ + ...shapes, + { + shapeName: shapeName, + id: newShapeId, + points: [ + stageSize ? stageSize.width / 2 - 50 : 0, + stageSize ? stageSize.height / 2 : 0, + stageSize ? stageSize.width / 2 + 50 : 0, + stageSize ? stageSize.height / 2 : 0, + ], + stroke: strokeColor, + strokeWidth: strokeWidth, + }, + ]); + break; + + default: + console.warn(`Unknown shapeName: ${shapeName}`); + break; + } + + setSelectedShapeId(newShapeId); }; function resetCanvas() { @@ -95,7 +168,7 @@ export default function Canvas() { { tool, points: [pos.x, pos.y], - stroke: color, + stroke: strokeColor, strokeWidth: strokeWidth, }, ]); @@ -138,35 +211,37 @@ export default function Canvas() { onMouseup={handleMouseUp} onTouchStart={handleMouseDown} > - - updateShapeProperty("stroke", newColor)} resetCanvas={resetCanvas} strokeWidth={strokeWidth} - setStrokeWidth={setStrokeWidth} - handleAddRectangle={addRectangle} + setStrokeWidth={(newWidth) => + updateShapeProperty("strokeWidth", newWidth) + } + handleAddShape={addShape} /> ); diff --git a/components/ShapesLayer.tsx b/components/ShapesLayer.tsx index f5ae31b..6cdf9b3 100644 --- a/components/ShapesLayer.tsx +++ b/components/ShapesLayer.tsx @@ -1,7 +1,9 @@ -import { MutableRefObject, useState } from "react"; -import { Stage, Layer } from "react-konva"; +import { MutableRefObject } from "react"; +import { Layer } from "react-konva"; import { ShapeType, StageSizeType } from "./Canvas"; -import Rectangle from "./shapes/Rectangle"; +import RectangleShape from "./shapes/RectangleShape"; +import EllipseShape from "./shapes/EllipseShape"; +import LineShape from "./shapes/LineShape"; type ShapesLayerProps = { shapes: ShapeType[]; @@ -16,45 +18,38 @@ type ShapesLayerProps = { }; 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, - }; + function onShapeChange(newAttrs: Partial, i: number) { + console.log(newAttrs); + const newShapes = shapes.slice(); // Create a shallow copy of the shapes array + newShapes[i] = { ...newShapes[i], ...newAttrs }; // Update the specific shape with new attributes + setShapes(newShapes); + } - return ( - { - setSelectedShapeId(shape.id); - }} - onChange={(newAttrs: ShapeType) => { - const newShapes = shapes.slice(); // deep copy - newShapes[i] = newAttrs; - setShapes(newShapes); - }} - /> - ); - })} - - ); + const renderShape = (shape: ShapeType, i: number) => { + const commonProps = { + shapeProps: shape, + isSelected: shape.id === selectedShapeId, + onSelect: () => setSelectedShapeId(shape.id), + onChange: (newAttrs: Partial) => onShapeChange(newAttrs, i), + }; + + switch (shape.shapeName) { + case "rectangle": + return ; + case "ellipse": + return ; + case "line": + return ; + default: + console.warn(`Unknown shape: ${shape.shapeName}`); + return null; + } + }; + + return {shapes.map((shape, i) => renderShape(shape, i))}; } diff --git a/components/Toolbar.tsx b/components/Toolbar.tsx index a06a026..a0afe5f 100644 --- a/components/Toolbar.tsx +++ b/components/Toolbar.tsx @@ -15,6 +15,9 @@ import { HexColorPicker } from "react-colorful"; import { useState } from "react"; import LineWeightRoundedIcon from "@mui/icons-material/LineWeightRounded"; import CropSquareRoundedIcon from "@mui/icons-material/CropSquareRounded"; +import CircleOutlinedIcon from "@mui/icons-material/CircleOutlined"; +import HorizontalRuleRoundedIcon from "@mui/icons-material/HorizontalRuleRounded"; +import ShapeLineOutlinedIcon from "@mui/icons-material/ShapeLineOutlined"; function LineWeightSliderValueLabel(props: SliderValueLabelProps) { const { children, value } = props; @@ -33,7 +36,7 @@ type ToolbarProps = { selectColor: (newColor: string) => void; strokeWidth: number; setStrokeWidth: (newWidth: number) => void; - handleAddRectangle: () => void; + handleAddShape: (shapeName: string) => void; }; function Toolbar({ @@ -43,12 +46,14 @@ function Toolbar({ selectColor, strokeWidth, setStrokeWidth, - handleAddRectangle, + handleAddShape, }: ToolbarProps) { // color picker const [colorPickerAnchorEl, setColorPickerAnchorEl] = useState(null); + const isColorPickerAnchorElOpen = Boolean(colorPickerAnchorEl); + const handleClickColorPickerButton = ( event: React.MouseEvent ) => { @@ -59,12 +64,28 @@ function Toolbar({ setColorPickerAnchorEl(null); }; - const isColorPickerAnchorElOpen = Boolean(colorPickerAnchorEl); + // shapes + const [shapesAnchorEl, setShapesAnchorEl] = + useState(null); + + const isShapesAnchorElOpen = Boolean(shapesAnchorEl); + + const handleClickShapesButton = ( + event: React.MouseEvent + ) => { + setShapesAnchorEl(event.currentTarget); + }; + + const handleCloseShapesPopover = () => { + setShapesAnchorEl(null); + }; - // line weight + // stroke width const [lineWeightAnchorEl, setLineWeightAnchorEl] = useState(null); + const isLineWeightSliderAnchorElOpen = Boolean(lineWeightAnchorEl); + const handleClickLineWeightButton = ( event: React.MouseEvent ) => { @@ -78,9 +99,7 @@ function Toolbar({ const handleChangeStrokeWidth = (value: number) => { setStrokeWidth(value); }; - - const isLineWeightSliderAnchorElOpen = Boolean(lineWeightAnchorEl); - + return (
{/* shapes */} - - + + {/* line weight */} @@ -125,6 +147,7 @@ function Toolbar({ + {/* colorPickerPopover */} + {/* lineWeightPopover */} + + {/* shapesPopover */} + + {/* shapes */} + { + handleAddShape("rectangle"); + handleCloseShapesPopover(); + }} + > + + + { + handleAddShape("ellipse"); + handleCloseShapesPopover(); + }} + > + + + { + handleAddShape("line"); + handleCloseShapesPopover(); + }} + > + + +
); } diff --git a/components/shapes/EllipseShape.tsx b/components/shapes/EllipseShape.tsx new file mode 100644 index 0000000..d013197 --- /dev/null +++ b/components/shapes/EllipseShape.tsx @@ -0,0 +1,107 @@ +import React, { useEffect, useRef } from "react"; +import { Ellipse, Transformer } from "react-konva"; +import { ShapeType } from "../Canvas"; +import Konva from "konva"; + +type EllipseShapeProps = { + shapeProps: Partial; + isSelected: boolean; + onSelect: () => void; + onChange: (newAttrs: Partial) => void; +}; + +export default function EllipseShape({ + shapeProps, + isSelected, + onSelect, + onChange, +}: EllipseShapeProps) { + const shapeRef = useRef(null); + const trRef = useRef(null); + + useEffect(() => { + if (isSelected && trRef.current && shapeRef.current) { + // we need to attach transformer manually + trRef.current.nodes([shapeRef.current]); + trRef.current.getLayer()?.batchDraw(); + } + }, [isSelected]); + + const { shapeName, id, x, y, radiusX, radiusY, stroke, strokeWidth } = + shapeProps; + + const selectedProps = { + shapeName, + id, + x, + y, + radiusX, + radiusY, + stroke, + strokeWidth, + }; + + return ( + <> + { + onChange({ + ...selectedProps, + 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 = shapeRef.current; + if (node) { + const scaleX = node.scaleX(); + const scaleY = node.scaleY(); + + // we will reset it back + node.scaleX(1); + node.scaleY(1); + + onChange({ + ...selectedProps, + x: node.x(), + y: node.y(), + // set minimal value + radiusX: Math.max( + 5, + (selectedProps.radiusX ?? node.width() / 2) * scaleX + ), + radiusY: Math.max( + 5, + (selectedProps.radiusY ?? node.height() / 2) * scaleY + ), + }); + } + }} + /> + {isSelected && ( + { + // limit resize + if (Math.abs(newBox.width) < 5 || Math.abs(newBox.height) < 5) { + return oldBox; + } + return newBox; + }} + /> + )} + + ); +} diff --git a/components/shapes/LineShape.tsx b/components/shapes/LineShape.tsx new file mode 100644 index 0000000..d109aa3 --- /dev/null +++ b/components/shapes/LineShape.tsx @@ -0,0 +1,97 @@ +import React, { useEffect, useRef } from "react"; +import { Line, Transformer } from "react-konva"; +import { ShapeType } from "../Canvas"; +import Konva from "konva"; + +type LineShapeProps = { + shapeProps: Partial; + isSelected: boolean; + onSelect: () => void; + onChange: (newAttrs: Partial) => void; +}; + +export default function LineShape({ + shapeProps, + isSelected, + onSelect, + onChange, +}: LineShapeProps) { + const shapeRef = useRef(null); + const trRef = useRef(null); + + useEffect(() => { + if (isSelected && trRef.current && shapeRef.current) { + // we need to attach transformer manually + trRef.current.nodes([shapeRef.current]); + trRef.current.getLayer()?.batchDraw(); + } + }, [isSelected]); + + const { shapeName, id, points, stroke, strokeWidth } = shapeProps; + + const selectedProps = { + shapeName, + id, + points, + stroke, + strokeWidth, + }; + + return ( + <> + { + onChange({ + ...selectedProps, + x: e.target.x(), + y: e.target.y(), + }); + }} + onTransformEnd={(e) => { + const node = shapeRef.current; + if (node) { + const scaleX = node.scaleX(); + const scaleY = node.scaleY(); + + // Reset scale to 1 + node.scaleX(1); + node.scaleY(1); + + const newPoints = node + .points() + .map((point, index) => + index % 2 === 0 ? point * scaleX : point * scaleY + ); + + onChange({ + ...selectedProps, + x: node.x(), + y: node.y(), + points: newPoints, + }); + } + }} + /> + {isSelected && ( + { + // limit resize + if (Math.abs(newBox.width) < 5 || Math.abs(newBox.height) < 5) { + return oldBox; + } + return newBox; + }} + /> + )} + + ); +} diff --git a/components/shapes/Rectangle.tsx b/components/shapes/RectangleShape.tsx similarity index 58% rename from components/shapes/Rectangle.tsx rename to components/shapes/RectangleShape.tsx index f191722..b961f02 100644 --- a/components/shapes/Rectangle.tsx +++ b/components/shapes/RectangleShape.tsx @@ -1,45 +1,58 @@ -import React, { useEffect } from "react"; -import { createRoot } from "react-dom/client"; -import { Stage, Layer, Rect, Transformer } from "react-konva"; +import React, { useEffect, useRef } from "react"; +import { Rect, Transformer } from "react-konva"; import { ShapeType } from "../Canvas"; -import { Node } from "konva/lib/Node"; import Konva from "konva"; -type RectangleProps = { - shapeProps: ShapeType; +type RectangleShapeProps = { + shapeProps: Partial; isSelected: boolean; onSelect: () => void; - onChange: (newAttrs: ShapeType) => void; + onChange: (newAttrs: Partial) => void; }; -export default function Rectangle({ +export default function RectangleShape({ shapeProps, isSelected, onSelect, onChange, -}: RectangleProps) { - const shapeRef = React.useRef(null); - const trRef = React.useRef(null); +}: RectangleShapeProps) { + const shapeRef = useRef(null); + const trRef = useRef(null); useEffect(() => { - if (isSelected && trRef.current) { + if (isSelected && trRef.current && shapeRef.current) { // we need to attach transformer manually - trRef.current.nodes([shapeRef.current as unknown as Node]); + trRef.current.nodes([shapeRef.current]); trRef.current.getLayer()?.batchDraw(); } }, [isSelected]); + const { shapeName, id, x, y, width, height, stroke, strokeWidth } = + shapeProps; + + const selectedProps = { + shapeName, + id, + x, + y, + width, + height, + stroke, + strokeWidth, + }; + return ( <> { onChange({ - ...shapeProps, + ...selectedProps, x: e.target.x(), y: e.target.y(), }); @@ -49,7 +62,7 @@ export default function Rectangle({ // 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; + const node = shapeRef.current; if (node) { const scaleX = node.scaleX(); const scaleY = node.scaleY(); @@ -58,12 +71,18 @@ export default function Rectangle({ node.scaleX(1); node.scaleY(1); onChange({ - ...shapeProps, + ...selectedProps, x: node.x(), y: node.y(), // set minimal value - width: Math.max(5, node.width() * scaleX), - height: Math.max(node.height() * scaleY), + width: Math.max( + 5, + (selectedProps.width ?? node.width()) * scaleX + ), + height: Math.max( + 5, + (selectedProps.height ?? node.height()) * scaleY + ), }); } }}