feat(erase-object): auto-center compare slider at erased region

Track the painted mask centroid and initialise the before/after slider
at that position so the result is immediately visible without manual
dragging.
This commit is contained in:
Siddharth Kumar Sah
2026-04-15 23:11:27 +08:00
parent dd1e2e5ba5
commit a8cca62336
6 changed files with 52 additions and 3 deletions
@@ -9,6 +9,8 @@ interface BeforeAfterSliderProps {
beforeSize?: number;
/** Processed file size in bytes. */
afterSize?: number;
/** Initial divider position as a percentage (0100). Defaults to 50. */
initialPosition?: number;
}
function formatSize(bytes: number): string {
@@ -29,9 +31,10 @@ export function BeforeAfterSlider({
afterSrc,
beforeSize,
afterSize,
initialPosition = 50,
}: BeforeAfterSliderProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [position, setPosition] = useState(50); // percentage 0-100
const [position, setPosition] = useState(initialPosition); // percentage 0-100
const [isDragging, setIsDragging] = useState(false);
const updatePosition = useCallback((clientX: number) => {
@@ -36,7 +36,9 @@ export function CompareSettings() {
const result = await res.json();
setSimilarity(result.similarity);
setDownloadUrl(result.downloadUrl);
setProcessedUrl(result.downloadUrl);
// Show the second image in the slider (not the diff) so the user can
// visually compare their two originals. The diff is still downloadable.
setProcessedUrl(URL.createObjectURL(secondFile));
} catch (err) {
setError(err instanceof Error ? err.message : "Comparison failed");
} finally {
@@ -14,6 +14,7 @@ interface EraseObjectSettingsProps {
hasStrokes: boolean;
brushSize: number;
onBrushSizeChange: (size: number) => void;
onMaskCenter?: (centerPct: number) => void;
}
export function EraseObjectSettings({
@@ -21,6 +22,7 @@ export function EraseObjectSettings({
hasStrokes,
brushSize,
onBrushSizeChange: setBrushSize,
onMaskCenter,
}: EraseObjectSettingsProps) {
const { files, processing, error, setProcessing, setError, setProcessedUrl, setSizes } =
useFileStore();
@@ -41,6 +43,12 @@ export function EraseObjectSettings({
const maskBlob = await eraserRef.current.exportMask();
if (!maskBlob) return;
// Record where the user painted so the comparison slider starts at that location
const maskCenter = eraserRef.current.getMaskCenter();
if (maskCenter !== null && onMaskCenter) {
onMaskCenter(maskCenter);
}
setError(null);
setDownloadUrl(null);
setProcessing(true);
@@ -5,6 +5,7 @@ type Stroke = { points: Point[]; size: number };
export interface EraserCanvasRef {
exportMask: () => Promise<Blob | null>;
getMaskCenter: () => number | null;
clear: () => void;
undo: () => void;
}
@@ -238,6 +239,20 @@ export const EraserCanvas = forwardRef<EraserCanvasRef, EraserCanvasProps>(funct
mask.toBlob((b) => resolve(b), "image/png");
});
},
getMaskCenter: () => {
if (strokesRef.current.length === 0 || !canvasSize) return null;
let minX = Infinity;
let maxX = -Infinity;
for (const stroke of strokesRef.current) {
for (const pt of stroke.points) {
minX = Math.min(minX, pt.x - stroke.size / 2);
maxX = Math.max(maxX, pt.x + stroke.size / 2);
}
}
if (minX === Infinity) return null;
const centerX = (minX + maxX) / 2;
return Math.max(0, Math.min(100, (centerX / canvasSize.w) * 100));
},
clear: () => {
strokesRef.current = [];
currentPointsRef.current = [];
+2 -1
View File
@@ -43,6 +43,7 @@ export interface EraserProps {
hasStrokes: boolean;
brushSize: number;
onBrushSizeChange: (size: number) => void;
onMaskCenter?: (centerPct: number) => void;
}
// ── Registry entry ─────────────────────────────────────────────────
@@ -433,7 +434,7 @@ export const toolRegistry = new Map<string, ToolRegistryEntry>([
[
"image-enhancement",
{
displayMode: "live-preview" as DisplayMode,
displayMode: "before-after" as DisplayMode,
livePreview: true,
Settings: ImageEnhancementSettings as never,
},
+20
View File
@@ -179,6 +179,8 @@ export function ToolPage() {
const eraserRef = useRef<EraserCanvasRef | null>(null);
const [eraserHasStrokes, setEraserHasStrokes] = useState(false);
const [eraserBrushSize, setEraserBrushSize] = useState(30);
// Center of the painted mask as a 0-100 percentage — used to init the slider at the right spot
const [eraserSliderInitPos, setEraserSliderInitPos] = useState<number | null>(null);
// Reset crop state when the image changes
useEffect(() => {
@@ -188,6 +190,7 @@ export function ToolPage() {
const handleFiles = useCallback(
(newFiles: File[]) => {
setEraserSliderInitPos(null);
reset();
setFiles(newFiles);
},
@@ -196,6 +199,7 @@ export function ToolPage() {
const handleUndo = useCallback(() => {
undoProcessing();
setEraserSliderInitPos(null);
}, [undoProcessing]);
const handleAddMore = useCallback(() => {
@@ -276,6 +280,7 @@ export function ToolPage() {
hasStrokes: eraserHasStrokes,
brushSize: eraserBrushSize,
onBrushSizeChange: setEraserBrushSize,
onMaskCenter: setEraserSliderInitPos,
}
: undefined,
};
@@ -349,6 +354,21 @@ export function ToolPage() {
);
}
// After erasing: compare clean original vs inpainted result.
// Initialise the divider at the center of the painted area so the comparison
// lands right where the object was removed.
if (displayMode === "interactive-eraser" && hasProcessed && originalBlobUrl) {
return (
<BeforeAfterSlider
beforeSrc={originalBlobUrl}
afterSrc={displayUrl}
beforeSize={originalSize ?? undefined}
afterSize={processedSize ?? undefined}
initialPosition={eraserSliderInitPos ?? 50}
/>
);
}
if (displayMode === "interactive-split" && hasFile && originalBlobUrl) {
if (registryEntry?.ResultsPanel) {
const Panel = registryEntry.ResultsPanel;