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}
|
||||
/>
|
||||
|
||||
@@ -1555,6 +1555,8 @@ export const ar: TranslationKeys = {
|
||||
progressLabelBatch: "جاري تكبير {count} صورة",
|
||||
},
|
||||
"erase-object": {
|
||||
brushMode: "فرشاة",
|
||||
lassoMode: "لاسو",
|
||||
brushSize: "حجم الفرشاة",
|
||||
fine: "دقيق",
|
||||
wide: "عريض",
|
||||
@@ -1563,6 +1565,7 @@ export const ar: TranslationKeys = {
|
||||
outputFormat: "صيغة الإخراج",
|
||||
quality: "الجودة",
|
||||
paintHint: "ارسم فوق الكائنات التي تريد إزالتها. استخدم Ctrl+Z للتراجع.",
|
||||
lassoHint: "ارسم حلقة حول الكائن الذي تريد إزالته. استخدم Ctrl+Z للتراجع.",
|
||||
submit: "مسح الكائن",
|
||||
submitBatch: "مسح الكل ({count})",
|
||||
progressLabel: "جاري مسح الكائن",
|
||||
|
||||
@@ -1572,6 +1572,8 @@ export const de: TranslationKeys = {
|
||||
progressLabelBatch: "{count} Bilder werden hochskaliert",
|
||||
},
|
||||
"erase-object": {
|
||||
brushMode: "Pinsel",
|
||||
lassoMode: "Lasso",
|
||||
brushSize: "Pinselgröße",
|
||||
fine: "Fein",
|
||||
wide: "Breit",
|
||||
@@ -1581,6 +1583,8 @@ export const de: TranslationKeys = {
|
||||
quality: "Qualität",
|
||||
paintHint:
|
||||
"Malen Sie über die Objekte, die Sie entfernen möchten. Strg+Z zum Rückgängigmachen.",
|
||||
lassoHint:
|
||||
"Ziehen Sie eine Schlaufe um das Objekt, das Sie entfernen möchten. Strg+Z zum Rückgängigmachen.",
|
||||
submit: "Objekt entfernen",
|
||||
submitBatch: "Alle entfernen ({count})",
|
||||
progressLabel: "Objekt wird entfernt",
|
||||
|
||||
@@ -1519,6 +1519,8 @@ export const en = {
|
||||
progressLabelBatch: "Upscaling {count} images",
|
||||
},
|
||||
"erase-object": {
|
||||
brushMode: "Brush",
|
||||
lassoMode: "Lasso",
|
||||
brushSize: "Brush Size",
|
||||
fine: "Fine",
|
||||
wide: "Wide",
|
||||
@@ -1527,6 +1529,7 @@ export const en = {
|
||||
outputFormat: "Output Format",
|
||||
quality: "Quality",
|
||||
paintHint: "Paint over the objects you want to remove. Use Ctrl+Z to undo.",
|
||||
lassoHint: "Draw a loop around the object you want to remove. Use Ctrl+Z to undo.",
|
||||
submit: "Erase Object",
|
||||
submitBatch: "Erase All ({count})",
|
||||
progressLabel: "Erasing object",
|
||||
|
||||
@@ -1555,6 +1555,8 @@ export const es: TranslationKeys = {
|
||||
progressLabelBatch: "Escalando {count} imágenes",
|
||||
},
|
||||
"erase-object": {
|
||||
brushMode: "Pincel",
|
||||
lassoMode: "Lazo",
|
||||
brushSize: "Tamaño del pincel",
|
||||
fine: "Fino",
|
||||
wide: "Ancho",
|
||||
@@ -1563,6 +1565,8 @@ export const es: TranslationKeys = {
|
||||
outputFormat: "Formato de salida",
|
||||
quality: "Calidad",
|
||||
paintHint: "Pinta sobre los objetos que quieras eliminar. Usa Ctrl+Z para deshacer.",
|
||||
lassoHint:
|
||||
"Dibuja un lazo alrededor del objeto que quieras eliminar. Usa Ctrl+Z para deshacer.",
|
||||
submit: "Borrar objeto",
|
||||
submitBatch: "Borrar todos ({count})",
|
||||
progressLabel: "Borrando objeto",
|
||||
|
||||
@@ -1579,6 +1579,8 @@ export const fr: TranslationKeys = {
|
||||
progressLabelBatch: "Agrandissement de {count} images",
|
||||
},
|
||||
"erase-object": {
|
||||
brushMode: "Pinceau",
|
||||
lassoMode: "Lasso",
|
||||
brushSize: "Taille du pinceau",
|
||||
fine: "Fin",
|
||||
wide: "Large",
|
||||
@@ -1588,6 +1590,8 @@ export const fr: TranslationKeys = {
|
||||
quality: "Qualité",
|
||||
paintHint:
|
||||
"Peignez sur les objets que vous souhaitez supprimer. Utilisez Ctrl+Z pour annuler.",
|
||||
lassoHint:
|
||||
"Tracez une boucle autour de l'objet que vous souhaitez supprimer. Utilisez Ctrl+Z pour annuler.",
|
||||
submit: "Effacer l'objet",
|
||||
submitBatch: "Tout effacer ({count})",
|
||||
progressLabel: "Effacement de l'objet",
|
||||
|
||||
@@ -1385,6 +1385,8 @@ export const hi: TranslationKeys = {
|
||||
progressLabelBatch: "{count} इमेज अपस्केल हो रही हैं",
|
||||
},
|
||||
"erase-object": {
|
||||
brushMode: "ब्रश",
|
||||
lassoMode: "लासो",
|
||||
brushSize: "ब्रश साइज़",
|
||||
fine: "महीन",
|
||||
wide: "चौड़ा",
|
||||
@@ -1393,6 +1395,7 @@ export const hi: TranslationKeys = {
|
||||
outputFormat: "आउटपुट फॉर्मेट",
|
||||
quality: "क्वालिटी",
|
||||
paintHint: "जिन वस्तुओं को हटाना है उन पर पेंट करें। पूर्ववत के लिए Ctrl+Z दबाएं।",
|
||||
lassoHint: "जिस वस्तु को हटाना है उसके चारों ओर लूप बनाएं। पूर्ववत के लिए Ctrl+Z दबाएं।",
|
||||
submit: "ऑब्जेक्ट मिटाएं",
|
||||
submitBatch: "सभी मिटाएं ({count})",
|
||||
progressLabel: "ऑब्जेक्ट मिटाया जा रहा है",
|
||||
|
||||
@@ -1565,6 +1565,8 @@ export const id: TranslationKeys = {
|
||||
progressLabelBatch: "Memperbesar {count} gambar",
|
||||
},
|
||||
"erase-object": {
|
||||
brushMode: "Kuas",
|
||||
lassoMode: "Laso",
|
||||
brushSize: "Ukuran Kuas",
|
||||
fine: "Halus",
|
||||
wide: "Lebar",
|
||||
@@ -1573,6 +1575,8 @@ export const id: TranslationKeys = {
|
||||
outputFormat: "Format Output",
|
||||
quality: "Kualitas",
|
||||
paintHint: "Cat di atas objek yang ingin Anda hapus. Gunakan Ctrl+Z untuk undo.",
|
||||
lassoHint:
|
||||
"Gambar lingkaran di sekeliling objek yang ingin Anda hapus. Gunakan Ctrl+Z untuk undo.",
|
||||
submit: "Hapus Objek",
|
||||
submitBatch: "Hapus Semua ({count})",
|
||||
progressLabel: "Menghapus objek",
|
||||
|
||||
@@ -1570,6 +1570,8 @@ export const it: TranslationKeys = {
|
||||
progressLabelBatch: "Ingrandimento di {count} immagini",
|
||||
},
|
||||
"erase-object": {
|
||||
brushMode: "Pennello",
|
||||
lassoMode: "Lazo",
|
||||
brushSize: "Dimensione pennello",
|
||||
fine: "Sottile",
|
||||
wide: "Largo",
|
||||
@@ -1578,6 +1580,8 @@ export const it: TranslationKeys = {
|
||||
outputFormat: "Formato di output",
|
||||
quality: "Qualità",
|
||||
paintHint: "Dipingi sopra gli oggetti che vuoi rimuovere. Usa Ctrl+Z per annullare.",
|
||||
lassoHint:
|
||||
"Traccia un anello attorno all'oggetto che vuoi rimuovere. Usa Ctrl+Z per annullare.",
|
||||
submit: "Cancella oggetto",
|
||||
submitBatch: "Cancella tutti ({count})",
|
||||
progressLabel: "Cancellazione oggetto",
|
||||
|
||||
@@ -1527,6 +1527,8 @@ export const ja: TranslationKeys = {
|
||||
progressLabelBatch: "{count}枚の画像をアップスケール中",
|
||||
},
|
||||
"erase-object": {
|
||||
brushMode: "ブラシ",
|
||||
lassoMode: "投げ縄",
|
||||
brushSize: "ブラシサイズ",
|
||||
fine: "細い",
|
||||
wide: "太い",
|
||||
@@ -1535,6 +1537,7 @@ export const ja: TranslationKeys = {
|
||||
outputFormat: "出力フォーマット",
|
||||
quality: "品質",
|
||||
paintHint: "除去したいオブジェクトの上を塗ってください。Ctrl+Zで元に戻せます。",
|
||||
lassoHint: "除去したいオブジェクトを囲むように輪を描いてください。Ctrl+Zで元に戻せます。",
|
||||
submit: "消去",
|
||||
submitBatch: "すべて消去({count})",
|
||||
progressLabel: "オブジェクトを消去中",
|
||||
|
||||
@@ -1509,6 +1509,8 @@ export const ko: TranslationKeys = {
|
||||
progressLabelBatch: "{count}개 이미지 업스케일 중",
|
||||
},
|
||||
"erase-object": {
|
||||
brushMode: "브러시",
|
||||
lassoMode: "올가미",
|
||||
brushSize: "브러시 크기",
|
||||
fine: "가늘게",
|
||||
wide: "굵게",
|
||||
@@ -1517,6 +1519,7 @@ export const ko: TranslationKeys = {
|
||||
outputFormat: "출력 포맷",
|
||||
quality: "품질",
|
||||
paintHint: "제거할 객체 위를 칠하세요. Ctrl+Z로 실행 취소할 수 있습니다.",
|
||||
lassoHint: "제거할 객체 주위에 올가미를 그리세요. Ctrl+Z로 실행 취소할 수 있습니다.",
|
||||
submit: "객체 지우기",
|
||||
submitBatch: "모두 지우기 ({count})",
|
||||
progressLabel: "객체 지우는 중",
|
||||
|
||||
@@ -1569,6 +1569,8 @@ export const nl: TranslationKeys = {
|
||||
progressLabelBatch: "{count} afbeeldingen opschalen",
|
||||
},
|
||||
"erase-object": {
|
||||
brushMode: "Penseel",
|
||||
lassoMode: "Lasso",
|
||||
brushSize: "Penseelgrootte",
|
||||
fine: "Fijn",
|
||||
wide: "Breed",
|
||||
@@ -1578,6 +1580,8 @@ export const nl: TranslationKeys = {
|
||||
quality: "Kwaliteit",
|
||||
paintHint:
|
||||
"Verf over de objecten die je wilt verwijderen. Gebruik Ctrl+Z om ongedaan te maken.",
|
||||
lassoHint:
|
||||
"Teken een lus rond het object dat je wilt verwijderen. Gebruik Ctrl+Z om ongedaan te maken.",
|
||||
submit: "Object verwijderen",
|
||||
submitBatch: "Alles verwijderen ({count})",
|
||||
progressLabel: "Object verwijderen",
|
||||
|
||||
@@ -1570,6 +1570,8 @@ export const pl: TranslationKeys = {
|
||||
progressLabelBatch: "Powiększanie {count} obrazów",
|
||||
},
|
||||
"erase-object": {
|
||||
brushMode: "Pędzel",
|
||||
lassoMode: "Lasso",
|
||||
brushSize: "Rozmiar pędzla",
|
||||
fine: "Cienki",
|
||||
wide: "Szeroki",
|
||||
@@ -1578,6 +1580,7 @@ export const pl: TranslationKeys = {
|
||||
outputFormat: "Format wyjściowy",
|
||||
quality: "Jakość",
|
||||
paintHint: "Zamaluj obiekty, które chcesz usunąć. Naciśnij Ctrl+Z, aby cofnąć.",
|
||||
lassoHint: "Narysuj pętlę wokół obiektu, który chcesz usunąć. Naciśnij Ctrl+Z, aby cofnąć.",
|
||||
submit: "Usuń obiekt",
|
||||
submitBatch: "Usuń wszystko ({count})",
|
||||
progressLabel: "Usuwanie obiektu",
|
||||
|
||||
@@ -1568,6 +1568,8 @@ export const ptBR: TranslationKeys = {
|
||||
progressLabelBatch: "Ampliando {count} imagens",
|
||||
},
|
||||
"erase-object": {
|
||||
brushMode: "Pincel",
|
||||
lassoMode: "Laço",
|
||||
brushSize: "Tamanho do pincel",
|
||||
fine: "Fino",
|
||||
wide: "Largo",
|
||||
@@ -1576,6 +1578,7 @@ export const ptBR: TranslationKeys = {
|
||||
outputFormat: "Formato de saída",
|
||||
quality: "Qualidade",
|
||||
paintHint: "Pinte sobre os objetos que deseja remover. Use Ctrl+Z para desfazer.",
|
||||
lassoHint: "Desenhe um laço ao redor do objeto que deseja remover. Use Ctrl+Z para desfazer.",
|
||||
submit: "Apagar objeto",
|
||||
submitBatch: "Apagar todos ({count})",
|
||||
progressLabel: "Apagando objeto",
|
||||
|
||||
@@ -1565,6 +1565,8 @@ export const ru: TranslationKeys = {
|
||||
progressLabelBatch: "Увеличение {count} изображений",
|
||||
},
|
||||
"erase-object": {
|
||||
brushMode: "Кисть",
|
||||
lassoMode: "Лассо",
|
||||
brushSize: "Размер кисти",
|
||||
fine: "Тонкая",
|
||||
wide: "Широкая",
|
||||
@@ -1573,6 +1575,7 @@ export const ru: TranslationKeys = {
|
||||
outputFormat: "Формат вывода",
|
||||
quality: "Качество",
|
||||
paintHint: "Закрасьте объекты, которые хотите удалить. Нажмите Ctrl+Z для отмены.",
|
||||
lassoHint: "Обведите петлёй объект, который хотите удалить. Нажмите Ctrl+Z для отмены.",
|
||||
submit: "Удалить объект",
|
||||
submitBatch: "Удалить всё ({count})",
|
||||
progressLabel: "Удаление объекта",
|
||||
|
||||
@@ -1565,6 +1565,8 @@ export const sv: TranslationKeys = {
|
||||
progressLabelBatch: "Uppskalar {count} bilder",
|
||||
},
|
||||
"erase-object": {
|
||||
brushMode: "Pensel",
|
||||
lassoMode: "Lasso",
|
||||
brushSize: "Penselstorlek",
|
||||
fine: "Fin",
|
||||
wide: "Bred",
|
||||
@@ -1573,6 +1575,7 @@ export const sv: TranslationKeys = {
|
||||
outputFormat: "Utdataformat",
|
||||
quality: "Kvalitet",
|
||||
paintHint: "Måla över objekten du vill ta bort. Använd Ctrl+Z för att ångra.",
|
||||
lassoHint: "Rita en ögla runt objektet du vill ta bort. Använd Ctrl+Z för att ångra.",
|
||||
submit: "Radera objekt",
|
||||
submitBatch: "Radera alla ({count})",
|
||||
progressLabel: "Raderar objekt",
|
||||
|
||||
@@ -1545,6 +1545,8 @@ export const th: TranslationKeys = {
|
||||
progressLabelBatch: "กำลังขยาย {count} ภาพ",
|
||||
},
|
||||
"erase-object": {
|
||||
brushMode: "แปรง",
|
||||
lassoMode: "ลาสโซ",
|
||||
brushSize: "ขนาดแปรง",
|
||||
fine: "ละเอียด",
|
||||
wide: "กว้าง",
|
||||
@@ -1553,6 +1555,7 @@ export const th: TranslationKeys = {
|
||||
outputFormat: "รูปแบบเอาต์พุต",
|
||||
quality: "คุณภาพ",
|
||||
paintHint: "ระบายทับวัตถุที่ต้องการลบ กด Ctrl+Z เพื่อเลิกทำ",
|
||||
lassoHint: "วาดวงรอบวัตถุที่ต้องการลบ กด Ctrl+Z เพื่อเลิกทำ",
|
||||
submit: "ลบวัตถุ",
|
||||
submitBatch: "ลบทั้งหมด ({count})",
|
||||
progressLabel: "กำลังลบวัตถุ",
|
||||
|
||||
@@ -1568,6 +1568,8 @@ export const tr: TranslationKeys = {
|
||||
progressLabelBatch: "{count} görüntü büyütülüyor",
|
||||
},
|
||||
"erase-object": {
|
||||
brushMode: "Fırça",
|
||||
lassoMode: "Kement",
|
||||
brushSize: "Fırça Boyutu",
|
||||
fine: "İnce",
|
||||
wide: "Geniş",
|
||||
@@ -1577,6 +1579,8 @@ export const tr: TranslationKeys = {
|
||||
quality: "Kalite",
|
||||
paintHint:
|
||||
"Kaldırmak istediğiniz nesnelerin üzerini boyayın. Geri almak için Ctrl+Z kullanın.",
|
||||
lassoHint:
|
||||
"Kaldırmak istediğiniz nesnenin etrafına bir ilmek çizin. Geri almak için Ctrl+Z kullanın.",
|
||||
submit: "Nesneyi Sil",
|
||||
submitBatch: "Tümünü Sil ({count})",
|
||||
progressLabel: "Nesne siliniyor",
|
||||
|
||||
@@ -1568,6 +1568,8 @@ export const uk: TranslationKeys = {
|
||||
progressLabelBatch: "Збільшення {count} зображень",
|
||||
},
|
||||
"erase-object": {
|
||||
brushMode: "Пензель",
|
||||
lassoMode: "Ласо",
|
||||
brushSize: "Розмір пензля",
|
||||
fine: "Тонкий",
|
||||
wide: "Широкий",
|
||||
@@ -1576,6 +1578,7 @@ export const uk: TranslationKeys = {
|
||||
outputFormat: "Формат виводу",
|
||||
quality: "Якість",
|
||||
paintHint: "Замалюйте об'єкти, які хочете видалити. Натисніть Ctrl+Z для скасування.",
|
||||
lassoHint: "Обведіть петлею об'єкт, який хочете видалити. Натисніть Ctrl+Z для скасування.",
|
||||
submit: "Видалити об'єкт",
|
||||
submitBatch: "Видалити все ({count})",
|
||||
progressLabel: "Видалення об'єкта",
|
||||
|
||||
@@ -1568,6 +1568,8 @@ export const vi: TranslationKeys = {
|
||||
progressLabelBatch: "Đang phóng to {count} ảnh",
|
||||
},
|
||||
"erase-object": {
|
||||
brushMode: "Cọ",
|
||||
lassoMode: "Lasso",
|
||||
brushSize: "Kích thước cọ",
|
||||
fine: "Mảnh",
|
||||
wide: "Rộng",
|
||||
@@ -1576,6 +1578,7 @@ export const vi: TranslationKeys = {
|
||||
outputFormat: "Định dạng đầu ra",
|
||||
quality: "Chất lượng",
|
||||
paintHint: "Tô lên các đối tượng bạn muốn xóa. Nhấn Ctrl+Z để hoàn tác.",
|
||||
lassoHint: "Vẽ một vòng quanh đối tượng bạn muốn xóa. Nhấn Ctrl+Z để hoàn tác.",
|
||||
submit: "Xóa đối tượng",
|
||||
submitBatch: "Xóa tất cả ({count})",
|
||||
progressLabel: "Đang xóa đối tượng",
|
||||
|
||||
@@ -1333,6 +1333,8 @@ export const zhCN: TranslationKeys = {
|
||||
progressLabelBatch: "正在放大 {count} 张图片",
|
||||
},
|
||||
"erase-object": {
|
||||
brushMode: "画笔",
|
||||
lassoMode: "套索",
|
||||
brushSize: "画笔大小",
|
||||
fine: "细",
|
||||
wide: "粗",
|
||||
@@ -1341,6 +1343,7 @@ export const zhCN: TranslationKeys = {
|
||||
outputFormat: "输出格式",
|
||||
quality: "质量",
|
||||
paintHint: "涂抹要移除的物体。按 Ctrl+Z 撤销。",
|
||||
lassoHint: "在要移除的物体周围画一个圈。按 Ctrl+Z 撤销。",
|
||||
submit: "擦除物体",
|
||||
submitBatch: "全部擦除({count})",
|
||||
progressLabel: "正在擦除物体",
|
||||
|
||||
@@ -1333,6 +1333,8 @@ export const zhTW: TranslationKeys = {
|
||||
progressLabelBatch: "正在放大{count}張影像",
|
||||
},
|
||||
"erase-object": {
|
||||
brushMode: "筆刷",
|
||||
lassoMode: "套索",
|
||||
brushSize: "筆刷大小",
|
||||
fine: "精細",
|
||||
wide: "寬大",
|
||||
@@ -1341,6 +1343,7 @@ export const zhTW: TranslationKeys = {
|
||||
outputFormat: "輸出格式",
|
||||
quality: "品質",
|
||||
paintHint: "在要移除的物件上塗抹。按Ctrl+Z復原。",
|
||||
lassoHint: "在要移除的物件周圍畫一個圈。按Ctrl+Z復原。",
|
||||
submit: "擦除物件",
|
||||
submitBatch: "全部擦除({count})",
|
||||
progressLabel: "正在擦除物件",
|
||||
|
||||
+125
-76
@@ -1,5 +1,5 @@
|
||||
import path from "node:path";
|
||||
import { expect, test } from "./helpers";
|
||||
import { expect, mockAiFeaturesInstalled, test } from "./helpers";
|
||||
|
||||
function fixturePath(name: string): string {
|
||||
return path.join(process.cwd(), "tests", "fixtures", name);
|
||||
@@ -15,61 +15,68 @@ async function uploadFile(page: import("@playwright/test").Page, filePath: strin
|
||||
}
|
||||
|
||||
test.describe("Erase Object tool", () => {
|
||||
async function skipIfFeatureNotInstalled(page: import("@playwright/test").Page) {
|
||||
// The eraser is an AI tool: without its bundle it renders a FeatureInstallPrompt
|
||||
// instead of the tool, which made this whole suite silently skip in CI and on
|
||||
// any box without the bundle. Mock the feature as installed so the UI actually
|
||||
// renders and these tests run. The settings panel (brush size, mode toggle,
|
||||
// submit) only mounts once a file is loaded, so tests that touch it upload first.
|
||||
async function gotoEraser(page: import("@playwright/test").Page) {
|
||||
await mockAiFeaturesInstalled(page, [
|
||||
{
|
||||
id: "object-eraser-colorize",
|
||||
name: "Object Eraser",
|
||||
enablesTools: ["erase-object", "colorize"],
|
||||
},
|
||||
]);
|
||||
await page.goto("/image/erase-object");
|
||||
// Guard against route rot: a wrong/404 route must fail loudly, not silently skip
|
||||
// (skipping on a missing submit button previously masked the route being broken).
|
||||
// A wrong/404 route must fail loudly, not silently pass.
|
||||
await expect(page.getByRole("heading", { name: "404" })).toHaveCount(0);
|
||||
try {
|
||||
await page.getByTestId("erase-object-submit").waitFor({ state: "visible", timeout: 15_000 });
|
||||
} catch {
|
||||
test.skip(true, "object-eraser-colorize feature bundle not installed");
|
||||
}
|
||||
// Installed => the tool renders its dropzone (not the install prompt). Failing
|
||||
// here means the feature gate wasn't bypassed, not that a bundle is missing.
|
||||
await page
|
||||
.getByRole("button", { name: /upload from computer/i })
|
||||
.waitFor({ state: "visible", timeout: 15_000 });
|
||||
}
|
||||
|
||||
test("page loads with correct UI controls", async ({ loggedInPage: page }) => {
|
||||
await skipIfFeatureNotInstalled(page);
|
||||
|
||||
// Brush size slider
|
||||
await expect(page.getByText("Brush Size")).toBeVisible();
|
||||
await expect(page.locator("#eraser-brush-size")).toBeVisible();
|
||||
|
||||
// Output format dropdown
|
||||
await expect(page.locator("#eraser-format")).toBeVisible();
|
||||
|
||||
// Submit button is disabled with no file
|
||||
await expect(page.getByTestId("erase-object-submit")).toBeDisabled();
|
||||
});
|
||||
|
||||
test("submit button disabled without file", async ({ loggedInPage: page }) => {
|
||||
await skipIfFeatureNotInstalled(page);
|
||||
|
||||
await expect(page.getByTestId("erase-object-submit")).toBeDisabled();
|
||||
});
|
||||
|
||||
test("submit button remains disabled with file but no strokes", async ({
|
||||
test("shows the tool (not an install prompt) when the feature is available", async ({
|
||||
loggedInPage: page,
|
||||
}) => {
|
||||
await skipIfFeatureNotInstalled(page);
|
||||
await gotoEraser(page);
|
||||
// Dropzone is shown; the settings panel and submit only appear after a file.
|
||||
await expect(page.getByRole("button", { name: /upload from computer/i })).toBeVisible();
|
||||
await expect(page.getByTestId("erase-object-submit")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("loads settings controls once a file is added", async ({ loggedInPage: page }) => {
|
||||
await gotoEraser(page);
|
||||
await uploadFile(page, fixturePath("image/valid/test-200x150.png"));
|
||||
|
||||
// Submit should still be disabled because no strokes have been painted
|
||||
await expect(page.getByText("Brush Size")).toBeVisible();
|
||||
await expect(page.locator("#eraser-brush-size")).toBeVisible();
|
||||
await expect(page.locator("#eraser-format")).toBeVisible();
|
||||
// No strokes yet -> submit disabled.
|
||||
await expect(page.getByTestId("erase-object-submit")).toBeDisabled();
|
||||
});
|
||||
|
||||
test("submit stays disabled with a file but no strokes", async ({ loggedInPage: page }) => {
|
||||
await gotoEraser(page);
|
||||
await uploadFile(page, fixturePath("image/valid/test-200x150.png"));
|
||||
await expect(page.getByTestId("erase-object-submit")).toBeDisabled();
|
||||
});
|
||||
|
||||
test("brush size slider is interactive", async ({ loggedInPage: page }) => {
|
||||
await skipIfFeatureNotInstalled(page);
|
||||
await gotoEraser(page);
|
||||
await uploadFile(page, fixturePath("image/valid/test-200x150.png"));
|
||||
|
||||
const slider = page.locator("#eraser-brush-size");
|
||||
await expect(slider).toBeVisible();
|
||||
|
||||
// Change slider value
|
||||
await slider.fill("75");
|
||||
await expect(page.getByText("75px")).toBeVisible();
|
||||
});
|
||||
|
||||
test("quality slider shows for lossy formats only", async ({ loggedInPage: page }) => {
|
||||
await skipIfFeatureNotInstalled(page);
|
||||
await gotoEraser(page);
|
||||
await uploadFile(page, fixturePath("image/valid/test-200x150.png"));
|
||||
|
||||
const qualitySlider = page.locator("#eraser-quality");
|
||||
const formatSelect = page.locator("#eraser-format");
|
||||
@@ -90,8 +97,15 @@ test.describe("Erase Object tool", () => {
|
||||
await expect(qualitySlider).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("strokes persist when switching between files", async ({ loggedInPage: page }) => {
|
||||
await skipIfFeatureNotInstalled(page);
|
||||
// FIXME(pre-existing): cross-file stroke persistence is broken. tool-page renders
|
||||
// the image area under `key={`pending-${selectedIndex}`}`, so switching files
|
||||
// REMOUNTS EraserCanvas and wipes its in-component `allStrokesRef` (the per-image
|
||||
// stroke cache the multi-file design relies on): draw on A, switch to B and back,
|
||||
// and A's strokes are gone. The key predates the lasso work; the fix belongs in
|
||||
// tool-page's key logic. Un-fixme once EraserCanvas survives file switches. This
|
||||
// suite previously skipped entirely (feature gate), so it never caught this.
|
||||
test.fixme("strokes persist when switching between files", async ({ loggedInPage: page }) => {
|
||||
await gotoEraser(page);
|
||||
|
||||
// Upload first file
|
||||
await uploadFile(page, fixturePath("image/valid/test-200x150.png"));
|
||||
@@ -110,32 +124,72 @@ test.describe("Erase Object tool", () => {
|
||||
// Undo/Clear buttons should appear
|
||||
await expect(page.getByRole("button", { name: "Undo" })).toBeVisible();
|
||||
|
||||
// Upload second file via the "+ Add more" button
|
||||
// Add a DISTINCT second file so the file entries are unambiguous.
|
||||
const fileChooserPromise = page.waitForEvent("filechooser");
|
||||
await page.getByRole("button", { name: /Add more/i }).click();
|
||||
const fileChooser = await fileChooserPromise;
|
||||
await fileChooser.setFiles(fixturePath("image/valid/test-200x150.png"));
|
||||
await fileChooser.setFiles(fixturePath("image/valid/test-100x100.jpg"));
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Switch to second file (click thumbnail or file entry)
|
||||
// The file list shows buttons with the filename - click the second one
|
||||
const fileEntries = page.locator("button").filter({ hasText: "test-200x150.png" });
|
||||
const count = await fileEntries.count();
|
||||
if (count >= 2) {
|
||||
await fileEntries.nth(1).click();
|
||||
await page.waitForTimeout(300);
|
||||
// Switch to the second file, then back to the first (select by unique name).
|
||||
await page.locator("button").filter({ hasText: "test-100x100.jpg" }).first().click();
|
||||
await page.waitForTimeout(300);
|
||||
await page.locator("button").filter({ hasText: "test-200x150.png" }).first().click();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Switch back to first file
|
||||
await fileEntries.first().click();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Undo button should still be visible (strokes were preserved)
|
||||
await expect(page.getByRole("button", { name: "Undo" })).toBeVisible();
|
||||
}
|
||||
// The first file's stroke was preserved -> Undo is still available.
|
||||
await expect(page.getByRole("button", { name: "Undo" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("shows Erase All button when multiple files have masks", async ({ loggedInPage: page }) => {
|
||||
await skipIfFeatureNotInstalled(page);
|
||||
test("lasso mode toggle switches modes and hides the brush size", async ({
|
||||
loggedInPage: page,
|
||||
}) => {
|
||||
await gotoEraser(page);
|
||||
await uploadFile(page, fixturePath("image/valid/test-200x150.png"));
|
||||
|
||||
// Brush is the default mode: the brush-size slider is shown.
|
||||
await expect(page.locator("#eraser-brush-size")).toBeVisible();
|
||||
|
||||
// Switch to lasso: the brush-size slider is hidden.
|
||||
await page.getByTestId("eraser-mode-lasso").click();
|
||||
await expect(page.locator("#eraser-brush-size")).not.toBeVisible();
|
||||
|
||||
// Switch back to brush: the slider returns.
|
||||
await page.getByTestId("eraser-mode-brush").click();
|
||||
await expect(page.locator("#eraser-brush-size")).toBeVisible();
|
||||
});
|
||||
|
||||
test("drawing a lasso loop enables submit", async ({ loggedInPage: page }) => {
|
||||
await gotoEraser(page);
|
||||
await uploadFile(page, fixturePath("image/valid/test-200x150.png"));
|
||||
|
||||
await page.getByTestId("eraser-mode-lasso").click();
|
||||
|
||||
const canvas = page.locator("canvas");
|
||||
await canvas.waitFor({ state: "visible", timeout: 5_000 });
|
||||
const box = await canvas.boundingBox();
|
||||
if (!box) throw new Error("Canvas not found");
|
||||
|
||||
// Drag a closed quad around the middle of the canvas (>= 3 points, real area).
|
||||
await page.mouse.move(box.x + box.width * 0.5, box.y + box.height * 0.3);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + box.width * 0.7, box.y + box.height * 0.5);
|
||||
await page.mouse.move(box.x + box.width * 0.5, box.y + box.height * 0.7);
|
||||
await page.mouse.move(box.x + box.width * 0.3, box.y + box.height * 0.5);
|
||||
await page.mouse.up();
|
||||
|
||||
// A lasso region counts as a stroke: Undo appears and submit is enabled.
|
||||
await expect(page.getByRole("button", { name: "Undo" })).toBeVisible();
|
||||
await expect(page.getByTestId("erase-object-submit")).toBeEnabled();
|
||||
});
|
||||
|
||||
// FIXME(pre-existing): needs per-file masks to persist across file switches,
|
||||
// which is broken by the EraserCanvas remount on switch (see the strokes-persist
|
||||
// fixme above). Un-fixme once that's fixed.
|
||||
test.fixme("shows Erase All button when multiple files have masks", async ({
|
||||
loggedInPage: page,
|
||||
}) => {
|
||||
await gotoEraser(page);
|
||||
|
||||
// Upload first file
|
||||
await uploadFile(page, fixturePath("image/valid/test-200x150.png"));
|
||||
@@ -154,32 +208,27 @@ test.describe("Erase Object tool", () => {
|
||||
// Button should say "Erase Object" (only one file has mask)
|
||||
await expect(page.getByTestId("erase-object-submit")).toHaveText("Erase Object");
|
||||
|
||||
// Upload second file
|
||||
// Add a DISTINCT second file and paint on it too.
|
||||
const fileChooserPromise = page.waitForEvent("filechooser");
|
||||
await page.getByRole("button", { name: /Add more/i }).click();
|
||||
const fileChooser = await fileChooserPromise;
|
||||
await fileChooser.setFiles(fixturePath("image/valid/test-200x150.png"));
|
||||
await fileChooser.setFiles(fixturePath("image/valid/test-100x100.jpg"));
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Switch to second file and paint
|
||||
const fileEntries = page.locator("button").filter({ hasText: "test-200x150.png" });
|
||||
const count = await fileEntries.count();
|
||||
if (count >= 2) {
|
||||
await fileEntries.nth(1).click();
|
||||
await page.waitForTimeout(500);
|
||||
await page.locator("button").filter({ hasText: "test-100x100.jpg" }).first().click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const canvas2 = page.locator("canvas");
|
||||
await canvas2.waitFor({ state: "visible", timeout: 5_000 });
|
||||
box = await canvas2.boundingBox();
|
||||
if (!box) throw new Error("Canvas not found");
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + box.width / 2 + 20, box.y + box.height / 2);
|
||||
await page.mouse.up();
|
||||
await page.waitForTimeout(200);
|
||||
const canvas2 = page.locator("canvas");
|
||||
await canvas2.waitFor({ state: "visible", timeout: 5_000 });
|
||||
box = await canvas2.boundingBox();
|
||||
if (!box) throw new Error("Canvas not found");
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + box.width / 2 + 20, box.y + box.height / 2);
|
||||
await page.mouse.up();
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
// Now button should say "Erase All (2)"
|
||||
await expect(page.getByTestId("erase-object-submit")).toHaveText("Erase All (2)");
|
||||
}
|
||||
// Both files now have masks -> batch submit button.
|
||||
await expect(page.getByTestId("erase-object-submit")).toHaveText("Erase All (2)");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -128,6 +128,43 @@ export async function uploadTestImage(page: Page): Promise<void> {
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// mockAiFeaturesInstalled() — make AI-tool bundles report as installed.
|
||||
//
|
||||
// AI tools (erase-object, upscale, ...) render a FeatureInstallPrompt instead of
|
||||
// the tool UI when their bundle is missing, so their e2e specs otherwise skip in
|
||||
// CI and on any box without the bundle. Mock the feature-status endpoint so the
|
||||
// tool UI renders. Call BEFORE navigating to the tool page, then goto (a full
|
||||
// load resets the in-memory features store, which re-fetches through this mock).
|
||||
// These specs exercise client-side UI up to mask generation, not the real AI
|
||||
// backend / processing.
|
||||
// ---------------------------------------------------------------------------
|
||||
export async function mockAiFeaturesInstalled(
|
||||
page: Page,
|
||||
bundles: Array<{ id: string; enablesTools: string[]; name?: string }>,
|
||||
): Promise<void> {
|
||||
await page.route("**/api/v1/features", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
bundles: bundles.map((b) => ({
|
||||
id: b.id,
|
||||
name: b.name ?? b.id,
|
||||
description: "",
|
||||
status: "installed",
|
||||
installedVersion: "1.0.0",
|
||||
estimatedSize: "",
|
||||
downloadBytes: null,
|
||||
installedBytes: null,
|
||||
enablesTools: b.enablesTools,
|
||||
progress: null,
|
||||
})),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// waitForProcessing() — wait for processing to complete
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user