HomePilot / frontend /src /ui /edit /MaskCanvas.jsx
HomePilot Deploy Bot
chore(hf): sync HomePilot to HF Space
921d377
Raw
History Blame Contribute Delete
15.9 kB
/**
* 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 (<div className="fixed inset-0 z-50 bg-black/90 flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10 bg-black/50 backdrop-blur-xl">
<div className="flex items-center gap-4">
<h2 className="text-lg font-bold text-white">Draw Mask</h2>
<span className="text-xs text-white/40">
Paint the areas you want to edit (white = areas to change)
</span>
</div>
<div className="flex items-center gap-2">
<button onClick={onCancel} className="flex items-center gap-2 px-4 py-2 rounded-lg bg-white/5 hover:bg-white/10 border border-white/10 text-white/70 hover:text-white transition-colors">
<X size={16}/>
Cancel
</button>
<button onClick={handleSave} className="flex items-center gap-2 px-4 py-2 rounded-lg bg-purple-500 hover:bg-purple-400 text-white font-semibold transition-colors">
<Check size={16}/>
Apply Mask
</button>
</div>
</div>
{/* Main canvas area */}
<div ref={containerRef} className="flex-1 flex items-center justify-center p-4 overflow-hidden">
<div className="relative">
{/* Background image */}
<img src={resolveFileUrl(imageUrl)} alt="Source" className="max-w-full max-h-[70vh] object-contain rounded-lg shadow-2xl" style={{
width: imageDimensions.width * canvasScale || 'auto',
height: imageDimensions.height * canvasScale || 'auto',
}}/>
{/* Canvas overlay */}
<canvas ref={canvasRef} onMouseDown={handleMouseDown} onMouseMove={handleMouseMove} onMouseUp={handleMouseUp} onMouseLeave={handleMouseLeave} className="absolute inset-0 cursor-crosshair" style={{
width: imageDimensions.width * canvasScale || '100%',
height: imageDimensions.height * canvasScale || '100%',
opacity: showMask ? 0.6 : 0,
pointerEvents: showMask ? 'auto' : 'none',
mixBlendMode: 'screen',
}}/>
{/* Brush cursor preview */}
{showMask && (<div className="pointer-events-none absolute rounded-full border-2 border-white/50" style={{
width: brushSize,
height: brushSize,
transform: 'translate(-50%, -50%)',
left: '50%',
top: '50%',
display: 'none',
}}/>)}
</div>
</div>
{/* Toolbar */}
<div className="flex items-center justify-center gap-6 px-6 py-4 border-t border-white/10 bg-black/50 backdrop-blur-xl">
{/* Mode buttons */}
<div className="flex items-center gap-2 p-1 rounded-xl bg-white/5 border border-white/10">
<button onClick={() => setMode('brush')} className={`flex items-center gap-2 px-4 py-2 rounded-lg transition-colors ${mode === 'brush'
? 'bg-purple-500 text-white'
: 'text-white/60 hover:text-white hover:bg-white/10'}`} title="Brush (B)">
<Brush size={18}/>
<span className="text-sm font-medium">Brush</span>
</button>
<button onClick={() => setMode('eraser')} className={`flex items-center gap-2 px-4 py-2 rounded-lg transition-colors ${mode === 'eraser'
? 'bg-purple-500 text-white'
: 'text-white/60 hover:text-white hover:bg-white/10'}`} title="Eraser (E)">
<Eraser size={18}/>
<span className="text-sm font-medium">Eraser</span>
</button>
</div>
{/* Brush size */}
<div className="flex items-center gap-3">
<span className="text-xs text-white/40 uppercase tracking-wider font-semibold">
Size
</span>
<input type="range" min={5} max={200} value={brushSize} onChange={(e) => setBrushSize(Number(e.target.value))} className="w-32 h-1.5 bg-white/10 rounded-full appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:bg-purple-400 [&::-webkit-slider-thumb]:rounded-full"/>
<span className="text-sm text-white/60 w-8">{brushSize}</span>
</div>
{/* Brush opacity */}
<div className="flex items-center gap-3">
<span className="text-xs text-white/40 uppercase tracking-wider font-semibold">
Opacity
</span>
<input type="range" min={0.1} max={1} step={0.1} value={brushOpacity} onChange={(e) => setBrushOpacity(Number(e.target.value))} className="w-24 h-1.5 bg-white/10 rounded-full appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:bg-purple-400 [&::-webkit-slider-thumb]:rounded-full"/>
<span className="text-sm text-white/60 w-8">
{Math.round(brushOpacity * 100)}%
</span>
</div>
{/* Divider */}
<div className="w-px h-8 bg-white/10"/>
{/* History buttons */}
<div className="flex items-center gap-1">
<button onClick={handleUndo} disabled={historyIndex <= 0} className="p-2 rounded-lg text-white/60 hover:text-white hover:bg-white/10 disabled:opacity-30 disabled:cursor-not-allowed transition-colors" title="Undo (Ctrl+Z)">
<Undo2 size={18}/>
</button>
<button onClick={handleRedo} disabled={historyIndex >= history.length - 1} className="p-2 rounded-lg text-white/60 hover:text-white hover:bg-white/10 disabled:opacity-30 disabled:cursor-not-allowed transition-colors" title="Redo (Ctrl+Shift+Z)">
<Redo2 size={18}/>
</button>
</div>
{/* Clear button */}
<button onClick={handleClear} className="flex items-center gap-2 px-3 py-2 rounded-lg text-white/60 hover:text-red-400 hover:bg-red-500/10 transition-colors" title="Clear mask">
<Trash2 size={18}/>
<span className="text-sm font-medium">Clear</span>
</button>
{/* Toggle visibility */}
<button onClick={() => setShowMask(!showMask)} className="flex items-center gap-2 px-3 py-2 rounded-lg text-white/60 hover:text-white hover:bg-white/10 transition-colors" title="Toggle mask visibility">
{showMask ? <Eye size={18}/> : <EyeOff size={18}/>}
<span className="text-sm font-medium">{showMask ? 'Hide' : 'Show'}</span>
</button>
</div>
{/* Keyboard shortcuts help */}
<div className="absolute bottom-20 left-4 text-[10px] text-white/30 space-y-1">
<div>
<kbd className="px-1 py-0.5 bg-white/10 rounded">B</kbd> Brush
</div>
<div>
<kbd className="px-1 py-0.5 bg-white/10 rounded">E</kbd> Eraser
</div>
<div>
<kbd className="px-1 py-0.5 bg-white/10 rounded">[</kbd>{' '}
<kbd className="px-1 py-0.5 bg-white/10 rounded">]</kbd> Brush size
</div>
<div>
<kbd className="px-1 py-0.5 bg-white/10 rounded">Ctrl+Z</kbd> Undo
</div>
<div>
<kbd className="px-1 py-0.5 bg-white/10 rounded">Enter</kbd> Apply
</div>
<div>
<kbd className="px-1 py-0.5 bg-white/10 rounded">Esc</kbd> Cancel
</div>
</div>
</div>);
}