From 02e764b0899470b275d392407f069c2c5d1baa0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20=C3=87a=C4=9Fr=C4=B1=20G=C3=BCven?= Date: Fri, 18 Oct 2024 02:59:11 +0300 Subject: [PATCH 1/4] feat: enhance path drawing with flexible handling and live updates --- src/App.tsx | 128 ++++++++++++++++---------------------- src/utils/exportimport.ts | 6 +- 2 files changed, 57 insertions(+), 77 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 5aa44cb..6c3a6c6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,6 +8,7 @@ import { Shape as ShapeInterface } from './interfaces' import { ShapeType } from './utils/constants' import Sidebar from './components/Sidebar' import ThreeJSViewer from './components/ThreeJSViewer' +import { isEmpty } from 'lodash' const Canvas: React.FC = () => { const pathThickness = 25 @@ -17,8 +18,6 @@ const Canvas: React.FC = () => { const [shapes, setShapes] = useState([]) const [selectedShape, setSelectedShape] = useState(null) const [shapeType, setShapeType] = useState('rectangle') - const [currentPath, setCurrentPath] = useState(null) - const [pathPoints, setPathPoints] = useState<{ x: number; y: number }[]>([]) const [isDrawing, setIsDrawing] = useState(false) const [startPoint, setStartPoint] = useState<{ x: number; y: number } | null>(null) const [currentShape, setCurrentShape] = useState(null) @@ -26,6 +25,7 @@ const Canvas: React.FC = () => { const pathColor = useRef(getRandomColor()) const selectedIdRef = useRef(undefined) + const pathPointsRef = useRef<{ x: number; y: number }[]>([]) const toggleViewMode = () => setIs3DMode(!is3DMode) @@ -163,20 +163,33 @@ const Canvas: React.FC = () => { const g = currentShape.graphics g.clear() + const { x: startX, y: startY } = startPoint + switch (shapeType) { case 'rectangle': g.beginFill(pathColor.current) .beginStroke(pathColor.current) - .drawRect(startPoint.x, startPoint.y, x - startPoint.x, y - startPoint.y) + .drawRect(startX, startY, x - startX, y - startY) break case 'circle': { - const radius = Math.sqrt(Math.pow(x - startPoint.x, 2) + Math.pow(y - startPoint.y, 2)) - g.beginFill(pathColor.current).beginStroke(pathColor.current).drawCircle(startPoint.x, startPoint.y, radius) + const radius = Math.sqrt(Math.pow(x - startX, 2) + Math.pow(y - startY, 2)) + g.beginFill(pathColor.current).beginStroke(pathColor.current).drawCircle(startX, startY, radius) break } case 'line': - g.beginStroke(pathColor.current).moveTo(startPoint.x, startPoint.y).lineTo(x, y) + g.beginStroke(pathColor.current).moveTo(startX, startY).lineTo(x, y) break + case 'path': { + const newPoints = g.beginStroke(pathColor.current).setStrokeStyle(pathThickness) + + if (isEmpty(pathPointsRef.current)) { + newPoints.moveTo(startX, startY) + } else { + pathPointsRef.current.forEach(point => newPoints.lineTo(point.x, point.y)) + } + + newPoints.lineTo(x, y) + } } stageRef.current?.update() @@ -233,57 +246,22 @@ const Canvas: React.FC = () => { stageRef.current?.update() } - const updatePath = (x: number, y: number) => { - if (currentPath && pathPoints.length > 0) { - const g = currentPath.graphics - g.clear().beginStroke(pathColor.current).setStrokeStyle(pathThickness) - g.moveTo(pathPoints[0].x, pathPoints[0].y) - g.lineTo(x, y) // Draw the latest line to the current mouse point - - stageRef.current?.update() // Refresh stage - } - } - - const endPath = (x: number, y: number) => { - if (currentPath && pathPoints.length > 0) { - const newPoints = [...pathPoints, { x, y }] - - createShape({ - type: 'path', - fillColor: 'transparent', - strokeColor: pathColor.current, - x: 0, - y: 0, - points: newPoints, - }) - - stageRef.current?.removeChild(currentPath) - setPathPoints([]) - setCurrentPath(null) - setIsDrawing(false) - stageRef.current?.update() - } - } - // Mouse event handlers const handleCanvasMouseDown = useCallback( (event: React.MouseEvent) => { - pathColor.current = getRandomColor() - const { offsetX, offsetY } = event.nativeEvent + + if (shapeType === 'path') { + pathPointsRef.current.push({ x: offsetX, y: offsetY }) + } else { + pathColor.current = getRandomColor() + } + const clickedShape = stageRef.current?.getObjectsUnderPoint(offsetX, offsetY, 1)?.[0] as ShapeInterface if (clickedShape) { selectedIdRef.current = clickedShape?.id as number - setSelectedShape(clickedShape) - } else if (shapeType === 'path') { - if (!isDrawing) { - startPath(offsetX, offsetY) - } else { - endPath(offsetX, offsetY) - startPath(offsetX, offsetY) - } } else { startDrawing(offsetX, offsetY) } @@ -296,11 +274,7 @@ const Canvas: React.FC = () => { const { offsetX, offsetY } = event.nativeEvent if (isDrawing) { - if (shapeType === 'path') { - updatePath(offsetX, offsetY) - } else { - draw(offsetX, offsetY) - } + draw(offsetX, offsetY) } }, [isDrawing, shapeType] @@ -312,7 +286,7 @@ const Canvas: React.FC = () => { if (isDrawing) { if (shapeType === 'path') { - endPath(offsetX, offsetY) + pathPointsRef.current = [...pathPointsRef.current, { x: offsetX, y: offsetY }] } else { endDrawing(offsetX, offsetY) } @@ -339,16 +313,6 @@ const Canvas: React.FC = () => { [isDrawing, shapeType] ) - const startPath = (x: number, y: number) => { - const newPath = new Shape() - newPath.graphics.beginStroke(pathColor.current).setStrokeStyle(pathThickness) - stageRef.current?.addChild(newPath) - - setCurrentPath(newPath) - setPathPoints([{ x, y }]) - setIsDrawing(true) // Ensure we are in drawing mode - } - // Initialize canvas and attach event handlers useEffect(() => { if (canvasRef.current) { @@ -380,9 +344,8 @@ const Canvas: React.FC = () => { setShapes([]) setSelectedShape(null) - setCurrentPath(null) - setPathPoints([]) setIsDrawing(false) + pathPointsRef.current = [] }, []) // Keyboard delete, backspace handlers @@ -411,28 +374,41 @@ const Canvas: React.FC = () => { event.preventDefault() if (isDrawing) { + if (shapeType === 'path' && Array.isArray(pathPointsRef.current) && pathPointsRef.current.length > 1) { + const current = { + type: 'path', + fillColor: pathColor.current, + strokeColor: pathColor.current, + x: 0, + y: 0, + points: pathPointsRef.current, + instance: stageRef.current?.children[stageRef.current?.children.length - 1] as Shape, + } + + stageRef.current?.removeChild(currentShape) + stageRef.current?.update() + + createShape({ + ...current, + }) + + pathPointsRef.current = [] + setStartPoint(null) + } + setIsDrawing(false) - setCurrentPath(null) - setPathPoints([]) } } window.addEventListener('contextmenu', handleRightClick) - if (!isDrawing) { - setCurrentPath(null) - setPathPoints([]) - } - return () => { window.removeEventListener('contextmenu', handleRightClick) } }, [isDrawing]) useEffect(() => { - if (shapeType !== 'path') { - setIsDrawing(false) - } + setIsDrawing(false) }, [shapeType]) return ( diff --git a/src/utils/exportimport.ts b/src/utils/exportimport.ts index 881bfae..81fbb03 100644 --- a/src/utils/exportimport.ts +++ b/src/utils/exportimport.ts @@ -27,8 +27,12 @@ const getUpdatedShapeData = (shape: Shape) => { break case 'path': if (instance && updatedShape.points) { + updatedShape.x = 0 + updatedShape.y = 0 + const dx = instance.x - shape.x const dy = instance.y - shape.y + updatedShape.points = updatedShape.points.map(point => ({ x: point.x + dx, y: point.y + dy, @@ -42,7 +46,7 @@ const getUpdatedShapeData = (shape: Shape) => { export const exportShapes = (shapes: Shape[]) => { // Only export active shapes (not deleted) - const activeShapes = shapes.filter(shape => shape.instance && !shape.instance?.isDeleted) + const activeShapes = shapes.filter(shape => !shape.instance?.isDeleted) const exportedData = activeShapes.map(getUpdatedShapeData) try { From 55c01ca9e10daeb8a7c351641dd77fcd0b65a61a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20=C3=87a=C4=9Fr=C4=B1=20G=C3=BCven?= Date: Fri, 18 Oct 2024 04:31:29 +0300 Subject: [PATCH 2/4] feat: add thickness selection feature to the sidebar for drawings --- src/App.tsx | 20 ++++++++++++-------- src/components/Sidebar.tsx | 22 +++++++++++++++++++++- src/components/ThreeJSViewer.tsx | 7 +++++-- src/interfaces/index.ts | 3 +++ src/utils/constants.ts | 1 + src/utils/exportimport.ts | 4 ++++ 6 files changed, 46 insertions(+), 11 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 6c3a6c6..1c3c243 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,14 +5,13 @@ import { getRandomColor } from './utils' // @ts-expect-error -> no support for EaselJS in TypeScript -> https://github.com/CreateJS/EaselJS/issues/796 import { Stage, Shape, Ticker } from '@createjs/easeljs' import { Shape as ShapeInterface } from './interfaces' -import { ShapeType } from './utils/constants' +import { defaultPathThickness, ShapeType } from './utils/constants' import Sidebar from './components/Sidebar' import ThreeJSViewer from './components/ThreeJSViewer' import { isEmpty } from 'lodash' const Canvas: React.FC = () => { - const pathThickness = 25 - + const [pathThickness, setPathThickness] = useState(defaultPathThickness) const canvasRef = useRef(null) const stageRef = useRef(null) const [shapes, setShapes] = useState([]) @@ -49,12 +48,13 @@ const Canvas: React.FC = () => { if (props.endX === undefined || props.endY === undefined) return g.beginStroke(props.strokeColor) + .setStrokeStyle(props?.thickness ?? pathThickness) .moveTo(0, 0) .lineTo(props.endX - props.x, props.endY - props.y) break case 'path': if (props?.points && props.points?.length > 1) { - g.beginStroke(props.strokeColor).setStrokeStyle(pathThickness) + g.beginStroke(props.strokeColor).setStrokeStyle(props?.thickness ?? pathThickness) g.moveTo(props.points[0].x, props.points[0].y) props.points.forEach((point: { x: number; y: number }, index: number) => { @@ -102,6 +102,7 @@ const Canvas: React.FC = () => { const g = this.graphics g.clear() .beginStroke(props.strokeColor) + .setStrokeStyle(pathThickness) .moveTo(0, 0) .lineTo(props.endX - props.x, props.endY - props.y) } else if (props.type === 'path' && props.points) { @@ -177,7 +178,7 @@ const Canvas: React.FC = () => { break } case 'line': - g.beginStroke(pathColor.current).moveTo(startX, startY).lineTo(x, y) + g.beginStroke(pathColor.current).setStrokeStyle(pathThickness).moveTo(startX, startY).lineTo(x, y) break case 'path': { const newPoints = g.beginStroke(pathColor.current).setStrokeStyle(pathThickness) @@ -227,11 +228,12 @@ const Canvas: React.FC = () => { shapeProps = { type: 'line', fillColor: 'transparent', - strokeColor: getRandomColor(), + strokeColor: pathColor.current, x: startPoint.x, y: startPoint.y, endX: x, endY: y, + thickness: pathThickness, } break default: @@ -249,12 +251,11 @@ const Canvas: React.FC = () => { // Mouse event handlers const handleCanvasMouseDown = useCallback( (event: React.MouseEvent) => { + if (!isDrawing) pathColor.current = getRandomColor() const { offsetX, offsetY } = event.nativeEvent if (shapeType === 'path') { pathPointsRef.current.push({ x: offsetX, y: offsetY }) - } else { - pathColor.current = getRandomColor() } const clickedShape = stageRef.current?.getObjectsUnderPoint(offsetX, offsetY, 1)?.[0] as ShapeInterface @@ -383,6 +384,7 @@ const Canvas: React.FC = () => { y: 0, points: pathPointsRef.current, instance: stageRef.current?.children[stageRef.current?.children.length - 1] as Shape, + thickness: pathThickness, } stageRef.current?.removeChild(currentShape) @@ -422,6 +424,8 @@ const Canvas: React.FC = () => { shapeType={shapeType} toggleViewMode={toggleViewMode} is3DMode={is3DMode} + pathThickness={pathThickness} + setPathThickness={setPathThickness} />
) { const importFunc = (event: React.ChangeEvent) => { importShapes({ event, createShape, clearShapes }) } + const setThickness = (thickness: number) => { + if (thickness < 1 || isNaN(thickness)) thickness = defaultPathThickness + + setPathThickness(thickness) + } const iconList = ['rectangle', 'circle', 'horizontal_rule', 'route'] return ( @@ -34,6 +41,19 @@ export default function Sidebar({ {iconList[index]} {type.charAt(0).toUpperCase() + type.slice(1)} + + {['line', 'path'].includes(type) && shapeType === type && ( +
+ Path thickness: + setThickness(Number(e.target.value))} + className='p-2 border border-gray-200 rounded-md focus:outline-none focus:shadow-outline w-32' + placeholder='Path thickness' + /> +
+ )} ))} diff --git a/src/components/ThreeJSViewer.tsx b/src/components/ThreeJSViewer.tsx index 0411aee..1f2a4e0 100644 --- a/src/components/ThreeJSViewer.tsx +++ b/src/components/ThreeJSViewer.tsx @@ -2,6 +2,7 @@ import React, { useRef, useEffect } from 'react' import * as THREE from 'three' import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls' import { Shape as ShapeInterface } from '../interfaces' +import { defaultPathThickness } from '../utils/constants' interface ThreeJSViewerProps { shapes: ShapeInterface[] @@ -49,6 +50,8 @@ const ThreeJSViewer: React.FC = ({ shapes }) => { let material: THREE.Material let mesh: THREE.Mesh + const thickness = shape?.thickness ?? shape?.instance?._strokeStyle?.width ?? defaultPathThickness + switch (shape.type) { case 'rectangle': { if (shape.width === undefined || shape.height === undefined) { @@ -75,7 +78,7 @@ const ThreeJSViewer: React.FC = ({ shapes }) => { const startPosition = convertTo3DCoords(shape.x, shape.y) const endPosition = convertTo3DCoords(shape.endX!, shape.endY!) const lineCurve = new THREE.LineCurve3(startPosition, endPosition) - geometry = new THREE.TubeGeometry(lineCurve, 10, 2, 8, false) + geometry = new THREE.TubeGeometry(lineCurve, 10, thickness, 8, false) material = new THREE.MeshPhongMaterial({ color: shape?.strokeColor ?? 0x000000 }) mesh = new THREE.Mesh(geometry, material) break @@ -84,7 +87,7 @@ const ThreeJSViewer: React.FC = ({ shapes }) => { if (shape.points && shape.points.length > 1) { const pathPoints = shape.points.map(point => convertTo3DCoords(point.x, point.y)) const curve = new THREE.CatmullRomCurve3(pathPoints) - geometry = new THREE.TubeGeometry(curve, 64, 25, 8, true) + geometry = new THREE.TubeGeometry(curve, 64, thickness, 8, false) material = new THREE.MeshPhongMaterial({ color: shape.strokeColor || 0x000000 }) mesh = new THREE.Mesh(geometry, material) } else { diff --git a/src/interfaces/index.ts b/src/interfaces/index.ts index 156b9c1..8ce9459 100644 --- a/src/interfaces/index.ts +++ b/src/interfaces/index.ts @@ -16,6 +16,7 @@ export interface Shape { endY?: number graphics?: any instance?: any + thickness?: number } export interface SidebarProps { @@ -26,4 +27,6 @@ export interface SidebarProps { shapeType: string toggleViewMode: () => void is3DMode: boolean + pathThickness: number + setPathThickness: (thickness: number) => void } diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 83005b3..bc1727e 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -1,2 +1,3 @@ export const shapeList = ['rectangle', 'circle', 'line', 'path'] export type ShapeType = (typeof shapeList)[number] +export const defaultPathThickness = 25 diff --git a/src/utils/exportimport.ts b/src/utils/exportimport.ts index 81fbb03..0263e65 100644 --- a/src/utils/exportimport.ts +++ b/src/utils/exportimport.ts @@ -1,4 +1,5 @@ import { Shape } from '../interfaces' +import { defaultPathThickness } from './constants' const getUpdatedShapeData = (shape: Shape) => { const instance = shape.instance @@ -23,12 +24,15 @@ const getUpdatedShapeData = (shape: Shape) => { const dy = instance.y - shape.y updatedShape.endX = (shape.endX ?? 0) + dx updatedShape.endY = (shape.endY ?? 0) + dy + updatedShape.thickness ??= shape?.instance?._strokeStyle?.width || defaultPathThickness } + break case 'path': if (instance && updatedShape.points) { updatedShape.x = 0 updatedShape.y = 0 + updatedShape.thickness ??= shape?.instance?._strokeStyle?.width || defaultPathThickness const dx = instance.x - shape.x const dy = instance.y - shape.y From 8d2042d850890a173cdfbf0c57790760ecedc4b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20=C3=87a=C4=9Fr=C4=B1=20G=C3=BCven?= Date: Fri, 18 Oct 2024 05:04:27 +0300 Subject: [PATCH 3/4] refactor: use the current thickness of the shape while dragging --- src/App.tsx | 11 ++++++++--- src/utils/exportimport.ts | 1 + 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 1c3c243..fd74a02 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -102,7 +102,7 @@ const Canvas: React.FC = () => { const g = this.graphics g.clear() .beginStroke(props.strokeColor) - .setStrokeStyle(pathThickness) + .setStrokeStyle(props?.thickness ?? pathThickness) .moveTo(0, 0) .lineTo(props.endX - props.x, props.endY - props.y) } else if (props.type === 'path' && props.points) { @@ -165,6 +165,11 @@ const Canvas: React.FC = () => { g.clear() const { x: startX, y: startY } = startPoint + let thickness = pathThickness + + if (['line', 'path'].includes(shapeType) && currentShape?.graphics?._strokeStyle?.width) { + thickness = currentShape?.graphics?._strokeStyle?.width + } switch (shapeType) { case 'rectangle': @@ -178,10 +183,10 @@ const Canvas: React.FC = () => { break } case 'line': - g.beginStroke(pathColor.current).setStrokeStyle(pathThickness).moveTo(startX, startY).lineTo(x, y) + g.beginStroke(pathColor.current).setStrokeStyle(thickness).moveTo(startX, startY).lineTo(x, y) break case 'path': { - const newPoints = g.beginStroke(pathColor.current).setStrokeStyle(pathThickness) + const newPoints = g.beginStroke(pathColor.current).setStrokeStyle(thickness) if (isEmpty(pathPointsRef.current)) { newPoints.moveTo(startX, startY) diff --git a/src/utils/exportimport.ts b/src/utils/exportimport.ts index 0263e65..9e52ca8 100644 --- a/src/utils/exportimport.ts +++ b/src/utils/exportimport.ts @@ -15,6 +15,7 @@ const getUpdatedShapeData = (shape: Shape) => { points: shape.points ? [...shape.points] : undefined, endX: shape.endX, endY: shape.endY, + thickness: shape.thickness, } switch (shape.type) { From 97ff6821eebc9c1a15d3755d1c70be04019e92e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20=C3=87a=C4=9Fr=C4=B1=20G=C3=BCven?= Date: Fri, 18 Oct 2024 05:20:06 +0300 Subject: [PATCH 4/4] refactor: use getStrokeThickness to retrieve thickness from props discussed here: https://github.com/mustafacagri/react-threejs-easeljs-drawing-app/pull/1#discussion_r1805722889 --- src/App.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index fd74a02..6492d1b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -28,6 +28,8 @@ const Canvas: React.FC = () => { const toggleViewMode = () => setIs3DMode(!is3DMode) + const getStrokeThickness = (thickness?: number) => thickness ?? pathThickness + // Function to handle shape creation const createShape = useCallback( (props: ShapeInterface) => { @@ -48,13 +50,13 @@ const Canvas: React.FC = () => { if (props.endX === undefined || props.endY === undefined) return g.beginStroke(props.strokeColor) - .setStrokeStyle(props?.thickness ?? pathThickness) + .setStrokeStyle(getStrokeThickness(props?.thickness)) .moveTo(0, 0) .lineTo(props.endX - props.x, props.endY - props.y) break case 'path': if (props?.points && props.points?.length > 1) { - g.beginStroke(props.strokeColor).setStrokeStyle(props?.thickness ?? pathThickness) + g.beginStroke(props.strokeColor).setStrokeStyle(getStrokeThickness(props?.thickness)) g.moveTo(props.points[0].x, props.points[0].y) props.points.forEach((point: { x: number; y: number }, index: number) => { @@ -102,7 +104,7 @@ const Canvas: React.FC = () => { const g = this.graphics g.clear() .beginStroke(props.strokeColor) - .setStrokeStyle(props?.thickness ?? pathThickness) + .setStrokeStyle(getStrokeThickness(props?.thickness)) .moveTo(0, 0) .lineTo(props.endX - props.x, props.endY - props.y) } else if (props.type === 'path' && props.points) {