/** * MaskCanvas - Canvas overlay component for drawing inpainting masks. * * Features: * - Canvas overlay on top of the image * - Brush drawing with adjustable size and opacity * - Eraser mode * - Undo/Redo support * - Clear mask * - Export mask as data URL */ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { resolveFileUrl } from '../resolveFileUrl'; import { Brush, Eraser, Undo2, Redo2, Trash2, Eye, EyeOff, Check, X, } from 'lucide-react'; export function MaskCanvas({ imageUrl, onSaveMask, onCancel, initialMask, }) { const canvasRef = useRef(null); const containerRef = useRef(null); // Drawing state const [isDrawing, setIsDrawing] = useState(false); const [mode, setMode] = useState('brush'); const [brushSize, setBrushSize] = useState(30); const [brushOpacity, setBrushOpacity] = useState(1.0); const [showMask, setShowMask] = useState(true); // History for undo/redo const [history, setHistory] = useState([]); const [historyIndex, setHistoryIndex] = useState(-1); // Image dimensions const [imageDimensions, setImageDimensions] = useState({ width: 0, height: 0 }); const [canvasScale, setCanvasScale] = useState(1); // Initialize canvas when image loads useEffect(() => { const img = new Image(); img.crossOrigin = 'anonymous'; img.onload = () => { const canvas = canvasRef.current; const container = containerRef.current; if (!canvas || !container) return; // Calculate scale to fit in container while maintaining aspect ratio const maxWidth = container.clientWidth - 32; const maxHeight = container.clientHeight - 32; const scale = Math.min(maxWidth / img.width, maxHeight / img.height, 1); const scaledWidth = Math.floor(img.width * scale); const scaledHeight = Math.floor(img.height * scale); // Set canvas to actual image dimensions for mask quality canvas.width = img.width; canvas.height = img.height; // Store dimensions and scale setImageDimensions({ width: img.width, height: img.height }); setCanvasScale(scale); // Clear canvas with transparent background const ctx = canvas.getContext('2d'); if (ctx) { ctx.clearRect(0, 0, canvas.width, canvas.height); // Load initial mask if provided if (initialMask) { const maskImg = new Image(); maskImg.crossOrigin = 'anonymous'; maskImg.onload = () => { ctx.drawImage(maskImg, 0, 0, canvas.width, canvas.height); saveToHistory(); }; maskImg.src = initialMask; } else { saveToHistory(); } } }; img.src = imageUrl; }, [imageUrl, initialMask]); // Save current canvas state to history const saveToHistory = useCallback(() => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext('2d'); if (!ctx) return; const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); setHistory((prev) => { // Remove any future history if we're not at the end const newHistory = prev.slice(0, historyIndex + 1); newHistory.push({ imageData }); // Limit history to 50 entries if (newHistory.length > 50) newHistory.shift(); return newHistory; }); setHistoryIndex((prev) => Math.min(prev + 1, 49)); }, [historyIndex]); // Undo const handleUndo = useCallback(() => { if (historyIndex <= 0) return; const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext('2d'); if (!ctx) return; const newIndex = historyIndex - 1; setHistoryIndex(newIndex); ctx.putImageData(history[newIndex].imageData, 0, 0); }, [history, historyIndex]); // Redo const handleRedo = useCallback(() => { if (historyIndex >= history.length - 1) return; const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext('2d'); if (!ctx) return; const newIndex = historyIndex + 1; setHistoryIndex(newIndex); ctx.putImageData(history[newIndex].imageData, 0, 0); }, [history, historyIndex]); // Clear mask const handleClear = useCallback(() => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext('2d'); if (!ctx) return; ctx.clearRect(0, 0, canvas.width, canvas.height); saveToHistory(); }, [saveToHistory]); // Get canvas coordinates from mouse event const getCanvasCoords = useCallback((e) => { const canvas = canvasRef.current; if (!canvas) return { x: 0, y: 0 }; const rect = canvas.getBoundingClientRect(); const x = ((e.clientX - rect.left) / rect.width) * canvas.width; const y = ((e.clientY - rect.top) / rect.height) * canvas.height; return { x, y }; }, []); // Draw on canvas const draw = useCallback((x, y) => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext('2d'); if (!ctx) return; ctx.beginPath(); ctx.arc(x, y, brushSize / 2 / canvasScale, 0, Math.PI * 2); if (mode === 'brush') { // Draw white mask (areas to inpaint) ctx.globalCompositeOperation = 'source-over'; ctx.fillStyle = `rgba(255, 255, 255, ${brushOpacity})`; } else { // Erase ctx.globalCompositeOperation = 'destination-out'; ctx.fillStyle = 'rgba(0, 0, 0, 1)'; } ctx.fill(); }, [mode, brushSize, brushOpacity, canvasScale]); // Mouse event handlers const handleMouseDown = useCallback((e) => { setIsDrawing(true); const { x, y } = getCanvasCoords(e); draw(x, y); }, [getCanvasCoords, draw]); const handleMouseMove = useCallback((e) => { if (!isDrawing) return; const { x, y } = getCanvasCoords(e); draw(x, y); }, [isDrawing, getCanvasCoords, draw]); const handleMouseUp = useCallback(() => { if (isDrawing) { setIsDrawing(false); saveToHistory(); } }, [isDrawing, saveToHistory]); const handleMouseLeave = useCallback(() => { if (isDrawing) { setIsDrawing(false); saveToHistory(); } }, [isDrawing, saveToHistory]); // Save mask and call callback const handleSave = useCallback(() => { const canvas = canvasRef.current; if (!canvas) return; // Export as PNG data URL const maskDataUrl = canvas.toDataURL('image/png'); onSaveMask(maskDataUrl); }, [onSaveMask]); // Keyboard shortcuts useEffect(() => { const handleKeyDown = (e) => { if (e.key === 'z' && (e.ctrlKey || e.metaKey)) { if (e.shiftKey) { handleRedo(); } else { handleUndo(); } e.preventDefault(); } else if (e.key === 'b') { setMode('brush'); } else if (e.key === 'e') { setMode('eraser'); } else if (e.key === 'Escape') { onCancel(); } else if (e.key === 'Enter') { handleSave(); } else if (e.key === '[') { setBrushSize((prev) => Math.max(5, prev - 5)); } else if (e.key === ']') { setBrushSize((prev) => Math.min(200, prev + 5)); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [handleUndo, handleRedo, onCancel, handleSave]); return (