|
|
import React, { useRef, useEffect, useState, useCallback } from 'react'; |
|
|
|
|
|
interface CanvasComponentProps { |
|
|
penColor: string; |
|
|
penSize: number; |
|
|
isEraserMode: boolean; |
|
|
dataURLToLoad?: string | null; |
|
|
onDrawEnd: (dataURL: string) => void; |
|
|
canvasPhysicalWidth: number; |
|
|
canvasPhysicalHeight: number; |
|
|
zoomLevel: number; |
|
|
} |
|
|
|
|
|
const CanvasComponent: React.FC<CanvasComponentProps> = ({ |
|
|
penColor, |
|
|
penSize, |
|
|
isEraserMode, |
|
|
dataURLToLoad, |
|
|
onDrawEnd, |
|
|
canvasPhysicalWidth, |
|
|
canvasPhysicalHeight, |
|
|
zoomLevel, // Use zoomLevel |
|
|
}) => { |
|
|
const canvasRef = useRef<HTMLCanvasElement>(null); |
|
|
const [isDrawing, setIsDrawing] = useState(false); |
|
|
const lastPositionRef = useRef<{ x: number; y: number } | null>(null); |
|
|
|
|
|
const getCanvasContext = useCallback((): CanvasRenderingContext2D | null => { |
|
|
const canvas = canvasRef.current; |
|
|
return canvas ? canvas.getContext('2d') : null; |
|
|
}, []); |
|
|
|
|
|
useEffect(() => { |
|
|
const canvas = canvasRef.current; |
|
|
const context = getCanvasContext(); |
|
|
if (canvas && context) { |
|
|
if (dataURLToLoad) { |
|
|
const img = new Image(); |
|
|
img.onload = () => { |
|
|
context.fillStyle = '#FFFFFF'; |
|
|
context.fillRect(0, 0, canvas.width, canvas.height); |
|
|
context.drawImage(img, 0, 0); |
|
|
}; |
|
|
img.onerror = () => { |
|
|
console.error("Failed to load image from dataURL for canvas. Displaying a blank canvas."); |
|
|
context.fillStyle = '#FFFFFF'; |
|
|
context.fillRect(0, 0, canvas.width, canvas.height); |
|
|
} |
|
|
img.src = dataURLToLoad; |
|
|
} else { |
|
|
context.fillStyle = '#FFFFFF'; |
|
|
context.fillRect(0, 0, canvas.width, canvas.height); |
|
|
} |
|
|
} |
|
|
}, [dataURLToLoad, getCanvasContext, canvasPhysicalWidth, canvasPhysicalHeight]); |
|
|
|
|
|
const getRelativePosition = useCallback((event: MouseEvent | TouchEvent): { x: number; y: number } | null => { |
|
|
const canvas = canvasRef.current; |
|
|
if (!canvas) return null; |
|
|
|
|
|
const rect = canvas.getBoundingClientRect(); |
|
|
let clientX, clientY; |
|
|
|
|
|
if (event instanceof TouchEvent) { |
|
|
if (event.touches.length === 0) return null; |
|
|
clientX = event.touches[0].clientX; |
|
|
clientY = event.touches[0].clientY; |
|
|
} else { |
|
|
clientX = event.clientX; |
|
|
clientY = event.clientY; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return { |
|
|
x: (clientX - rect.left) / zoomLevel, |
|
|
y: (clientY - rect.top) / zoomLevel, |
|
|
}; |
|
|
}, [zoomLevel]); |
|
|
|
|
|
const startDrawing = useCallback((event: MouseEvent | TouchEvent) => { |
|
|
event.preventDefault(); |
|
|
const pos = getRelativePosition(event); |
|
|
if (!pos) return; |
|
|
|
|
|
const context = getCanvasContext(); |
|
|
if(!context) return; |
|
|
|
|
|
setIsDrawing(true); |
|
|
lastPositionRef.current = pos; |
|
|
|
|
|
context.lineCap = 'round'; |
|
|
context.lineJoin = 'round'; |
|
|
|
|
|
if (isEraserMode) { |
|
|
context.globalCompositeOperation = 'destination-out'; |
|
|
context.strokeStyle = "rgba(0,0,0,1)"; |
|
|
context.fillStyle = "rgba(0,0,0,1)"; |
|
|
} else { |
|
|
context.globalCompositeOperation = 'source-over'; |
|
|
context.strokeStyle = penColor; |
|
|
context.fillStyle = penColor; |
|
|
} |
|
|
|
|
|
context.beginPath(); |
|
|
|
|
|
|
|
|
context.arc(pos.x, pos.y, penSize / (2 * zoomLevel), 0, Math.PI * 2); |
|
|
context.lineWidth = penSize / zoomLevel; |
|
|
context.fill(); |
|
|
|
|
|
|
|
|
}, [getCanvasContext, penColor, penSize, isEraserMode, getRelativePosition, zoomLevel]); |
|
|
|
|
|
const draw = useCallback((event: MouseEvent | TouchEvent) => { |
|
|
if (!isDrawing) return; |
|
|
event.preventDefault(); |
|
|
const pos = getRelativePosition(event); |
|
|
if (!pos || !lastPositionRef.current) return; |
|
|
|
|
|
const context = getCanvasContext(); |
|
|
if (context) { |
|
|
context.lineWidth = penSize / zoomLevel; |
|
|
context.lineCap = 'round'; |
|
|
context.lineJoin = 'round'; |
|
|
|
|
|
if (isEraserMode) { |
|
|
context.globalCompositeOperation = 'destination-out'; |
|
|
context.strokeStyle = "rgba(0,0,0,1)"; |
|
|
} else { |
|
|
context.globalCompositeOperation = 'source-over'; |
|
|
context.strokeStyle = penColor; |
|
|
} |
|
|
|
|
|
context.beginPath(); |
|
|
context.moveTo(lastPositionRef.current.x, lastPositionRef.current.y); |
|
|
context.lineTo(pos.x, pos.y); |
|
|
context.stroke(); |
|
|
lastPositionRef.current = pos; |
|
|
} |
|
|
}, [isDrawing, getCanvasContext, penColor, penSize, isEraserMode, getRelativePosition, zoomLevel]); |
|
|
|
|
|
const endDrawing = useCallback(() => { |
|
|
if (!isDrawing) return; |
|
|
setIsDrawing(false); |
|
|
|
|
|
const canvas = canvasRef.current; |
|
|
if (canvas) { |
|
|
const context = getCanvasContext(); |
|
|
if (context) { |
|
|
context.globalCompositeOperation = 'source-over'; |
|
|
} |
|
|
onDrawEnd(canvas.toDataURL('image/png')); |
|
|
} |
|
|
lastPositionRef.current = null; |
|
|
}, [isDrawing, onDrawEnd, getCanvasContext]); |
|
|
|
|
|
useEffect(() => { |
|
|
const canvas = canvasRef.current; |
|
|
if (!canvas) return; |
|
|
|
|
|
|
|
|
const eventOptions = { passive: false }; |
|
|
|
|
|
canvas.addEventListener('mousedown', startDrawing, eventOptions); |
|
|
canvas.addEventListener('mousemove', draw, eventOptions); |
|
|
canvas.addEventListener('mouseup', endDrawing, eventOptions); |
|
|
canvas.addEventListener('mouseleave', endDrawing, eventOptions); |
|
|
|
|
|
canvas.addEventListener('touchstart', startDrawing, eventOptions); |
|
|
canvas.addEventListener('touchmove', draw, eventOptions); |
|
|
canvas.addEventListener('touchend', endDrawing, eventOptions); |
|
|
canvas.addEventListener('touchcancel', endDrawing, eventOptions); |
|
|
|
|
|
return () => { |
|
|
canvas.removeEventListener('mousedown', startDrawing); |
|
|
canvas.removeEventListener('mousemove', draw); |
|
|
canvas.removeEventListener('mouseup', endDrawing); |
|
|
canvas.removeEventListener('mouseleave', endDrawing); |
|
|
canvas.removeEventListener('touchstart', startDrawing); |
|
|
canvas.removeEventListener('touchmove', draw); |
|
|
canvas.removeEventListener('touchend', endDrawing); |
|
|
canvas.removeEventListener('touchcancel', endDrawing); |
|
|
}; |
|
|
}, [startDrawing, draw, endDrawing]); |
|
|
|
|
|
return ( |
|
|
<canvas |
|
|
ref={canvasRef} |
|
|
width={canvasPhysicalWidth} |
|
|
height={canvasPhysicalHeight} |
|
|
className="border-2 border-slate-400 rounded-lg shadow-2xl touch-none bg-white cursor-crosshair" |
|
|
// Style for imageRendering is good for pixel art, ensure it doesn't conflict with scaling needs. |
|
|
// Visual scaling is handled by the parent div's transform. |
|
|
style={{ imageRendering: 'pixelated' }} |
|
|
aria-label="Drawing canvas" |
|
|
/> |
|
|
); |
|
|
}; |
|
|
|
|
|
export default CanvasComponent; |
|
|
|