From b8227c45b905fc0f0b8ea4263dad709256e15f7a Mon Sep 17 00:00:00 2001 From: Siddharth Kumar Sah Date: Sat, 11 Apr 2026 14:47:46 +0800 Subject: [PATCH] feat(crop): improve UI and fix batch crop for multi-file - Replace Rule of Thirds button with checkbox for clearer toggle - Show side-by-side comparison after crop instead of overlay slider - Add custom aspect ratio option with W:H number inputs - Fix batch crop failing on files with different dimensions by sending percentage-based coordinates instead of absolute pixels - Add failed-file error state display in tool page --- apps/api/src/routes/tools/crop.ts | 9 +- .../src/components/tools/crop-settings.tsx | 121 +++++++++++++++--- apps/web/src/pages/tool-page.tsx | 19 ++- packages/image-engine/src/operations/crop.ts | 25 +++- packages/image-engine/src/types.ts | 1 + 5 files changed, 148 insertions(+), 27 deletions(-) diff --git a/apps/api/src/routes/tools/crop.ts b/apps/api/src/routes/tools/crop.ts index d89acf71..08214b22 100644 --- a/apps/api/src/routes/tools/crop.ts +++ b/apps/api/src/routes/tools/crop.ts @@ -6,10 +6,11 @@ import { resolveOutputFormat } from "../../lib/output-format.js"; import { createToolRoute } from "../tool-factory.js"; const settingsSchema = z.object({ - left: z.number().int().min(0), - top: z.number().int().min(0), - width: z.number().int().positive(), - height: z.number().int().positive(), + left: z.number().min(0), + top: z.number().min(0), + width: z.number().positive(), + height: z.number().positive(), + unit: z.enum(["px", "percent"]).optional(), }); export function registerCrop(app: FastifyInstance) { diff --git a/apps/web/src/components/tools/crop-settings.tsx b/apps/web/src/components/tools/crop-settings.tsx index 01c988ba..7e4a5280 100644 --- a/apps/web/src/components/tools/crop-settings.tsx +++ b/apps/web/src/components/tools/crop-settings.tsx @@ -40,6 +40,10 @@ export function CropSettings({ const { crop, aspect, showGrid, imgDimensions } = cropState; + const [customMode, setCustomMode] = useState(false); + const [customW, setCustomW] = useState("3"); + const [customH, setCustomH] = useState("2"); + // Convert percentage crop to pixel values const toPixels = useCallback( (c: Crop) => { @@ -94,6 +98,7 @@ export function CropSettings({ const handleAspectSelect = useCallback( (value: number | undefined) => { + setCustomMode(false); onAspectChange(value); // When selecting an aspect ratio, adjust the current crop to match if (value && imgDimensions) { @@ -121,6 +126,42 @@ export function CropSettings({ [onAspectChange, onCropChange, imgDimensions], ); + const applyCustomAspect = useCallback( + (w: number, h: number) => { + if (w > 0 && h > 0) { + const value = w / h; + onAspectChange(value); + if (imgDimensions) { + const imgAspect = imgDimensions.width / imgDimensions.height; + let newWidth: number; + let newHeight: number; + if (value > imgAspect) { + newWidth = 100; + newHeight = (imgDimensions.width / value / imgDimensions.height) * 100; + } else { + newHeight = 100; + newWidth = ((imgDimensions.height * value) / imgDimensions.width) * 100; + } + onCropChange({ + unit: "%", + x: (100 - newWidth) / 2, + y: (100 - newHeight) / 2, + width: newWidth, + height: newHeight, + }); + } + } + }, + [onAspectChange, onCropChange, imgDimensions], + ); + + const handleCustomSelect = useCallback(() => { + setCustomMode(true); + const w = Number(customW); + const h = Number(customH); + applyCustomAspect(w, h); + }, [customW, customH, applyCustomAspect]); + const handleSwapAspect = useCallback(() => { if (aspect) { handleAspectSelect(1 / aspect); @@ -130,15 +171,23 @@ export function CropSettings({ const pixels = toPixels(crop); const handleProcess = () => { - const settings = { - left: pixels.left, - top: pixels.top, - width: Math.max(1, pixels.width), - height: Math.max(1, pixels.height), - }; if (files.length > 1) { + // Send percentage-based crop so the server adapts to each image's dimensions + const settings = { + left: crop.x, + top: crop.y, + width: Math.max(0.1, crop.width), + height: Math.max(0.1, crop.height), + unit: "percent" as const, + }; processAllFiles(files, settings); } else { + const settings = { + left: pixels.left, + top: pixels.top, + width: Math.max(1, pixels.width), + height: Math.max(1, pixels.height), + }; processFiles(files, settings); } }; @@ -184,7 +233,7 @@ export function CropSettings({ key={label} onClick={() => handleAspectSelect(value)} className={`px-2 py-1.5 rounded text-xs transition-colors ${ - activePresetLabel === label + !customMode && activePresetLabel === label ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground hover:bg-primary/20 hover:text-foreground" }`} @@ -192,7 +241,47 @@ export function CropSettings({ {label} ))} + + {customMode && ( +
+ { + setCustomW(e.target.value); + const w = Number(e.target.value); + const h = Number(customH); + if (w > 0 && h > 0) applyCustomAspect(w, h); + }} + min={1} + className="w-16 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground tabular-nums text-center" + /> + : + { + setCustomH(e.target.value); + const w = Number(customW); + const h = Number(e.target.value); + if (w > 0 && h > 0) applyCustomAspect(w, h); + }} + min={1} + className="w-16 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground tabular-nums text-center" + /> +
+ )} {/* Position & Size */} @@ -259,18 +348,16 @@ export function CropSettings({ {/* Grid overlay toggle */} - + {/* Error */} {error &&

{error}

} diff --git a/apps/web/src/pages/tool-page.tsx b/apps/web/src/pages/tool-page.tsx index 309f8b2e..91024d73 100644 --- a/apps/web/src/pages/tool-page.tsx +++ b/apps/web/src/pages/tool-page.tsx @@ -96,6 +96,7 @@ export function ToolPage() { setSelectedIndex, navigateNext, navigatePrev, + currentEntry, } = useFileStore(); const isMobile = useMobile(); const hasMultiple = entries.length > 1; @@ -249,6 +250,18 @@ export function ToolPage() { ); } + // Show error state for failed batch files (before interactive canvas blocks, + // which also match !hasProcessed and would show the canvas instead of the error) + if (hasFile && !hasProcessed && currentEntry?.status === "failed") { + return ( +
+

+ {currentEntry.error ?? "Processing failed for this file"} +

+
+ ); + } + if (displayMode === "interactive-crop" && hasFile && !hasProcessed && originalBlobUrl) { return ( { - const { left, top, width, height } = options; + const metadata = await image.metadata(); + const imgWidth = metadata.width ?? 0; + const imgHeight = metadata.height ?? 0; + + let left: number; + let top: number; + let width: number; + let height: number; + + if (options.unit === "percent") { + left = Math.round((options.left / 100) * imgWidth); + top = Math.round((options.top / 100) * imgHeight); + width = Math.round((options.width / 100) * imgWidth); + height = Math.round((options.height / 100) * imgHeight); + } else { + left = Math.round(options.left); + top = Math.round(options.top); + width = Math.round(options.width); + height = Math.round(options.height); + } if (width <= 0 || height <= 0) { throw new Error("Crop width and height must be greater than 0"); @@ -10,10 +29,6 @@ export async function crop(image: Sharp, options: CropOptions): Promise { throw new Error("Crop left and top must be non-negative"); } - const metadata = await image.metadata(); - const imgWidth = metadata.width ?? 0; - const imgHeight = metadata.height ?? 0; - if (left + width > imgWidth) { throw new Error( `Crop region exceeds image width: left(${left}) + width(${width}) > ${imgWidth}`, diff --git a/packages/image-engine/src/types.ts b/packages/image-engine/src/types.ts index ca02315c..416ec1d5 100644 --- a/packages/image-engine/src/types.ts +++ b/packages/image-engine/src/types.ts @@ -32,6 +32,7 @@ export interface CropOptions { top: number; width: number; height: number; + unit?: "px" | "percent"; } export interface RotateOptions {