Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: enhance path drawing with flexible handling and live updates #1

Merged
merged 4 commits into from
Oct 18, 2024
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 66 additions & 81 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,27 +5,26 @@ 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<HTMLCanvasElement>(null)
const stageRef = useRef<Stage | null>(null)
const [shapes, setShapes] = useState<ShapeInterface[]>([])
const [selectedShape, setSelectedShape] = useState<ShapeInterface | null>(null)
const [shapeType, setShapeType] = useState<ShapeType>('rectangle')
const [currentPath, setCurrentPath] = useState<Shape | null>(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<Shape | null>(null)
const [is3DMode, setIs3DMode] = useState(false)
const pathColor = useRef(getRandomColor())

const selectedIdRef = useRef<number | undefined>(undefined)
const pathPointsRef = useRef<{ x: number; y: number }[]>([])

const toggleViewMode = () => setIs3DMode(!is3DMode)

Expand All @@ -49,12 +48,13 @@ const Canvas: React.FC = () => {
if (props.endX === undefined || props.endY === undefined) return

g.beginStroke(props.strokeColor)
.setStrokeStyle(props?.thickness ?? pathThickness)
Copy link

@coderabbitai coderabbitai bot Oct 18, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Refactor repeated stroke style setting

The code setting stroke style with props?.thickness ?? pathThickness is repeated in multiple places. Consider creating a helper function to adhere to the DRY (Don't Repeat Yourself) principle.

Here's how you might refactor:

+const getStrokeThickness = (props: ShapeInterface) => props?.thickness ?? pathThickness;

 // Replace occurrences of
-.setStrokeStyle(props?.thickness ?? pathThickness)
+.setStrokeStyle(getStrokeThickness(props))

Also applies to: 57-57, 105-105

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Refactor repeated stroke style setting

The code setting stroke style with props?.thickness ?? pathThickness is repeated in multiple places. Consider creating a helper function to adhere to the DRY (Don't Repeat Yourself) principle.

Here's how you might refactor:

+const getStrokeThickness = (props: ShapeInterface) => props?.thickness ?? pathThickness;

 // Replace occurrences of
-.setStrokeStyle(props?.thickness ?? pathThickness)
+.setStrokeStyle(getStrokeThickness(props))

Also applies to: 57-57, 105-105

defining: const getStrokeThickness = (thickness?: number) => thickness ?? pathThickness

usage: getStrokeThickness(props?.thickness)

better, no? @coderabbitai

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mustafacagri Yes, that's a great improvement. Defining getStrokeThickness as:

const getStrokeThickness = (thickness?: number) => thickness ?? pathThickness;

and using it like:

.setStrokeStyle(getStrokeThickness(props?.thickness))

is better because it decouples the function from the props object and makes it more reusable. This approach simplifies the function signature and enhances code clarity. Good catch!

.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) => {
Expand Down Expand Up @@ -102,6 +102,7 @@ const Canvas: React.FC = () => {
const g = this.graphics
g.clear()
.beginStroke(props.strokeColor)
.setStrokeStyle(props?.thickness ?? pathThickness)
.moveTo(0, 0)
.lineTo(props.endX - props.x, props.endY - props.y)
} else if (props.type === 'path' && props.points) {
Expand Down Expand Up @@ -163,20 +164,38 @@ const Canvas: React.FC = () => {
const g = currentShape.graphics
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':
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).setStrokeStyle(thickness).moveTo(startX, startY).lineTo(x, y)
break
case 'path': {
const newPoints = g.beginStroke(pathColor.current).setStrokeStyle(thickness)

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()
Expand Down Expand Up @@ -214,11 +233,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:
Expand All @@ -233,57 +253,21 @@ 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<HTMLCanvasElement>) => {
pathColor.current = getRandomColor()

if (!isDrawing) pathColor.current = getRandomColor()
const { offsetX, offsetY } = event.nativeEvent

if (shapeType === 'path') {
pathPointsRef.current.push({ x: offsetX, y: offsetY })
}

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)
}
Expand All @@ -296,11 +280,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]
Expand All @@ -312,7 +292,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)
}
Expand All @@ -339,16 +319,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) {
Expand Down Expand Up @@ -380,9 +350,8 @@ const Canvas: React.FC = () => {

setShapes([])
setSelectedShape(null)
setCurrentPath(null)
setPathPoints([])
setIsDrawing(false)
pathPointsRef.current = []
}, [])

// Keyboard delete, backspace handlers
Expand Down Expand Up @@ -411,28 +380,42 @@ 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,
thickness: pathThickness,
}

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 (
Expand All @@ -446,6 +429,8 @@ const Canvas: React.FC = () => {
shapeType={shapeType}
toggleViewMode={toggleViewMode}
is3DMode={is3DMode}
pathThickness={pathThickness}
setPathThickness={setPathThickness}
/>
<div
id='CanvasContainer'
Expand Down
22 changes: 21 additions & 1 deletion src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { importShapes, exportShapes } from '../utils/exportimport'
import { isEmpty } from 'lodash'
import { shapeList } from '../utils/constants'
import { defaultPathThickness, shapeList } from '../utils/constants'
import { SidebarProps } from '../interfaces'
import Button from './ui/Button'

Expand All @@ -12,11 +12,18 @@ export default function Sidebar({
shapeType,
toggleViewMode,
is3DMode,
pathThickness,
setPathThickness,
}: Readonly<SidebarProps>) {
const importFunc = (event: React.ChangeEvent<HTMLInputElement>) => {
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 (
Expand All @@ -34,6 +41,19 @@ export default function Sidebar({
<span className='material-icons mr-2'>{iconList[index]}</span>
{type.charAt(0).toUpperCase() + type.slice(1)}
</Button>

{['line', 'path'].includes(type) && shapeType === type && (
<div className='w-full my-4 flex items-center justify-between'>
<span className='text-gray-500 text-xs'>Path thickness:</span>
<input
type='number'
value={pathThickness}
onChange={e => 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'
/>
</div>
)}
</li>
))}
</ul>
Expand Down
7 changes: 5 additions & 2 deletions src/components/ThreeJSViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
Expand Down Expand Up @@ -49,6 +50,8 @@ const ThreeJSViewer: React.FC<ThreeJSViewerProps> = ({ 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) {
Expand All @@ -75,7 +78,7 @@ const ThreeJSViewer: React.FC<ThreeJSViewerProps> = ({ 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
Expand All @@ -84,7 +87,7 @@ const ThreeJSViewer: React.FC<ThreeJSViewerProps> = ({ 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 {
Expand Down
3 changes: 3 additions & 0 deletions src/interfaces/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export interface Shape {
endY?: number
graphics?: any
instance?: any
thickness?: number
}

export interface SidebarProps {
Expand All @@ -26,4 +27,6 @@ export interface SidebarProps {
shapeType: string
toggleViewMode: () => void
is3DMode: boolean
pathThickness: number
setPathThickness: (thickness: number) => void
}
1 change: 1 addition & 0 deletions src/utils/constants.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export const shapeList = ['rectangle', 'circle', 'line', 'path']
export type ShapeType = (typeof shapeList)[number]
export const defaultPathThickness = 25
Loading
Loading