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:
SnapOtter
2026-07-11 22:27:51 +08:00
committed by GitHub
parent 380419dd06
commit 601557edae
27 changed files with 408 additions and 124 deletions
@@ -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>
)}