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
+104 -17
View File
@@ -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}
</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>
{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>
{/* Position & Size */}
@@ -259,18 +348,16 @@ export function CropSettings({
</div>
{/* Grid overlay toggle */}
<button
type="button"
onClick={() => onGridToggle(!showGrid)}
className={`flex items-center gap-2 w-full px-2 py-1.5 rounded text-xs transition-colors ${
showGrid
? "bg-primary/10 text-primary"
: "bg-muted text-muted-foreground hover:text-foreground"
}`}
>
<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">
<input
type="checkbox"
checked={showGrid}
onChange={(e) => onGridToggle(e.target.checked)}
className="accent-primary h-3.5 w-3.5"
/>
<Grid3x3 className="h-3.5 w-3.5" />
Rule of Thirds
</button>
</label>
{/* Error */}
{error && <p className="text-xs text-red-500">{error}</p>}
+18 -1
View File
@@ -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 (
<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) {
return (
<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 (
<SideBySideComparison
beforeSrc={originalBlobUrl}