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
This commit is contained in:
Siddharth Kumar Sah
2026-04-11 14:47:46 +08:00
parent 6c1a347f1c
commit b8227c45b9
5 changed files with 148 additions and 27 deletions
+5 -4
View File
@@ -6,10 +6,11 @@ import { resolveOutputFormat } from "../../lib/output-format.js";
import { createToolRoute } from "../tool-factory.js"; import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({ const settingsSchema = z.object({
left: z.number().int().min(0), left: z.number().min(0),
top: z.number().int().min(0), top: z.number().min(0),
width: z.number().int().positive(), width: z.number().positive(),
height: z.number().int().positive(), height: z.number().positive(),
unit: z.enum(["px", "percent"]).optional(),
}); });
export function registerCrop(app: FastifyInstance) { export function registerCrop(app: FastifyInstance) {
+104 -17
View File
@@ -40,6 +40,10 @@ export function CropSettings({
const { crop, aspect, showGrid, imgDimensions } = cropState; 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 // Convert percentage crop to pixel values
const toPixels = useCallback( const toPixels = useCallback(
(c: Crop) => { (c: Crop) => {
@@ -94,6 +98,7 @@ export function CropSettings({
const handleAspectSelect = useCallback( const handleAspectSelect = useCallback(
(value: number | undefined) => { (value: number | undefined) => {
setCustomMode(false);
onAspectChange(value); onAspectChange(value);
// When selecting an aspect ratio, adjust the current crop to match // When selecting an aspect ratio, adjust the current crop to match
if (value && imgDimensions) { if (value && imgDimensions) {
@@ -121,6 +126,42 @@ export function CropSettings({
[onAspectChange, onCropChange, imgDimensions], [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(() => { const handleSwapAspect = useCallback(() => {
if (aspect) { if (aspect) {
handleAspectSelect(1 / aspect); handleAspectSelect(1 / aspect);
@@ -130,15 +171,23 @@ export function CropSettings({
const pixels = toPixels(crop); const pixels = toPixels(crop);
const handleProcess = () => { 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) { 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); processAllFiles(files, settings);
} else { } else {
const settings = {
left: pixels.left,
top: pixels.top,
width: Math.max(1, pixels.width),
height: Math.max(1, pixels.height),
};
processFiles(files, settings); processFiles(files, settings);
} }
}; };
@@ -184,7 +233,7 @@ export function CropSettings({
key={label} key={label}
onClick={() => handleAspectSelect(value)} onClick={() => handleAspectSelect(value)}
className={`px-2 py-1.5 rounded text-xs transition-colors ${ className={`px-2 py-1.5 rounded text-xs transition-colors ${
activePresetLabel === label !customMode && activePresetLabel === label
? "bg-primary text-primary-foreground" ? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:bg-primary/20 hover:text-foreground" : "bg-muted text-muted-foreground hover:bg-primary/20 hover:text-foreground"
}`} }`}
@@ -192,7 +241,47 @@ export function CropSettings({
{label} {label}
</button> </button>
))} ))}
<button
type="button"
onClick={handleCustomSelect}
className={`px-2 py-1.5 rounded text-xs transition-colors ${
customMode
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:bg-primary/20 hover:text-foreground"
}`}
>
Custom
</button>
</div> </div>
{customMode && (
<div className="flex items-center gap-1.5 mt-2">
<input
type="number"
value={customW}
onChange={(e) => {
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"
/>
<span className="text-xs text-muted-foreground">:</span>
<input
type="number"
value={customH}
onChange={(e) => {
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"
/>
</div>
)}
</div> </div>
{/* Position & Size */} {/* Position & Size */}
@@ -259,18 +348,16 @@ export function CropSettings({
</div> </div>
{/* Grid overlay toggle */} {/* Grid overlay toggle */}
<button <label className="flex items-center gap-2 w-full px-2 py-1.5 rounded text-xs cursor-pointer select-none text-muted-foreground hover:text-foreground transition-colors">
type="button" <input
onClick={() => onGridToggle(!showGrid)} type="checkbox"
className={`flex items-center gap-2 w-full px-2 py-1.5 rounded text-xs transition-colors ${ checked={showGrid}
showGrid onChange={(e) => onGridToggle(e.target.checked)}
? "bg-primary/10 text-primary" className="accent-primary h-3.5 w-3.5"
: "bg-muted text-muted-foreground hover:text-foreground" />
}`}
>
<Grid3x3 className="h-3.5 w-3.5" /> <Grid3x3 className="h-3.5 w-3.5" />
Rule of Thirds Rule of Thirds
</button> </label>
{/* Error */} {/* Error */}
{error && <p className="text-xs text-red-500">{error}</p>} {error && <p className="text-xs text-red-500">{error}</p>}
+18 -1
View File
@@ -96,6 +96,7 @@ export function ToolPage() {
setSelectedIndex, setSelectedIndex,
navigateNext, navigateNext,
navigatePrev, navigatePrev,
currentEntry,
} = useFileStore(); } = useFileStore();
const isMobile = useMobile(); const isMobile = useMobile();
const hasMultiple = entries.length > 1; 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 (
<div className="flex flex-col items-center justify-center gap-3 h-full text-center px-4">
<p className="text-sm text-red-500">
{currentEntry.error ?? "Processing failed for this file"}
</p>
</div>
);
}
if (displayMode === "interactive-crop" && hasFile && !hasProcessed && originalBlobUrl) { if (displayMode === "interactive-crop" && hasFile && !hasProcessed && originalBlobUrl) {
return ( return (
<CropCanvas <CropCanvas
@@ -274,7 +287,11 @@ export function ToolPage() {
); );
} }
if (hasProcessed && originalBlobUrl && displayMode === "side-by-side") { if (
hasProcessed &&
originalBlobUrl &&
(displayMode === "side-by-side" || displayMode === "interactive-crop")
) {
return ( return (
<SideBySideComparison <SideBySideComparison
beforeSrc={originalBlobUrl} beforeSrc={originalBlobUrl}
+20 -5
View File
@@ -1,7 +1,26 @@
import type { CropOptions, Sharp } from "../types.js"; import type { CropOptions, Sharp } from "../types.js";
export async function crop(image: Sharp, options: CropOptions): Promise<Sharp> { export async function crop(image: Sharp, options: CropOptions): Promise<Sharp> {
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) { if (width <= 0 || height <= 0) {
throw new Error("Crop width and height must be greater than 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<Sharp> {
throw new Error("Crop left and top must be non-negative"); 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) { if (left + width > imgWidth) {
throw new Error( throw new Error(
`Crop region exceeds image width: left(${left}) + width(${width}) > ${imgWidth}`, `Crop region exceeds image width: left(${left}) + width(${width}) > ${imgWidth}`,
+1
View File
@@ -32,6 +32,7 @@ export interface CropOptions {
top: number; top: number;
width: number; width: number;
height: number; height: number;
unit?: "px" | "percent";
} }
export interface RotateOptions { export interface RotateOptions {