mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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.
269 lines
7.8 KiB
TypeScript
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>
|
|
);
|
|
});
|