xpaintdev / components /CanvasComponent.tsx
suisuyy
Initialize xpaintai project with core files and basic structure
763be49
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; // New prop
}
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(); // This rect is of the visually scaled canvas
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;
}
// Adjust coordinates based on zoom level
// (clientX - rect.left) gives position relative to the scaled canvas's top-left on screen
// Divide by zoomLevel to get position relative to the unscaled canvas's internal coordinates
return {
x: (clientX - rect.left) / zoomLevel,
y: (clientY - rect.top) / zoomLevel,
};
}, [zoomLevel]); // Add zoomLevel to dependencies
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();
// For single click dots, use arc. For lines, it's just the start point.
// The actual drawing of the dot happens here.
context.arc(pos.x, pos.y, penSize / (2 * zoomLevel), 0, Math.PI * 2); // Scale dot size with zoom for visual consistency
context.lineWidth = penSize / zoomLevel; // Scale line width for visual consistency
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; // Scale line width
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'; // Reset composite operation
}
onDrawEnd(canvas.toDataURL('image/png'));
}
lastPositionRef.current = null;
}, [isDrawing, onDrawEnd, getCanvasContext]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
// Passive false is important for preventDefault() to work on touch events and prevent scrolling page.
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]); // Re-bind if these handlers change (e.g. due to zoomLevel)
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;