Files
SnapOtter/apps/web/src/components/tools/eraser-canvas.tsx
T
Siddharth Kumar Sah 40656a0452 feat(erase-object): replace mask upload with in-browser brush painting
Replace the external mask file upload workflow with an interactive
canvas-based brush tool. Users now paint directly on the image to
mark areas for erasure. Adds EraserCanvas component with adjustable
brush size, undo/clear, and mask export. Switch Python inpainting
from broken lama-cleaner to OpenCV cv2.inpaint (Telea algorithm).
Add before/after comparison slider after processing.
2026-03-26 17:33:17 +08:00

269 lines
7.8 KiB
TypeScript

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<Blob | null>;
clear: () => void;
undo: () => void;
}
interface EraserCanvasProps {
imageSrc: string;
brushSize: number;
onStrokeChange: (hasStrokes: boolean) => void;
}
export const EraserCanvas = forwardRef<EraserCanvasRef, EraserCanvasProps>(function EraserCanvas(
{ imageSrc, brushSize, onStrokeChange },
ref,
) {
const wrapperRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const imgRef = useRef<HTMLImageElement>(null);
const [canvasSize, setCanvasSize] = useState<{ w: number; h: number } | null>(null);
const naturalRef = useRef({ w: 0, h: 0 });
const strokesRef = useRef<Stroke[]>([]);
const drawingRef = useRef(false);
const currentPointsRef = useRef<Point[]>([]);
// 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<Blob | null>((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 (
<div ref={wrapperRef} className="relative flex items-center justify-center w-full h-full">
{/* Hidden img for measuring natural size before canvas is ready */}
{!canvasSize && (
<img
ref={imgRef}
src={imageSrc}
onLoad={measure}
alt=""
className="max-w-full max-h-full object-contain"
/>
)}
{canvasSize && (
<div className="relative" style={{ width: canvasSize.w, height: canvasSize.h }}>
<img
ref={imgRef}
src={imageSrc}
alt="Paint over objects to erase"
className="block"
style={{ width: canvasSize.w, height: canvasSize.h }}
/>
<canvas
ref={canvasRef}
width={canvasSize.w}
height={canvasSize.h}
className="absolute inset-0 cursor-crosshair touch-none"
onMouseDown={handleDown}
onMouseMove={handleMove}
onMouseUp={handleUp}
onMouseLeave={handleUp}
onTouchStart={handleDown}
onTouchMove={handleMove}
onTouchEnd={handleUp}
/>
</div>
)}
</div>
);
});