import { ArrowLeftRight, Download, Grid3x3 } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; import type { Crop } from "react-image-crop"; import { ProgressCard } from "@/components/common/progress-card"; import { useToolProcessor } from "@/hooks/use-tool-processor"; import { useFileStore } from "@/stores/file-store"; const ASPECT_PRESETS = [ { label: "Free", value: undefined as number | undefined }, { label: "1:1", value: 1 }, { label: "4:3", value: 4 / 3 }, { label: "3:2", value: 3 / 2 }, { label: "16:9", value: 16 / 9 }, { label: "2:3", value: 2 / 3 }, { label: "4:5", value: 4 / 5 }, { label: "9:16", value: 9 / 16 }, ]; export interface CropSettingsProps { cropState: { crop: Crop; aspect: number | undefined; showGrid: boolean; imgDimensions: { width: number; height: number } | null; }; onCropChange: (crop: Crop) => void; onAspectChange: (aspect: number | undefined) => void; onGridToggle: (show: boolean) => void; } export function CropSettings({ cropState, onCropChange, onAspectChange, onGridToggle, }: CropSettingsProps) { const { files } = useFileStore(); const { processFiles, processAllFiles, processing, error, downloadUrl, progress } = useToolProcessor("crop"); 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) => { if (!imgDimensions) return { left: 0, top: 0, width: 0, height: 0 }; return { left: Math.round((c.x / 100) * imgDimensions.width), top: Math.round((c.y / 100) * imgDimensions.height), width: Math.round((c.width / 100) * imgDimensions.width), height: Math.round((c.height / 100) * imgDimensions.height), }; }, [imgDimensions], ); // Convert pixel value change back to percentage crop const handlePixelChange = useCallback( (field: "left" | "top" | "width" | "height", value: number) => { if (!imgDimensions) return; const newCrop = { ...crop }; if (field === "left") { newCrop.x = Math.max(0, Math.min((value / imgDimensions.width) * 100, 100 - newCrop.width)); } else if (field === "top") { newCrop.y = Math.max( 0, Math.min((value / imgDimensions.height) * 100, 100 - newCrop.height), ); } else if (field === "width") { const pct = Math.max(0, Math.min((value / imgDimensions.width) * 100, 100 - newCrop.x)); newCrop.width = pct; if (aspect) { newCrop.height = Math.min( (pct / 100) * imgDimensions.width * (1 / aspect) * (100 / imgDimensions.height), 100 - newCrop.y, ); newCrop.y = Math.max(0, Math.min(newCrop.y, 100 - newCrop.height)); } } else if (field === "height") { const pct = Math.max(0, Math.min((value / imgDimensions.height) * 100, 100 - newCrop.y)); newCrop.height = pct; if (aspect) { newCrop.width = Math.min( (pct / 100) * imgDimensions.height * aspect * (100 / imgDimensions.width), 100 - newCrop.x, ); newCrop.x = Math.max(0, Math.min(newCrop.x, 100 - newCrop.width)); } } onCropChange(newCrop); }, [crop, imgDimensions, aspect, onCropChange], ); const handleAspectSelect = useCallback( (value: number | undefined) => { setCustomMode(false); onAspectChange(value); // When selecting an aspect ratio, adjust the current crop to match if (value && imgDimensions) { const imgAspect = imgDimensions.width / imgDimensions.height; let newWidth: number; let newHeight: number; if (value > imgAspect) { // Desired ratio is wider than image — use full width, shrink height newWidth = 100; newHeight = (imgDimensions.width / value / imgDimensions.height) * 100; } else { // Desired ratio is taller than image — use full height, shrink width 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 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); } }, [aspect, handleAspectSelect]); const pixels = toPixels(crop); const handleProcess = () => { 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); } }; const hasFile = files.length > 0; const hasSize = pixels.width > 0 && pixels.height > 0; const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (hasFile && hasSize && !processing) handleProcess(); }; // Find which preset label matches the current aspect const activePresetLabel = ASPECT_PRESETS.find((p) => { if (p.value === undefined && aspect === undefined) return true; if (p.value !== undefined && aspect !== undefined) { return Math.abs(p.value - aspect) < 0.01; } return false; })?.label; return (
{/* 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 */} {/* Error */} {error &&

{error}

} {/* Process */} {processing ? ( ) : ( )} {/* Download */} {downloadUrl && ( Download )} ); } // ── Pipeline-only crop controls (numeric inputs, no canvas) ────────── export interface CropControlsProps { settings?: Record; onChange?: (settings: Record) => void; } export function CropControls({ settings: initialSettings, onChange }: CropControlsProps) { const [left, setLeft] = useState(0); const [top, setTop] = useState(0); const [width, setWidth] = useState(""); const [height, setHeight] = useState(""); const initializedRef = useRef(false); useEffect(() => { if (!initialSettings || initializedRef.current) return; initializedRef.current = true; if (initialSettings.left != null) setLeft(Number(initialSettings.left)); if (initialSettings.top != null) setTop(Number(initialSettings.top)); if (initialSettings.width != null) setWidth(String(initialSettings.width)); if (initialSettings.height != null) setHeight(String(initialSettings.height)); }, [initialSettings]); const onChangeRef = useRef(onChange); useEffect(() => { onChangeRef.current = onChange; }); useEffect(() => { onChangeRef.current?.({ left, top, width: width ? Number(width) : undefined, height: height ? Number(height) : undefined, }); }, [left, top, width, height]); return (
setLeft(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" />
setTop(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" />
setWidth(e.target.value)} min={1} placeholder="Required" className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" />
setHeight(e.target.value)} min={1} placeholder="Required" className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" />
); }