| 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; |
|
|