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:
@@ -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) {
|
||||||
|
|||||||
@@ -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>}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}`,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user