From 8ed0700a04dc0c417e20d9bb83c96a8348868c55 Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Mon, 11 May 2026 21:15:03 +0800 Subject: [PATCH] feat: add Content-Aware tab to crop settings with expansion controls --- .../src/components/tools/crop-settings.tsx | 506 ++++++++++++------ 1 file changed, 353 insertions(+), 153 deletions(-) diff --git a/apps/web/src/components/tools/crop-settings.tsx b/apps/web/src/components/tools/crop-settings.tsx index 8f787e71..9da3d3b8 100644 --- a/apps/web/src/components/tools/crop-settings.tsx +++ b/apps/web/src/components/tools/crop-settings.tsx @@ -5,6 +5,15 @@ import { ProgressCard } from "@/components/common/progress-card"; import { useToolProcessor } from "@/hooks/use-tool-processor"; import { useFileStore } from "@/stores/file-store"; +const EXTEND_PRESETS = [ + { label: "16:9", aspect: 16 / 9 }, + { label: "1:1", aspect: 1 }, + { label: "4:3", aspect: 4 / 3 }, + { label: "3:2", aspect: 3 / 2 }, + { label: "9:16", aspect: 9 / 16 }, + { label: "4:5", aspect: 4 / 5 }, +]; + const ASPECT_PRESETS = [ { label: "Free", value: undefined as number | undefined }, { label: "1:1", value: 1 }, @@ -26,6 +35,7 @@ export interface CropSettingsProps { onCropChange: (crop: Crop) => void; onAspectChange: (aspect: number | undefined) => void; onGridToggle: (show: boolean) => void; + onModeChange?: (mode: "standard" | "content-aware") => void; } export function CropSettings({ @@ -33,10 +43,14 @@ export function CropSettings({ onCropChange, onAspectChange, onGridToggle, + onModeChange, }: CropSettingsProps) { const { files } = useFileStore(); - const { processFiles, processAllFiles, processing, error, downloadUrl, progress } = - useToolProcessor("crop"); + const standardCrop = useToolProcessor("crop"); + const contentAwareCrop = useToolProcessor("content-aware-crop"); + const [mode, setMode] = useState<"standard" | "content-aware">("standard"); + const active = mode === "content-aware" ? contentAwareCrop : standardCrop; + const { processFiles, processAllFiles, processing, error, downloadUrl, progress } = active; const { crop, aspect, showGrid, imgDimensions } = cropState; @@ -44,6 +58,17 @@ export function CropSettings({ const [customW, setCustomW] = useState("3"); const [customH, setCustomH] = useState("2"); + // Content-aware extension state + const [extendTop, setExtendTop] = useState(0); + const [extendRight, setExtendRight] = useState(0); + const [extendBottom, setExtendBottom] = useState(0); + const [extendLeft, setExtendLeft] = useState(0); + + // Notify parent when mode changes + useEffect(() => { + onModeChange?.(mode); + }, [mode, onModeChange]); + // Convert percentage crop to pixel values const toPixels = useCallback( (c: Crop) => { @@ -180,7 +205,7 @@ export function CropSettings({ height: Math.max(0.1, crop.height), unit: "percent" as const, }; - processAllFiles(files, settings); + standardCrop.processAllFiles(files, settings); } else { const settings = { left: pixels.left, @@ -188,16 +213,60 @@ export function CropSettings({ width: Math.max(1, pixels.width), height: Math.max(1, pixels.height), }; - processFiles(files, settings); + standardCrop.processFiles(files, settings); + } + }; + + const handleExtendPreset = useCallback( + (targetAspect: number) => { + if (!imgDimensions) return; + const { width: w, height: h } = imgDimensions; + const currentAspect = w / h; + let top = 0; + let right = 0; + let bottom = 0; + let left = 0; + if (targetAspect > currentAspect) { + // Need wider -- extend left and right + const newWidth = Math.round(h * targetAspect); + const extra = newWidth - w; + left = Math.round(extra / 2); + right = extra - left; + } else { + // Need taller -- extend top and bottom + const newHeight = Math.round(w / targetAspect); + const extra = newHeight - h; + top = Math.round(extra / 2); + bottom = extra - top; + } + setExtendTop(top); + setExtendRight(right); + setExtendBottom(bottom); + setExtendLeft(left); + }, + [imgDimensions], + ); + + const handleContentAwareProcess = () => { + const settings = { extendTop, extendRight, extendBottom, extendLeft }; + if (files.length > 1) { + contentAwareCrop.processAllFiles(files, settings); + } else { + contentAwareCrop.processFiles(files, settings); } }; const hasFile = files.length > 0; const hasSize = pixels.width > 0 && pixels.height > 0; + const hasExtension = extendTop > 0 || extendRight > 0 || extendBottom > 0 || extendLeft > 0; const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); - if (hasFile && hasSize && !processing) handleProcess(); + if (mode === "content-aware") { + if (hasFile && hasExtension && !processing) handleContentAwareProcess(); + } else { + if (hasFile && hasSize && !processing) handleProcess(); + } }; // Find which preset label matches the current aspect @@ -209,155 +278,280 @@ export function CropSettings({ return false; })?.label; + const canSubmit = + mode === "content-aware" + ? hasFile && hasExtension && !processing + : hasFile && hasSize && !processing; + return (
- {/* Aspect Ratio */} -
-
-

Aspect Ratio

- {aspect !== undefined && ( - + {/* Mode tabs */} +
+ + +
+ + {/* Standard tab */} + {mode === "standard" && ( + <> + {/* Aspect Ratio */} +
+
+

Aspect Ratio

+ {aspect !== undefined && ( + + )} +
+
+ {ASPECT_PRESETS.map(({ label, value }) => ( + + ))} + +
+ {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 */} +
+

Position & Size

+
+
+ + handlePixelChange("left", Number(e.target.value))} + min={0} + max={imgDimensions ? imgDimensions.width - 1 : undefined} + className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground tabular-nums" + /> +
+
+ + handlePixelChange("top", Number(e.target.value))} + min={0} + max={imgDimensions ? imgDimensions.height - 1 : undefined} + className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground tabular-nums" + /> +
+
+ + handlePixelChange("width", Number(e.target.value))} + min={1} + max={imgDimensions ? imgDimensions.width : undefined} + className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground tabular-nums" + /> +
+
+ + handlePixelChange("height", Number(e.target.value))} + min={1} + max={imgDimensions ? imgDimensions.height : undefined} + className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground tabular-nums" + /> +
+
+
+ + {/* Grid overlay toggle */} + + + )} + + {/* Content-Aware tab */} + {mode === "content-aware" && ( +
+ {/* Aspect ratio presets */} +
+

Extend to aspect ratio

+
+ {EXTEND_PRESETS.map(({ label, aspect: a }) => ( + + ))} +
+
+ + {/* Per-side extension */} +
+

Extend by (pixels)

+
+
+ + setExtendTop(Math.max(0, Number(e.target.value)))} + min={0} + className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground tabular-nums" + /> +
+
+ + setExtendRight(Math.max(0, Number(e.target.value)))} + min={0} + className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground tabular-nums" + /> +
+
+ + setExtendBottom(Math.max(0, Number(e.target.value)))} + min={0} + className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground tabular-nums" + /> +
+
+ + setExtendLeft(Math.max(0, Number(e.target.value)))} + min={0} + className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground tabular-nums" + /> +
+
+
+ + {/* Output size display */} + {imgDimensions && ( +

+ New size: {imgDimensions.width + extendLeft + extendRight} x{" "} + {imgDimensions.height + extendTop + extendBottom} +

)}
-
- {ASPECT_PRESETS.map(({ label, value }) => ( - - ))} - -
- {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 */} -
-

Position & Size

-
-
- - handlePixelChange("left", Number(e.target.value))} - min={0} - max={imgDimensions ? imgDimensions.width - 1 : undefined} - className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground tabular-nums" - /> -
-
- - handlePixelChange("top", Number(e.target.value))} - min={0} - max={imgDimensions ? imgDimensions.height - 1 : undefined} - className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground tabular-nums" - /> -
-
- - handlePixelChange("width", Number(e.target.value))} - min={1} - max={imgDimensions ? imgDimensions.width : undefined} - className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground tabular-nums" - /> -
-
- - handlePixelChange("height", Number(e.target.value))} - min={1} - max={imgDimensions ? imgDimensions.height : undefined} - className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground tabular-nums" - /> -
-
-
- - {/* Grid overlay toggle */} - + )} {/* Error */} {error &&

{error}

} @@ -367,7 +561,7 @@ export function CropSettings({ - {files.length > 1 ? `Crop (${files.length} files)` : "Crop"} + {mode === "content-aware" + ? files.length > 1 + ? `Extend (${files.length} files)` + : "Extend" + : files.length > 1 + ? `Crop (${files.length} files)` + : "Crop"} )}