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 all 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
149 changes: 68 additions & 81 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,30 +5,31 @@ 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)

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

// Function to handle shape creation
const createShape = useCallback(
(props: ShapeInterface) => {
Expand All @@ -49,12 +50,13 @@ const Canvas: React.FC = () => {
if (props.endX === undefined || props.endY === undefined) return

g.beginStroke(props.strokeColor)
.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(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) => {
Expand Down Expand Up @@ -102,6 +104,7 @@ const Canvas: React.FC = () => {
const g = this.graphics
g.clear()
.beginStroke(props.strokeColor)
.setStrokeStyle(getStrokeThickness(props?.thickness))
.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 +166,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 +235,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 +255,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 +282,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 +294,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 +321,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 +352,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 +382,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 +431,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