import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react"; type Point = { x: number; y: number }; type Stroke = { points: Point[]; size: number }; export interface EraserCanvasRef { exportMask: () => Promise; clear: () => void; undo: () => void; } interface EraserCanvasProps { imageSrc: string; brushSize: number; onStrokeChange: (hasStrokes: boolean) => void; } export const EraserCanvas = forwardRef(function EraserCanvas( { imageSrc, brushSize, onStrokeChange }, ref, ) { const wrapperRef = useRef(null); const canvasRef = useRef(null); const imgRef = useRef(null); const [canvasSize, setCanvasSize] = useState<{ w: number; h: number } | null>(null); const naturalRef = useRef({ w: 0, h: 0 }); const strokesRef = useRef([]); const drawingRef = useRef(false); const currentPointsRef = useRef([]); // Measure and fit image to container const measure = useCallback(() => { const img = imgRef.current; const wrapper = wrapperRef.current; if (!img || !wrapper || !img.naturalWidth) return; naturalRef.current = { w: img.naturalWidth, h: img.naturalHeight }; const scale = Math.min( wrapper.clientWidth / img.naturalWidth, wrapper.clientHeight / img.naturalHeight, ); setCanvasSize({ w: Math.floor(img.naturalWidth * scale), h: Math.floor(img.naturalHeight * scale), }); }, []); // Reset strokes when image changes useEffect(() => { strokesRef.current = []; currentPointsRef.current = []; onStrokeChange(false); setCanvasSize(null); }, [imageSrc, onStrokeChange]); // Redraw all strokes const redraw = useCallback(() => { const ctx = canvasRef.current?.getContext("2d"); if (!ctx || !canvasSize) return; ctx.clearRect(0, 0, canvasSize.w, canvasSize.h); for (const stroke of strokesRef.current) { drawStroke(ctx, stroke); } }, [canvasSize]); function drawStroke(ctx: CanvasRenderingContext2D, stroke: Stroke) { ctx.lineCap = "round"; ctx.lineJoin = "round"; if (stroke.points.length === 1) { ctx.beginPath(); ctx.fillStyle = "rgba(255, 60, 60, 0.4)"; ctx.arc(stroke.points[0].x, stroke.points[0].y, stroke.size / 2, 0, Math.PI * 2); ctx.fill(); } else { ctx.beginPath(); ctx.strokeStyle = "rgba(255, 60, 60, 0.4)"; ctx.lineWidth = stroke.size; ctx.moveTo(stroke.points[0].x, stroke.points[0].y); for (let i = 1; i < stroke.points.length; i++) { ctx.lineTo(stroke.points[i].x, stroke.points[i].y); } ctx.stroke(); } } // Get canvas-relative point from event const getPoint = useCallback((e: React.MouseEvent | React.TouchEvent): Point | null => { const canvas = canvasRef.current; if (!canvas) return null; const rect = canvas.getBoundingClientRect(); const raw = "touches" in e ? e.touches[0] : e; if (!raw) return null; return { x: raw.clientX - rect.left, y: raw.clientY - rect.top }; }, []); const handleDown = useCallback( (e: React.MouseEvent | React.TouchEvent) => { if ("touches" in e) e.preventDefault(); const pt = getPoint(e); if (!pt) return; drawingRef.current = true; currentPointsRef.current = [pt]; // Immediate dot const ctx = canvasRef.current?.getContext("2d"); if (ctx) { ctx.beginPath(); ctx.fillStyle = "rgba(255, 60, 60, 0.4)"; ctx.arc(pt.x, pt.y, brushSize / 2, 0, Math.PI * 2); ctx.fill(); } }, [getPoint, brushSize], ); const handleMove = useCallback( (e: React.MouseEvent | React.TouchEvent) => { if (!drawingRef.current) return; if ("touches" in e) e.preventDefault(); const pt = getPoint(e); if (!pt) return; currentPointsRef.current.push(pt); const ctx = canvasRef.current?.getContext("2d"); if (!ctx) return; const pts = currentPointsRef.current; if (pts.length < 2) return; ctx.beginPath(); ctx.strokeStyle = "rgba(255, 60, 60, 0.4)"; ctx.lineWidth = brushSize; ctx.lineCap = "round"; ctx.moveTo(pts[pts.length - 2].x, pts[pts.length - 2].y); ctx.lineTo(pt.x, pt.y); ctx.stroke(); }, [getPoint, brushSize], ); const handleUp = useCallback(() => { if (!drawingRef.current) return; drawingRef.current = false; if (currentPointsRef.current.length > 0) { strokesRef.current.push({ points: [...currentPointsRef.current], size: brushSize, }); currentPointsRef.current = []; onStrokeChange(true); redraw(); } }, [brushSize, onStrokeChange, redraw]); // Expose methods useImperativeHandle( ref, () => ({ exportMask: async () => { const nat = naturalRef.current; if (!nat.w || !canvasSize || strokesRef.current.length === 0) return null; const mask = document.createElement("canvas"); mask.width = nat.w; mask.height = nat.h; const ctx = mask.getContext("2d"); if (!ctx) return null; ctx.fillStyle = "black"; ctx.fillRect(0, 0, nat.w, nat.h); const sx = nat.w / canvasSize.w; const sy = nat.h / canvasSize.h; ctx.fillStyle = "white"; ctx.strokeStyle = "white"; ctx.lineCap = "round"; ctx.lineJoin = "round"; for (const stroke of strokesRef.current) { const scaledSize = stroke.size * Math.max(sx, sy); if (stroke.points.length === 1) { ctx.beginPath(); ctx.arc( stroke.points[0].x * sx, stroke.points[0].y * sy, scaledSize / 2, 0, Math.PI * 2, ); ctx.fill(); } else { ctx.beginPath(); ctx.lineWidth = scaledSize; ctx.moveTo(stroke.points[0].x * sx, stroke.points[0].y * sy); for (let i = 1; i < stroke.points.length; i++) { ctx.lineTo(stroke.points[i].x * sx, stroke.points[i].y * sy); } ctx.stroke(); } } return new Promise((resolve) => { mask.toBlob((b) => resolve(b), "image/png"); }); }, clear: () => { strokesRef.current = []; currentPointsRef.current = []; onStrokeChange(false); redraw(); }, undo: () => { strokesRef.current.pop(); onStrokeChange(strokesRef.current.length > 0); redraw(); }, }), [canvasSize, onStrokeChange, redraw], ); return (
{/* Hidden img for measuring natural size before canvas is ready */} {!canvasSize && ( )} {canvasSize && (
Paint over objects to erase
)}
); });