mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat(erase-object): add freeform lasso selection mode (#503)
Adds a Brush | Lasso toggle to the object eraser. Lasso lets the user drag a freeform loop that auto-closes and fills into the mask, so they select around a subject instead of painting every pixel. Frontend-only; the mask contract is unchanged. Also un-skips the erase-object e2e suite via a shared mockAiFeaturesInstalled helper (7 tests now run; 2 multi-file tests fixme'd for a pre-existing tool-page remount bug). Closes #492.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { Download, Redo, Trash2 } from "lucide-react";
|
||||
import { Download, Lasso, Paintbrush, Redo, Trash2 } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
@@ -117,6 +117,8 @@ interface EraseObjectSettingsProps {
|
||||
hasStrokes: boolean;
|
||||
brushSize: number;
|
||||
onBrushSizeChange: (size: number) => void;
|
||||
mode: "brush" | "lasso";
|
||||
onModeChange: (mode: "brush" | "lasso") => void;
|
||||
onMaskCenter?: (centerPct: number) => void;
|
||||
maskedFileCount: number;
|
||||
}
|
||||
@@ -126,6 +128,8 @@ export function EraseObjectSettings({
|
||||
hasStrokes,
|
||||
brushSize,
|
||||
onBrushSizeChange: setBrushSize,
|
||||
mode,
|
||||
onModeChange,
|
||||
onMaskCenter,
|
||||
maskedFileCount,
|
||||
}: EraseObjectSettingsProps) {
|
||||
@@ -439,29 +443,63 @@ export function EraseObjectSettings({
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Brush size */}
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<label htmlFor="eraser-brush-size" className="text-xs text-muted-foreground">
|
||||
{t.toolSettings["erase-object"].brushSize}
|
||||
</label>
|
||||
<span className="text-xs font-mono text-foreground">{brushSize}px</span>
|
||||
</div>
|
||||
<input
|
||||
id="eraser-brush-size"
|
||||
type="range"
|
||||
min={5}
|
||||
max={100}
|
||||
value={brushSize}
|
||||
onChange={(e) => setBrushSize(Number(e.target.value))}
|
||||
className="w-full mt-1"
|
||||
/>
|
||||
<div className="flex justify-between text-[10px] text-muted-foreground mt-0.5">
|
||||
<span>{t.toolSettings["erase-object"].fine}</span>
|
||||
<span>{t.toolSettings["erase-object"].wide}</span>
|
||||
</div>
|
||||
{/* Mode: brush vs lasso */}
|
||||
<div className="flex gap-1 rounded-lg bg-muted p-1">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="eraser-mode-brush"
|
||||
aria-pressed={mode === "brush"}
|
||||
onClick={() => onModeChange("brush")}
|
||||
className={`flex-1 flex items-center justify-center gap-1.5 py-1.5 rounded-md text-xs font-medium transition-colors ${
|
||||
mode === "brush"
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<Paintbrush className="h-3.5 w-3.5" />
|
||||
{t.toolSettings["erase-object"].brushMode}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="eraser-mode-lasso"
|
||||
aria-pressed={mode === "lasso"}
|
||||
onClick={() => onModeChange("lasso")}
|
||||
className={`flex-1 flex items-center justify-center gap-1.5 py-1.5 rounded-md text-xs font-medium transition-colors ${
|
||||
mode === "lasso"
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<Lasso className="h-3.5 w-3.5" />
|
||||
{t.toolSettings["erase-object"].lassoMode}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Brush size (brush mode only) */}
|
||||
{mode === "brush" && (
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<label htmlFor="eraser-brush-size" className="text-xs text-muted-foreground">
|
||||
{t.toolSettings["erase-object"].brushSize}
|
||||
</label>
|
||||
<span className="text-xs font-mono text-foreground">{brushSize}px</span>
|
||||
</div>
|
||||
<input
|
||||
id="eraser-brush-size"
|
||||
type="range"
|
||||
min={5}
|
||||
max={100}
|
||||
value={brushSize}
|
||||
onChange={(e) => setBrushSize(Number(e.target.value))}
|
||||
className="w-full mt-1"
|
||||
/>
|
||||
<div className="flex justify-between text-[10px] text-muted-foreground mt-0.5">
|
||||
<span>{t.toolSettings["erase-object"].fine}</span>
|
||||
<span>{t.toolSettings["erase-object"].wide}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Clear / Undo */}
|
||||
{hasStrokes && (
|
||||
<div className="flex gap-2">
|
||||
@@ -528,7 +566,9 @@ export function EraseObjectSettings({
|
||||
{/* Hint */}
|
||||
{hasFile && !hasStrokes && (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
Paint over the objects you want to remove. Use Ctrl+Z to undo.
|
||||
{mode === "lasso"
|
||||
? t.toolSettings["erase-object"].lassoHint
|
||||
: t.toolSettings["erase-object"].paintHint}
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
||||
@@ -4,13 +4,29 @@ import { useZoomPan } from "@/hooks/use-zoom-pan";
|
||||
import { renderSize } from "@/hooks/zoom-pan-math";
|
||||
|
||||
type Point = { x: number; y: number };
|
||||
type Stroke = { points: Point[]; size: number };
|
||||
type StrokeKind = "brush" | "lasso";
|
||||
type Stroke = { points: Point[]; size: number; kind: StrokeKind };
|
||||
type ImageStrokeData = {
|
||||
strokes: Stroke[];
|
||||
canvasSize: { w: number; h: number };
|
||||
naturalSize: { w: number; h: number };
|
||||
};
|
||||
|
||||
// A lasso must enclose at least this many points and this much fitted-px area to
|
||||
// count, so an accidental tap or tiny drag never leaves a stray filled region.
|
||||
const MIN_LASSO_POINTS = 3;
|
||||
const MIN_LASSO_AREA = 100;
|
||||
|
||||
// Shoelace area of a polygon in fitted canvas coordinates.
|
||||
function lassoArea(points: Point[]): number {
|
||||
let area = 0;
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
const j = (i + 1) % points.length;
|
||||
area += points[i].x * points[j].y - points[j].x * points[i].y;
|
||||
}
|
||||
return Math.abs(area) / 2;
|
||||
}
|
||||
|
||||
export interface EraserCanvasRef {
|
||||
exportMask: () => Promise<Blob | null>;
|
||||
exportAllMasks: () => Promise<Map<string, Blob>>;
|
||||
@@ -23,12 +39,13 @@ export interface EraserCanvasRef {
|
||||
interface EraserCanvasProps {
|
||||
imageSrc: string;
|
||||
brushSize: number;
|
||||
mode: StrokeKind;
|
||||
onStrokeChange: (hasStrokes: boolean) => void;
|
||||
onMaskedCountChange?: (count: number) => void;
|
||||
}
|
||||
|
||||
export const EraserCanvas = forwardRef<EraserCanvasRef, EraserCanvasProps>(function EraserCanvas(
|
||||
{ imageSrc, brushSize, onStrokeChange, onMaskedCountChange },
|
||||
{ imageSrc, brushSize, mode, onStrokeChange, onMaskedCountChange },
|
||||
ref,
|
||||
) {
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
@@ -120,6 +137,19 @@ export const EraserCanvas = forwardRef<EraserCanvasRef, EraserCanvasProps>(funct
|
||||
ctx.lineCap = "round";
|
||||
ctx.lineJoin = "round";
|
||||
|
||||
if (stroke.kind === "lasso") {
|
||||
if (stroke.points.length < MIN_LASSO_POINTS) return;
|
||||
ctx.beginPath();
|
||||
ctx.fillStyle = "rgba(255, 60, 60, 0.4)";
|
||||
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.closePath();
|
||||
ctx.fill();
|
||||
return;
|
||||
}
|
||||
|
||||
if (stroke.points.length === 1) {
|
||||
ctx.beginPath();
|
||||
ctx.fillStyle = "rgba(255, 60, 60, 0.4)";
|
||||
@@ -198,16 +228,19 @@ export const EraserCanvas = forwardRef<EraserCanvasRef, EraserCanvasProps>(funct
|
||||
drawingRef.current = true;
|
||||
currentPointsRef.current = [pt];
|
||||
|
||||
// Immediate dot
|
||||
const ctx = prepCtx();
|
||||
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();
|
||||
// Brush: drop an immediate dot so a single tap erases. Lasso: draw nothing
|
||||
// until the loop takes shape on move.
|
||||
if (mode === "brush") {
|
||||
const ctx = prepCtx();
|
||||
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, isPanMode, beginPan, prepCtx],
|
||||
[getPoint, brushSize, mode, isPanMode, beginPan, prepCtx],
|
||||
);
|
||||
|
||||
const handleMove = useCallback(
|
||||
@@ -224,9 +257,10 @@ export const EraserCanvas = forwardRef<EraserCanvasRef, EraserCanvasProps>(funct
|
||||
return;
|
||||
}
|
||||
|
||||
// Update cursor position for brush preview
|
||||
// Brush shows a round cursor preview; lasso uses a crosshair (no preview dot).
|
||||
const pt = getPoint(e);
|
||||
if (pt) setCursorPos(pt);
|
||||
if (pt && mode === "brush") setCursorPos(pt);
|
||||
else setCursorPos(null);
|
||||
|
||||
if (!drawingRef.current) return;
|
||||
if ("touches" in e) e.preventDefault();
|
||||
@@ -237,8 +271,27 @@ export const EraserCanvas = forwardRef<EraserCanvasRef, EraserCanvasProps>(funct
|
||||
const ctx = prepCtx();
|
||||
if (!ctx) return;
|
||||
const pts = currentPointsRef.current;
|
||||
if (pts.length < 2) return;
|
||||
|
||||
if (mode === "lasso") {
|
||||
// The filled shape changes as points are added, so repaint the committed
|
||||
// strokes then overlay the in-progress loop as a translucent preview.
|
||||
redraw();
|
||||
if (pts.length >= 2) {
|
||||
ctx.beginPath();
|
||||
ctx.fillStyle = "rgba(255, 60, 60, 0.25)";
|
||||
ctx.strokeStyle = "rgba(255, 60, 60, 0.8)";
|
||||
ctx.lineWidth = 2;
|
||||
ctx.lineJoin = "round";
|
||||
ctx.moveTo(pts[0].x, pts[0].y);
|
||||
for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (pts.length < 2) return;
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = "rgba(255, 60, 60, 0.4)";
|
||||
ctx.lineWidth = brushSize;
|
||||
@@ -247,7 +300,7 @@ export const EraserCanvas = forwardRef<EraserCanvasRef, EraserCanvasProps>(funct
|
||||
ctx.lineTo(pt.x, pt.y);
|
||||
ctx.stroke();
|
||||
},
|
||||
[getPoint, brushSize, isPanMode, movePan, prepCtx],
|
||||
[getPoint, brushSize, mode, isPanMode, movePan, prepCtx, redraw],
|
||||
);
|
||||
|
||||
const handleUp = useCallback(() => {
|
||||
@@ -255,18 +308,23 @@ export const EraserCanvas = forwardRef<EraserCanvasRef, EraserCanvasProps>(funct
|
||||
if (!drawingRef.current) return;
|
||||
drawingRef.current = false;
|
||||
|
||||
if (currentPointsRef.current.length > 0) {
|
||||
strokesRef.current.push({
|
||||
points: [...currentPointsRef.current],
|
||||
size: brushSize,
|
||||
});
|
||||
currentPointsRef.current = [];
|
||||
onStrokeChange(true);
|
||||
const pts = currentPointsRef.current;
|
||||
currentPointsRef.current = [];
|
||||
if (pts.length > 0) {
|
||||
if (mode === "lasso") {
|
||||
// Auto-close the loop into a filled region; ignore accidental taps / tiny loops.
|
||||
if (pts.length >= MIN_LASSO_POINTS && lassoArea(pts) >= MIN_LASSO_AREA) {
|
||||
strokesRef.current.push({ points: [...pts], size: 0, kind: "lasso" });
|
||||
}
|
||||
} else {
|
||||
strokesRef.current.push({ points: [...pts], size: brushSize, kind: "brush" });
|
||||
}
|
||||
onStrokeChange(strokesRef.current.length > 0);
|
||||
redraw();
|
||||
persistStrokes();
|
||||
onMaskedCountChange?.(allStrokesRef.current.size);
|
||||
}
|
||||
}, [brushSize, onStrokeChange, onMaskedCountChange, redraw, persistStrokes, endPan]);
|
||||
}, [brushSize, mode, onStrokeChange, onMaskedCountChange, redraw, persistStrokes, endPan]);
|
||||
|
||||
const handleLeave = useCallback(() => {
|
||||
setCursorPos(null);
|
||||
@@ -299,6 +357,18 @@ export const EraserCanvas = forwardRef<EraserCanvasRef, EraserCanvasProps>(funct
|
||||
ctx.lineJoin = "round";
|
||||
|
||||
for (const stroke of strokesRef.current) {
|
||||
if (stroke.kind === "lasso") {
|
||||
if (stroke.points.length < MIN_LASSO_POINTS) continue;
|
||||
ctx.beginPath();
|
||||
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.closePath();
|
||||
ctx.fill();
|
||||
continue;
|
||||
}
|
||||
|
||||
const scaledSize = stroke.size * Math.max(sx, sy);
|
||||
|
||||
if (stroke.points.length === 1) {
|
||||
@@ -345,6 +415,18 @@ export const EraserCanvas = forwardRef<EraserCanvasRef, EraserCanvasProps>(funct
|
||||
ctx.lineCap = "round";
|
||||
ctx.lineJoin = "round";
|
||||
for (const stroke of data.strokes) {
|
||||
if (stroke.kind === "lasso") {
|
||||
if (stroke.points.length < MIN_LASSO_POINTS) continue;
|
||||
ctx.beginPath();
|
||||
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.closePath();
|
||||
ctx.fill();
|
||||
continue;
|
||||
}
|
||||
|
||||
const scaledSize = stroke.size * Math.max(sx, sy);
|
||||
if (stroke.points.length === 1) {
|
||||
ctx.beginPath();
|
||||
@@ -463,7 +545,7 @@ export const EraserCanvas = forwardRef<EraserCanvasRef, EraserCanvasProps>(funct
|
||||
style={{
|
||||
width: canvasSize.w,
|
||||
height: canvasSize.h,
|
||||
cursor: isPanMode ? "grab" : "none",
|
||||
cursor: isPanMode ? "grab" : mode === "lasso" ? "crosshair" : "none",
|
||||
}}
|
||||
onMouseDown={handleDown}
|
||||
onMouseMove={handleMove}
|
||||
@@ -473,8 +555,8 @@ export const EraserCanvas = forwardRef<EraserCanvasRef, EraserCanvasProps>(funct
|
||||
onTouchMove={handleMove}
|
||||
onTouchEnd={handleUp}
|
||||
/>
|
||||
{/* Brush cursor preview */}
|
||||
{cursorPos && !isPanMode && (
|
||||
{/* Brush cursor preview (brush mode only) */}
|
||||
{cursorPos && !isPanMode && mode === "brush" && (
|
||||
<div
|
||||
className="pointer-events-none absolute rounded-full border-2 border-white/80"
|
||||
style={{
|
||||
|
||||
@@ -49,6 +49,8 @@ export interface EraserProps {
|
||||
hasStrokes: boolean;
|
||||
brushSize: number;
|
||||
onBrushSizeChange: (size: number) => void;
|
||||
mode: "brush" | "lasso";
|
||||
onModeChange: (mode: "brush" | "lasso") => void;
|
||||
onMaskCenter?: (centerPct: number) => void;
|
||||
maskedFileCount: number;
|
||||
}
|
||||
|
||||
@@ -361,6 +361,7 @@ export function ToolPage() {
|
||||
const eraserRef = useRef<EraserCanvasRef | null>(null);
|
||||
const [eraserHasStrokes, setEraserHasStrokes] = useState(false);
|
||||
const [eraserBrushSize, setEraserBrushSize] = useState(30);
|
||||
const [eraserMode, setEraserMode] = useState<"brush" | "lasso">("brush");
|
||||
const [eraserMaskedCount, setEraserMaskedCount] = useState(0);
|
||||
// Center of the painted mask as a 0-100 percentage, used to init the slider at the erased spot
|
||||
const [eraserSliderInitPos, setEraserSliderInitPos] = useState<number | null>(null);
|
||||
@@ -660,6 +661,8 @@ export function ToolPage() {
|
||||
hasStrokes: eraserHasStrokes,
|
||||
brushSize: eraserBrushSize,
|
||||
onBrushSizeChange: setEraserBrushSize,
|
||||
mode: eraserMode,
|
||||
onModeChange: setEraserMode,
|
||||
onMaskCenter: setEraserSliderInitPos,
|
||||
maskedFileCount: eraserMaskedCount,
|
||||
}
|
||||
@@ -851,6 +854,7 @@ export function ToolPage() {
|
||||
ref={eraserRef}
|
||||
imageSrc={originalBlobUrl}
|
||||
brushSize={eraserBrushSize}
|
||||
mode={eraserMode}
|
||||
onStrokeChange={setEraserHasStrokes}
|
||||
onMaskedCountChange={setEraserMaskedCount}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user