mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -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>}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user