diff --git a/IMAGE_RESIZE_MODULE.md b/IMAGE_RESIZE_MODULE.md new file mode 100644 index 0000000..5b36bcf --- /dev/null +++ b/IMAGE_RESIZE_MODULE.md @@ -0,0 +1,63 @@ +# Image Resize Module + +Module de redimensionnement d'images pour AdaTools. + +## Features + +### Redimensionnement +- Dimensions personnalisées (largeur/hauteur) +- Mise à l'échelle par pourcentage (25%, 50%, 75%, 100%, 150%, 200%) +- Verrouillage/déverrouillage du ratio d'aspect +- Rotation (90°) +- Retournement horizontal/vertical + +### Presets +- **Réseaux sociaux**: Instagram, Facebook, Twitter, LinkedIn, YouTube +- **Tailles courantes**: Thumbnail, HD, Full HD, 4K, Icon, Favicon +- Presets personnalisés sauvegardés en localStorage + +### Algorithmes de redimensionnement +| Algorithme | Description | Usage recommandé | +|------------|-------------|------------------| +| Ultra Quality (Pica) | Vrai Lanczos3 via pica.js | Meilleure qualité pour downscaling photos | +| High Quality | Lanczos natif navigateur | Photos et images détaillées | +| Standard | Bicubic | Équilibre qualité/vitesse | +| Fast | Bilinear | Traitement rapide | +| Pixel Perfect | Nearest neighbor | Pixel art | + +### Formats de sortie +- PNG (lossless) +- JPEG (avec slider qualité) +- WebP (avec slider qualité) + +### Fonctionnalités additionnelles +- Preview en temps réel avec debouncing +- Contrôles de zoom +- Comparaison avant/après +- Estimation de la taille du fichier +- Téléchargement et copie dans le presse-papiers +- Upload par drag-and-drop +- Coller depuis le presse-papiers +- Mode batch (fichiers multiples) + +## Structure des fichiers + +``` +src/lib/ +├── image-resize-utils.ts # Fonctions utilitaires +└── image-resize-presets.ts # Définitions des presets + +components/modules/ +├── image-resize-module.tsx # Module principal +└── image-resize/ + ├── algorithm-selector.tsx # Sélection algorithme + ├── batch-processor.tsx # Traitement batch + ├── image-preview.tsx # Aperçu canvas + ├── output-settings.tsx # Paramètres sortie + ├── preset-selector.tsx # Sélection presets + └── resize-controls.tsx # Contrôles dimensions +``` + +## Dépendances + +- `pica` - Redimensionnement haute qualité (Lanczos3) diff --git a/app/test-trpc/page.tsx b/app/test-trpc/page.tsx deleted file mode 100644 index 2fa8b4f..0000000 --- a/app/test-trpc/page.tsx +++ /dev/null @@ -1,27 +0,0 @@ -"use client"; - -import { api } from "@/src/lib/trpc/client"; - -export default function TestTRPCPage() { - const greeting = api.test.greeting.useQuery(); - - return ( -
-

Test tRPC

- -
- {greeting ? ( -
-

✅ tRPC fonctionne !

-

- Réponse du serveur :{" "} - {greeting.data} -

-
- ) : ( -
TRPC NOT WORKING
- )} -
-
- ); -} diff --git a/components/modules/image-resize-module.tsx b/components/modules/image-resize-module.tsx new file mode 100644 index 0000000..2134cb1 --- /dev/null +++ b/components/modules/image-resize-module.tsx @@ -0,0 +1,540 @@ +"use client"; + +import { useState, useRef, useEffect, useCallback } from "react"; +import { Module } from "../dashboard/module"; +import { Button } from "../ui/button"; +import { + ImageIcon, + Upload, + RotateCw, + FlipHorizontal, + FlipVertical, + Layers, +} from "lucide-react"; +import { ResizeControls } from "./image-resize/resize-controls"; +import { PresetSelector } from "./image-resize/preset-selector"; +import { AlgorithmSelector } from "./image-resize/algorithm-selector"; +import { ImagePreview } from "./image-resize/image-preview"; +import { OutputSettings } from "./image-resize/output-settings"; +import { BatchProcessor, type BatchFile } from "./image-resize/batch-processor"; +import { + loadImageFromFile, + resizeImage, + convertToFormat, + estimateFileSize, + rotateImage, + flipImage, + type ImageFormat, + type ResizeAlgorithm, +} from "@/src/lib/image-resize-utils"; + +interface ImageResizeModuleProps { + isPinned?: boolean; + onTogglePin?: () => void; + isAuthenticated?: boolean; + onAuthRequired?: () => void; +} + +export function ImageResizeModule({ + isPinned, + onTogglePin, + isAuthenticated = true, +}: ImageResizeModuleProps) { + // Mode state + const [batchMode, setBatchMode] = useState(false); + + // Single mode state + const [originalImage, setOriginalImage] = useState( + null, + ); + const [originalWidth, setOriginalWidth] = useState(0); + const [originalHeight, setOriginalHeight] = useState(0); + const [width, setWidth] = useState(0); + const [height, setHeight] = useState(0); + const [aspectRatioLocked, setAspectRatioLocked] = useState(true); + const [algorithm, setAlgorithm] = useState("bicubic"); + const [format, setFormat] = useState("png"); + const [quality, setQuality] = useState(92); + const [processedCanvas, setProcessedCanvas] = + useState(null); + const [estimatedSize, setEstimatedSize] = useState(0); + const [originalFileSize, setOriginalFileSize] = useState(0); + const [isCopied, setIsCopied] = useState(false); + + // Batch mode state + const [batchFiles, setBatchFiles] = useState([]); + const [isProcessingBatch, setIsProcessingBatch] = useState(false); + const [currentBatchIndex, setCurrentBatchIndex] = useState(0); + + // Refs + const fileInputRef = useRef(null); + const debounceTimerRef = useRef(null); + + // Process image (with debouncing) + const processImage = useCallback( + async ( + img: HTMLImageElement, + newWidth: number, + newHeight: number, + outputFormat: ImageFormat, + outputQuality: number, + algorithmValue: ResizeAlgorithm, + ) => { + try { + const canvas = await resizeImage(img, { + width: newWidth, + height: newHeight, + maintainAspectRatio: false, + algorithm: algorithmValue, + }); + + const qualityDecimal = outputQuality / 100; + const dataUrl = await convertToFormat( + canvas, + outputFormat, + qualityDecimal, + ); + const size = estimateFileSize(dataUrl); + + setProcessedCanvas(canvas); + setEstimatedSize(size); + } catch (error) { + console.error("Error processing image:", error); + } + }, + [], + ); + + // Debounced image processing + useEffect(() => { + if (!originalImage || width <= 0 || height <= 0) return; + + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + + debounceTimerRef.current = setTimeout(() => { + processImage(originalImage, width, height, format, quality, algorithm); + }, 300); + + return () => { + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + }; + }, [originalImage, width, height, format, quality, algorithm, processImage]); + + // Handle file selection + const handleFileChange = async ( + event: React.ChangeEvent, + ) => { + const file = event.target.files?.[0]; + if (!file) return; + + try { + const { + image, + width: imgWidth, + height: imgHeight, + } = await loadImageFromFile(file); + + setOriginalImage(image); + setOriginalWidth(imgWidth); + setOriginalHeight(imgHeight); + setWidth(imgWidth); + setHeight(imgHeight); + setOriginalFileSize(file.size); + } catch { + console.error("Error loading image"); + } + }; + + // Handle drag and drop + const handleDrop = async (event: React.DragEvent) => { + event.preventDefault(); + const file = event.dataTransfer.files[0]; + if (!file || !file.type.startsWith("image/")) return; + + try { + const { + image, + width: imgWidth, + height: imgHeight, + } = await loadImageFromFile(file); + + setOriginalImage(image); + setOriginalWidth(imgWidth); + setOriginalHeight(imgHeight); + setWidth(imgWidth); + setHeight(imgHeight); + setOriginalFileSize(file.size); + } catch { + console.error("Error loading image"); + } + }; + + // Handle paste from clipboard + useEffect(() => { + const handlePaste = async (event: ClipboardEvent) => { + const items = event.clipboardData?.items; + if (!items) return; + + for (let i = 0; i < items.length; i++) { + if (items[i].type.startsWith("image/")) { + const file = items[i].getAsFile(); + if (!file) continue; + + try { + const { + image, + width: imgWidth, + height: imgHeight, + } = await loadImageFromFile(file); + + setOriginalImage(image); + setOriginalWidth(imgWidth); + setOriginalHeight(imgHeight); + setWidth(imgWidth); + setHeight(imgHeight); + setOriginalFileSize(file.size); + } catch (error) { + console.error("Error loading pasted image:", error); + } + break; + } + } + }; + + document.addEventListener("paste", handlePaste); + return () => document.removeEventListener("paste", handlePaste); + }, []); + + // Handle rotation + const handleRotate = (degrees: 90 | 180 | 270) => { + if (!processedCanvas) return; + + try { + const rotatedCanvas = rotateImage(processedCanvas, degrees); + setProcessedCanvas(rotatedCanvas); + + // Update dimensions if rotated 90 or 270 + if (degrees === 90 || degrees === 270) { + const newWidth = height; + const newHeight = width; + setWidth(newWidth); + setHeight(newHeight); + } + } catch (error) { + console.error("Error rotating image:", error); + } + }; + + // Handle flip + const handleFlip = (direction: "horizontal" | "vertical") => { + if (!processedCanvas) return; + + try { + const flippedCanvas = flipImage(processedCanvas, direction); + setProcessedCanvas(flippedCanvas); + } catch (error) { + console.error("Error flipping image:", error); + } + }; + + // Handle download + const handleDownload = async () => { + if (!processedCanvas) return; + + try { + const qualityDecimal = quality / 100; + const dataUrl = await convertToFormat( + processedCanvas, + format, + qualityDecimal, + ); + + const link = document.createElement("a"); + link.download = `resized-${Date.now()}.${format}`; + link.href = dataUrl; + link.click(); + } catch (error) { + console.error("Error downloading image:", error); + } + }; + + // Handle copy to clipboard + const handleCopyToClipboard = async () => { + if (!processedCanvas) return; + + try { + processedCanvas.toBlob(async (blob) => { + if (!blob) return; + + await navigator.clipboard.write([ + new ClipboardItem({ "image/png": blob }), + ]); + + setIsCopied(true); + setTimeout(() => setIsCopied(false), 2000); + }); + } catch (error) { + console.error("Error copying to clipboard:", error); + } + }; + + // Handle reset + const handleReset = () => { + setOriginalImage(null); + setOriginalWidth(0); + setOriginalHeight(0); + setWidth(0); + setHeight(0); + setProcessedCanvas(null); + setEstimatedSize(0); + setOriginalFileSize(0); + setFormat("png"); + setQuality(92); + setAspectRatioLocked(true); + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + }; + + // Handle preset selection + const handlePresetSelect = (presetWidth: number, presetHeight: number) => { + setWidth(presetWidth); + setHeight(presetHeight); + }; + + // Batch mode handlers + const handleRemoveBatchFile = (fileId: string) => { + setBatchFiles(batchFiles.filter((f) => f.id !== fileId)); + }; + + const handleClearAllBatch = () => { + setBatchFiles([]); + }; + + const handleProcessAllBatch = async () => { + setIsProcessingBatch(true); + + for (let i = 0; i < batchFiles.length; i++) { + setCurrentBatchIndex(i); + const file = batchFiles[i]; + + try { + // Update status to processing + setBatchFiles((prev) => + prev.map((f) => + f.id === file.id ? { ...f, status: "processing" as const } : f, + ), + ); + + // Process the file + const { image } = await loadImageFromFile(file.file); + const canvas = await resizeImage(image, { + width, + height, + maintainAspectRatio: aspectRatioLocked, + algorithm, + }); + + const qualityDecimal = quality / 100; + const dataUrl = await convertToFormat(canvas, format, qualityDecimal); + const size = estimateFileSize(dataUrl); + + // Update status to completed + setBatchFiles((prev) => + prev.map((f) => + f.id === file.id + ? { + ...f, + status: "completed" as const, + processedDataUrl: dataUrl, + processedSize: size, + } + : f, + ), + ); + } catch { + // Update status to error + setBatchFiles((prev) => + prev.map((f) => + f.id === file.id + ? { + ...f, + status: "error" as const, + error: "Failed to process", + } + : f, + ), + ); + } + } + + setIsProcessingBatch(false); + }; + + const handleDownloadBatchFile = (fileId: string) => { + const file = batchFiles.find((f) => f.id === fileId); + if (!file || !file.processedDataUrl) return; + + const link = document.createElement("a"); + link.download = `resized-${file.name}`; + link.href = file.processedDataUrl; + link.click(); + }; + + const handleDownloadAllAsZip = async () => { + // This would require a zip library like JSZip + console.log("Download all as ZIP - not yet implemented"); + }; + + const imageLoaded = originalImage !== null; + + return ( + } + isPinned={isPinned} + onTogglePin={onTogglePin} + isAuthenticated={isAuthenticated} + > +
+ {/* Mode Toggle */} +
+ + +
+ + {/* Upload Zone (when no image and not in batch mode) */} + {!imageLoaded && !batchMode && ( +
fileInputRef.current?.click()} + onDrop={handleDrop} + onDragOver={(e) => e.preventDefault()} + > + +

+ Drop image here or click to upload +

+

+ PNG, JPG, WebP, GIF supported +

+ +
+ )} + + {/* Main Content (when image loaded and not in batch mode) */} + {imageLoaded && !batchMode && ( +
+ {/* Transformation Controls */} +
+ + + +
+ + {/* Preview */} + + + {/* Controls */} + + + {/* Preset Selector */} + + + {/* Algorithm Selector */} + + + {/* Output Settings */} + +
+ )} + + {/* Batch Mode */} + {batchMode && ( + + )} +
+
+ ); +} diff --git a/components/modules/image-resize/algorithm-selector.tsx b/components/modules/image-resize/algorithm-selector.tsx new file mode 100644 index 0000000..d0b64d1 --- /dev/null +++ b/components/modules/image-resize/algorithm-selector.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { Label } from "../../ui/label"; +import { RadioGroup, RadioGroupItem } from "../../ui/radio-group"; +import { + ResizeAlgorithm, + RESIZE_ALGORITHMS, +} from "@/src/lib/image-resize-utils"; + +interface AlgorithmSelectorProps { + value: ResizeAlgorithm; + onChange: (value: ResizeAlgorithm) => void; + disabled?: boolean; +} + +export function AlgorithmSelector({ + value, + onChange, + disabled = false, +}: AlgorithmSelectorProps) { + return ( +
+ + onChange(val as ResizeAlgorithm)} + disabled={disabled} + > + {RESIZE_ALGORITHMS.map((algorithm) => ( +
+ +
+ +

+ {algorithm.recommendedUse} +

+
+
+ ))} +
+
+ ); +} diff --git a/components/modules/image-resize/batch-processor.tsx b/components/modules/image-resize/batch-processor.tsx new file mode 100644 index 0000000..d400394 --- /dev/null +++ b/components/modules/image-resize/batch-processor.tsx @@ -0,0 +1,181 @@ +"use client"; + +import { Button } from "../../ui/button"; +import { + X, + Trash2, + Download, + Archive, + Loader2, + CheckCircle, + XCircle, +} from "lucide-react"; +import { type ResizeAlgorithm } from "@/src/lib/image-resize-utils"; + +export interface BatchFile { + id: string; + file: File; + name: string; + originalSize: number; + processedSize?: number; + processedDataUrl?: string; + status: "pending" | "processing" | "completed" | "error"; + error?: string; +} + +export interface BatchProcessorProps { + files: BatchFile[]; + onRemoveFile: (fileId: string) => void; + onClearAll: () => void; + onProcessAll: () => void; + onDownloadFile: (fileId: string) => void; + onDownloadAllAsZip: () => void; + isProcessing: boolean; + currentProcessingIndex: number; + disabled?: boolean; + algorithm?: ResizeAlgorithm; +} + +function formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +export function BatchProcessor({ + files, + onRemoveFile, + onClearAll, + onProcessAll, + onDownloadFile, + onDownloadAllAsZip, + isProcessing, + currentProcessingIndex, + disabled = false, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + algorithm, // Prop accepted for interface completeness; actual processing uses parent's algorithm +}: BatchProcessorProps) { + return ( +
+ {/* Header with Clear All */} +
+ + {files.length} file{files.length !== 1 ? "s" : ""} selected + + +
+ + {/* Progress Bar (when processing) */} + {isProcessing && ( +
+
+ Processing... + + {currentProcessingIndex + 1} of {files.length} + +
+
+
+
+
+ )} + + {/* File List */} +
+ {files.map((file) => ( +
+ {/* Thumbnail placeholder */} +
+ {file.status === "processing" && ( + + )} + {file.status === "completed" && ( + + )} + {file.status === "error" && ( + + )} +
+ + {/* File info */} +
+
{file.name}
+
+ {formatFileSize(file.originalSize)} + {file.processedSize && + ` → ${formatFileSize(file.processedSize)}`} +
+
+ + {/* Actions */} +
+ {file.status === "completed" && ( + + )} + +
+
+ ))} +
+ + {/* Action Buttons */} +
+ + +
+
+ ); +} diff --git a/components/modules/image-resize/image-preview.tsx b/components/modules/image-resize/image-preview.tsx new file mode 100644 index 0000000..1aaaaf8 --- /dev/null +++ b/components/modules/image-resize/image-preview.tsx @@ -0,0 +1,161 @@ +"use client"; + +import { useRef, useEffect, useState } from "react"; +import { Button } from "../../ui/button"; +import { ZoomIn, ZoomOut, Maximize2, Eye, EyeOff } from "lucide-react"; + +interface ImagePreviewProps { + canvas: HTMLCanvasElement | null; + originalImage: HTMLImageElement | null; + originalWidth: number; + originalHeight: number; + newWidth: number; + newHeight: number; + originalFileSize?: number; + estimatedFileSize?: number; +} + +function formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +export function ImagePreview({ + canvas, + originalImage, + originalWidth, + originalHeight, + newWidth, + newHeight, + originalFileSize, + estimatedFileSize, +}: ImagePreviewProps) { + const previewCanvasRef = useRef(null); + const [zoom, setZoom] = useState(100); + const [showOriginal, setShowOriginal] = useState(false); + + const zoomLevels = [25, 50, 75, 100, 150, 200]; + + const zoomIn = () => { + const currentIndex = zoomLevels.indexOf(zoom); + if (currentIndex < zoomLevels.length - 1) { + setZoom(zoomLevels[currentIndex + 1]); + } + }; + + const zoomOut = () => { + const currentIndex = zoomLevels.indexOf(zoom); + if (currentIndex > 0) { + setZoom(zoomLevels[currentIndex - 1]); + } + }; + + const fitToView = () => { + setZoom(100); + }; + + const toggleView = () => { + setShowOriginal(!showOriginal); + }; + + useEffect(() => { + if (!previewCanvasRef.current) return; + + const previewCanvas = previewCanvasRef.current; + const ctx = previewCanvas.getContext("2d"); + if (!ctx) return; + + if (showOriginal && originalImage) { + // Show original image + previewCanvas.width = originalWidth; + previewCanvas.height = originalHeight; + ctx.drawImage(originalImage, 0, 0); + } else if (canvas) { + // Show resized image + previewCanvas.width = newWidth; + previewCanvas.height = newHeight; + ctx.drawImage(canvas, 0, 0); + } + }, [ + canvas, + originalImage, + showOriginal, + originalWidth, + originalHeight, + newWidth, + newHeight, + ]); + + return ( +
+ {/* Preview Container */} +
+ {/* Zoom controls - positioned top-right */} +
+ + {zoom}% + + +
+ + {/* Canvas container - scrollable if zoomed */} +
+
+ +
+
+
+ + {/* Controls and Info */} +
+ + +
+ + Original: {originalWidth}×{originalHeight} + + + New: {newWidth}×{newHeight} + +
+
+ + {/* File size comparison */} + {originalFileSize && estimatedFileSize && ( +
+ Size: {formatFileSize(originalFileSize)} →{" "} + {formatFileSize(estimatedFileSize)} +
+ )} +
+ ); +} diff --git a/components/modules/image-resize/output-settings.tsx b/components/modules/image-resize/output-settings.tsx new file mode 100644 index 0000000..a72cef8 --- /dev/null +++ b/components/modules/image-resize/output-settings.tsx @@ -0,0 +1,140 @@ +"use client"; + +import { Button } from "../../ui/button"; +import { Slider } from "../../ui/slider"; +import { Label } from "../../ui/label"; +import { Download, Copy, Check, RefreshCw } from "lucide-react"; + +interface OutputSettingsProps { + format: "png" | "jpeg" | "webp"; + quality: number; + onFormatChange: (format: "png" | "jpeg" | "webp") => void; + onQualityChange: (quality: number) => void; + estimatedFileSize: number; + originalFileSize: number; + onDownload: () => void; + onCopyToClipboard: () => void; + onReset: () => void; + disabled?: boolean; + isCopied?: boolean; +} + +function formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +export function OutputSettings({ + format, + quality, + onFormatChange, + onQualityChange, + estimatedFileSize, + originalFileSize, + onDownload, + onCopyToClipboard, + onReset, + disabled = false, + isCopied = false, +}: OutputSettingsProps) { + const reduction = Math.round( + ((originalFileSize - estimatedFileSize) / originalFileSize) * 100, + ); + + const formatDescriptions = { + png: "Lossless compression, supports transparency", + jpeg: "Lossy compression, smaller file size", + webp: "Modern format, best compression", + }; + + return ( +
+ {/* Format Selector */} +
+ +
+ + + +
+

+ {formatDescriptions[format]} +

+
+ + {/* Quality Slider */} +
+
+ + {quality}% +
+ {format === "png" ? ( +

+ PNG is lossless - no quality adjustment needed +

+ ) : ( + onQualityChange(val)} + min={1} + max={100} + disabled={disabled} + /> + )} +
+ + {/* File Size Display */} +
+
Estimated Size
+
+ {formatFileSize(estimatedFileSize)} +
+
+ Original: {formatFileSize(originalFileSize)} ( + {reduction > 0 ? `${reduction}% smaller` : `${-reduction}% larger`}) +
+
+ + {/* Action Buttons */} +
+ + + +
+
+ ); +} diff --git a/components/modules/image-resize/preset-selector.tsx b/components/modules/image-resize/preset-selector.tsx new file mode 100644 index 0000000..a313c72 --- /dev/null +++ b/components/modules/image-resize/preset-selector.tsx @@ -0,0 +1,224 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "../../ui/button"; +import { Input } from "../../ui/input"; +import { Label } from "../../ui/label"; +import { Plus, Trash2 } from "lucide-react"; +import { + SOCIAL_MEDIA_PRESETS, + COMMON_PRESETS, + loadCustomPresets, + saveCustomPreset, + deleteCustomPreset, + type ImagePreset, +} from "@/src/lib/image-resize-presets"; + +interface PresetSelectorProps { + onPresetSelect: (width: number, height: number) => void; + selectedPresetId?: string; + disabled?: boolean; +} + +export function PresetSelector({ + onPresetSelect, + selectedPresetId, + disabled = false, +}: PresetSelectorProps) { + const [customPresets, setCustomPresets] = useState(() => + loadCustomPresets(), + ); + const [showAddDialog, setShowAddDialog] = useState(false); + const [newPresetName, setNewPresetName] = useState(""); + const [newPresetWidth, setNewPresetWidth] = useState(""); + const [newPresetHeight, setNewPresetHeight] = useState(""); + + const handlePresetClick = (preset: ImagePreset) => { + if (disabled) return; + onPresetSelect(preset.width, preset.height); + }; + + const handleSaveCustomPreset = () => { + const width = parseInt(newPresetWidth, 10); + const height = parseInt(newPresetHeight, 10); + + if ( + !newPresetName.trim() || + isNaN(width) || + isNaN(height) || + width <= 0 || + height <= 0 + ) { + return; + } + + const newPreset = saveCustomPreset({ + name: newPresetName.trim(), + width, + height, + }); + + setCustomPresets([...customPresets, newPreset]); + setNewPresetName(""); + setNewPresetWidth(""); + setNewPresetHeight(""); + setShowAddDialog(false); + }; + + const handleDeleteCustomPreset = (presetId: string) => { + deleteCustomPreset(presetId); + setCustomPresets(customPresets.filter((p) => p.id !== presetId)); + }; + + const renderPresetButton = (preset: ImagePreset) => { + const aspectRatio = preset.width / preset.height; + const visualWidth = Math.min(40, 40 * aspectRatio); + const visualHeight = Math.min(40, 40 / aspectRatio); + + return ( + + ); + }; + + return ( +
+ {/* Social Media Section */} +
+

Social Media

+
+ {SOCIAL_MEDIA_PRESETS.map(renderPresetButton)} +
+
+ + {/* Common Sizes Section */} +
+

Common Sizes

+
+ {COMMON_PRESETS.map(renderPresetButton)} +
+
+ + {/* Custom Presets Section */} +
+

Custom Presets

+ + {customPresets.length > 0 && ( +
+ {customPresets.map((preset) => ( +
+
{renderPresetButton(preset)}
+ +
+ ))} +
+ )} + + {!showAddDialog ? ( + + ) : ( +
+
+ + setNewPresetName(e.target.value)} + placeholder="My Custom Size" + className="mt-1" + /> +
+
+
+ + setNewPresetWidth(e.target.value)} + placeholder="800" + className="mt-1" + /> +
+
+ + setNewPresetHeight(e.target.value)} + placeholder="600" + className="mt-1" + /> +
+
+
+ + +
+
+ )} +
+
+ ); +} diff --git a/components/modules/image-resize/resize-controls.tsx b/components/modules/image-resize/resize-controls.tsx new file mode 100644 index 0000000..5f8c28f --- /dev/null +++ b/components/modules/image-resize/resize-controls.tsx @@ -0,0 +1,192 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "../../ui/button"; +import { Input } from "../../ui/input"; +import { Label } from "../../ui/label"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "../../ui/tabs"; +import { Link2, Link2Off, ArrowDownUp } from "lucide-react"; + +interface ResizeControlsProps { + width: number; + height: number; + originalWidth: number; + originalHeight: number; + onWidthChange: (width: number) => void; + onHeightChange: (height: number) => void; + aspectRatioLocked: boolean; + onAspectRatioLockedChange: (locked: boolean) => void; + disabled?: boolean; +} + +export function ResizeControls({ + width, + height, + originalWidth, + originalHeight, + onWidthChange, + onHeightChange, + aspectRatioLocked, + onAspectRatioLockedChange, + disabled = false, +}: ResizeControlsProps) { + const [selectedPercentage, setSelectedPercentage] = useState( + null, + ); + + const aspectRatio = originalWidth / originalHeight; + + const handleWidthChange = (newWidth: number) => { + onWidthChange(newWidth); + if (aspectRatioLocked) { + const newHeight = Math.round(newWidth / aspectRatio); + onHeightChange(newHeight); + } + }; + + const handleHeightChange = (newHeight: number) => { + onHeightChange(newHeight); + if (aspectRatioLocked) { + const newWidth = Math.round(newHeight * aspectRatio); + onWidthChange(newWidth); + } + }; + + const handleSwapDimensions = () => { + const tempWidth = width; + onWidthChange(height); + onHeightChange(tempWidth); + }; + + const handlePercentageChange = (percentage: number) => { + setSelectedPercentage(percentage); + const newWidth = Math.round((originalWidth * percentage) / 100); + const newHeight = Math.round((originalHeight * percentage) / 100); + onWidthChange(newWidth); + onHeightChange(newHeight); + }; + + const percentages = [25, 50, 75, 100, 150, 200]; + + return ( +
+ + + Dimensions + Percentage + Presets + + + +
+
+ +
+ + handleWidthChange(parseInt(e.target.value) || 0) + } + disabled={disabled} + className="flex-1" + /> + px +
+
+ + + +
+ +
+ + handleHeightChange(parseInt(e.target.value) || 0) + } + disabled={disabled} + className="flex-1" + /> + px +
+
+
+ +
+ + + Aspect ratio {aspectRatioLocked ? "locked" : "unlocked"} + +
+
+ + +
+ +
+ {percentages.map((percentage) => ( + + ))} +
+
+ + {selectedPercentage !== null && ( +
+
Calculated dimensions:
+
+ Width: {Math.round((originalWidth * selectedPercentage) / 100)} + px +
+
+ Height:{" "} + {Math.round((originalHeight * selectedPercentage) / 100)}px +
+
+ )} +
+ + +
+ Select a preset from below +
+
+
+
+ ); +} diff --git a/package.json b/package.json index 501c97d..1a5ea35 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "next-themes": "^0.4.6", "node-vibrant": "^4.0.3", "pg": "^8.16.3", + "pica": "^9.0.1", "postcss": "^8.5.6", "radix-ui": "^1.4.3", "react": "19.2.3", @@ -66,6 +67,7 @@ "@types/chroma-js": "^3.1.2", "@types/node": "^20", "@types/pg": "^8.16.0", + "@types/pica": "^9.0.5", "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0c40383..0572423 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -86,6 +86,9 @@ importers: pg: specifier: ^8.16.3 version: 8.16.3 + pica: + specifier: ^9.0.1 + version: 9.0.1 postcss: specifier: ^8.5.6 version: 8.5.6 @@ -147,6 +150,9 @@ importers: '@types/pg': specifier: ^8.16.0 version: 8.16.0 + '@types/pica': + specifier: ^9.0.5 + version: 9.0.5 '@types/react': specifier: ^19 version: 19.2.7 @@ -2175,6 +2181,9 @@ packages: '@types/pg@8.16.0': resolution: {integrity: sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==} + '@types/pica@9.0.5': + resolution: {integrity: sha512-OSd4905yxFNtRanHuyyQAfC9AkxiYcbhlzP606Gl6rFcYRgq4vdLCZuYKokLQBihgrkNzyPkoeykvJDWcPjaCw==} + '@types/prismjs@1.26.5': resolution: {integrity: sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==} @@ -3564,6 +3573,9 @@ packages: resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} engines: {node: '>= 0.4'} + glur@1.1.2: + resolution: {integrity: sha512-l+8esYHTKOx2G/Aao4lEQ0bnHWg4fWtJbVoZZT9Knxi01pB8C80BR85nONLFwkkQoFRCmXY+BUcGZN3yZ2QsRA==} + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -4248,6 +4260,9 @@ packages: typescript: optional: true + multimath@2.0.0: + resolution: {integrity: sha512-toRx66cAMJ+Ccz7pMIg38xSIrtnbozk0dchXezwQDMgQmbGpfxjtv68H+L00iFL8hxDaVjrmwAFSb3I6bg8Q2g==} + mute-stream@2.0.0: resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} engines: {node: ^18.17.0 || >=20.5.0} @@ -4578,6 +4593,9 @@ packages: pgpass@1.0.5: resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + pica@9.0.1: + resolution: {integrity: sha512-v0U4vY6Z3ztz9b4jBIhCD3WYoecGXCQeCsYep+sXRefViL+mVVoTL+wqzdPeE+GpBFsRUtQZb6dltvAt2UkMtQ==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -5442,6 +5460,9 @@ packages: webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + webworkify@1.5.0: + resolution: {integrity: sha512-AMcUeyXAhbACL8S2hqqdqOLqvJ8ylmIbNwUIqQujRSouf4+eUFaXbG6F1Rbu+srlJMmxQWsiU7mOJi0nMBfM1g==} + whatwg-fetch@3.6.20: resolution: {integrity: sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==} @@ -7554,6 +7575,8 @@ snapshots: pg-protocol: 1.10.3 pg-types: 2.2.0 + '@types/pica@9.0.5': {} + '@types/prismjs@1.26.5': {} '@types/react-dom@19.2.3(@types/react@19.2.7)': @@ -9123,6 +9146,8 @@ snapshots: define-properties: 1.2.1 gopd: 1.2.0 + glur@1.1.2: {} + gopd@1.2.0: {} graceful-fs@4.2.11: {} @@ -9721,6 +9746,11 @@ snapshots: transitivePeerDependencies: - '@types/node' + multimath@2.0.0: + dependencies: + glur: 1.1.2 + object-assign: 4.1.1 + mute-stream@2.0.0: {} mysql2@3.15.3: @@ -10069,6 +10099,13 @@ snapshots: dependencies: split2: 4.2.0 + pica@9.0.1: + dependencies: + glur: 1.1.2 + multimath: 2.0.0 + object-assign: 4.1.1 + webworkify: 1.5.0 + picocolors@1.1.1: {} picomatch@2.3.1: {} @@ -11151,6 +11188,8 @@ snapshots: webidl-conversions@3.0.1: {} + webworkify@1.5.0: {} + whatwg-fetch@3.6.20: {} whatwg-url@5.0.0: diff --git a/src/config/modules.tsx b/src/config/modules.tsx index 4614eb3..b2001cf 100644 --- a/src/config/modules.tsx +++ b/src/config/modules.tsx @@ -2,6 +2,7 @@ import { Base64Module } from "@/components/modules/base64-module"; import { ColorConverterModule } from "@/components/modules/color-converter-module"; import { ColorPaletteModule } from "@/components/modules/color-palette-module"; import { DomainNamesModule } from "@/components/modules/domain-names-module"; +import { ImageResizeModule } from "@/components/modules/image-resize-module"; import { LoremIpsumModule } from "@/components/modules/lorem-ipsum-module"; import { PomodoroTimerModule } from "@/components/modules/pomodoro-timer-module"; import { RemoveBgModule } from "@/components/modules/removeBg-module"; @@ -19,6 +20,7 @@ import { Code2, FileText, Globe, + ImageIcon, Images, KeyRound, Palette, @@ -91,6 +93,15 @@ export const AVAILABLE_MODULES: ModuleConfig[] = [ category: "Image processing", requiresAuth: false, }, + { + id: "image-resize", + name: "Image Resize", + description: "Resize images with presets and custom dimensions", + icon: , + component: ImageResizeModule, + category: "Image processing", + requiresAuth: false, + }, { id: "translation", name: "Translator", diff --git a/src/lib/image-resize-presets.ts b/src/lib/image-resize-presets.ts new file mode 100644 index 0000000..cc15621 --- /dev/null +++ b/src/lib/image-resize-presets.ts @@ -0,0 +1,166 @@ +export interface ImagePreset { + id: string; + name: string; + width: number; + height: number; + category: PresetCategory; +} + +export type PresetCategory = "social" | "common" | "custom"; + +const STORAGE_KEY = "image-resize-custom-presets"; + +export const SOCIAL_MEDIA_PRESETS: ImagePreset[] = [ + { + id: "instagram-post", + name: "Instagram Post", + width: 1080, + height: 1080, + category: "social", + }, + { + id: "instagram-story", + name: "Instagram Story", + width: 1080, + height: 1920, + category: "social", + }, + { + id: "facebook-post", + name: "Facebook Post", + width: 1200, + height: 630, + category: "social", + }, + { + id: "twitter-post", + name: "Twitter/X Post", + width: 1600, + height: 900, + category: "social", + }, + { + id: "linkedin-post", + name: "LinkedIn Post", + width: 1200, + height: 627, + category: "social", + }, + { + id: "youtube-thumbnail", + name: "YouTube Thumbnail", + width: 1280, + height: 720, + category: "social", + }, +]; + +export const COMMON_PRESETS: ImagePreset[] = [ + { + id: "thumbnail-small", + name: "Thumbnail Small", + width: 150, + height: 150, + category: "common", + }, + { + id: "thumbnail-medium", + name: "Thumbnail Medium", + width: 300, + height: 300, + category: "common", + }, + { + id: "hd", + name: "HD", + width: 1280, + height: 720, + category: "common", + }, + { + id: "full-hd", + name: "Full HD", + width: 1920, + height: 1080, + category: "common", + }, + { + id: "4k", + name: "4K", + width: 3840, + height: 2160, + category: "common", + }, + { + id: "icon", + name: "Icon", + width: 64, + height: 64, + category: "common", + }, + { + id: "favicon", + name: "Favicon", + width: 32, + height: 32, + category: "common", + }, +]; + +export const ALL_PRESETS: ImagePreset[] = [ + ...SOCIAL_MEDIA_PRESETS, + ...COMMON_PRESETS, +]; + +export function saveCustomPreset( + preset: Omit, +): ImagePreset { + const newPreset: ImagePreset = { + ...preset, + id: `custom-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + category: "custom", + }; + + const existingPresets = loadCustomPresets(); + const updatedPresets = [...existingPresets, newPreset]; + + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(updatedPresets)); + } catch (error) { + console.error("Failed to save custom preset:", error); + } + + return newPreset; +} + +export function loadCustomPresets(): ImagePreset[] { + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (!stored) { + return []; + } + const parsed = JSON.parse(stored); + return Array.isArray(parsed) ? parsed : []; + } catch (error) { + console.error("Failed to load custom presets:", error); + return []; + } +} + +export function deleteCustomPreset(presetId: string): void { + const existingPresets = loadCustomPresets(); + const updatedPresets = existingPresets.filter( + (preset) => preset.id !== presetId, + ); + + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(updatedPresets)); + } catch (error) { + console.error("Failed to delete custom preset:", error); + } +} + +export function getPresetById(presetId: string): ImagePreset | undefined { + const allPresets = [...ALL_PRESETS, ...loadCustomPresets()]; + return allPresets.find((preset) => preset.id === presetId); +} diff --git a/src/lib/image-resize-utils.ts b/src/lib/image-resize-utils.ts new file mode 100644 index 0000000..81f5886 --- /dev/null +++ b/src/lib/image-resize-utils.ts @@ -0,0 +1,479 @@ +import Pica from "pica"; + +export interface ResizeOptions { + width: number; + height: number; + maintainAspectRatio?: boolean; + algorithm?: ResizeAlgorithm; +} + +export interface CalculateDimensionsOptions { + originalWidth: number; + originalHeight: number; + targetWidth?: number; + targetHeight?: number; + percentage?: number; + maxWidth?: number; + maxHeight?: number; + maintainAspectRatio?: boolean; +} + +export type ImageFormat = "png" | "jpeg" | "webp"; + +export type ResizeAlgorithm = "lanczos" | "bicubic" | "bilinear" | "nearest" | "pica"; + +export const RESIZE_ALGORITHMS = [ + { + id: "pica" as const, + name: "Ultra Quality (Pica)", + description: "True Lanczos3 resampling, best for downscaling photos", + quality: "ultra" as const, + recommendedUse: "Meilleure qualité pour le downscaling de photos - utilise un vrai algorithme Lanczos3", + }, + { + id: "lanczos" as const, + name: "High Quality", + description: "Best for photos, slower processing", + quality: "high" as const, + recommendedUse: + "Best for photos and detailed images where quality is paramount", + }, + { + id: "bicubic" as const, + name: "Standard", + description: "Good balance of quality and speed, default choice", + quality: "medium" as const, + recommendedUse: + "Recommended for most use cases, provides good quality with reasonable speed", + }, + { + id: "bilinear" as const, + name: "Fast", + description: "Quick processing, acceptable quality", + quality: "low" as const, + recommendedUse: "Ideal when speed is more important than maximum quality", + }, + { + id: "nearest" as const, + name: "Pixel Perfect", + description: "No interpolation, ideal for pixel art", + quality: "pixelated" as const, + recommendedUse: + "Perfect for pixel art and images that need sharp, unblurred edges", + }, +] as const; + +/** + * Performs multi-step downscaling for better quality when resizing to less than 50% of original size. + * Uses iterative halving technique to produce sharper results than single-step resize. + * + * @param image - Source image or canvas to resize + * @param targetWidth - Final target width + * @param targetHeight - Final target height + * @returns Canvas with the resized image + */ +function stepDownResize( + image: HTMLImageElement | HTMLCanvasElement, + targetWidth: number, + targetHeight: number, +): HTMLCanvasElement { + let currentWidth = image.width; + let currentHeight = image.height; + let currentImage: HTMLImageElement | HTMLCanvasElement = image; + + // Iteratively halve dimensions until within 2x of target size + while (currentWidth > targetWidth * 2 || currentHeight > targetHeight * 2) { + const stepWidth = Math.max(Math.floor(currentWidth / 2), targetWidth); + const stepHeight = Math.max(Math.floor(currentHeight / 2), targetHeight); + + const stepCanvas = document.createElement("canvas"); + const stepCtx = stepCanvas.getContext("2d"); + + if (!stepCtx) { + throw new Error("Failed to get canvas context for step-down resize"); + } + + stepCanvas.width = stepWidth; + stepCanvas.height = stepHeight; + + stepCtx.imageSmoothingEnabled = true; + stepCtx.imageSmoothingQuality = "high"; + stepCtx.drawImage(currentImage, 0, 0, stepWidth, stepHeight); + + currentImage = stepCanvas; + currentWidth = stepWidth; + currentHeight = stepHeight; + } + + // Final step: resize to exact target dimensions + const finalCanvas = document.createElement("canvas"); + const finalCtx = finalCanvas.getContext("2d"); + + if (!finalCtx) { + throw new Error("Failed to get canvas context for final resize"); + } + + finalCanvas.width = targetWidth; + finalCanvas.height = targetHeight; + + finalCtx.imageSmoothingEnabled = true; + finalCtx.imageSmoothingQuality = "high"; + finalCtx.drawImage(currentImage, 0, 0, targetWidth, targetHeight); + + return finalCanvas; +} + +export async function resizeImage( + image: HTMLImageElement, + options: ResizeOptions, +): Promise { + const { algorithm = "bicubic" } = options; + let { width, height } = options; + + if (options.maintainAspectRatio) { + const aspectRatio = image.width / image.height; + if (width / height > aspectRatio) { + width = height * aspectRatio; + } else { + height = width / aspectRatio; + } + } + + // Use pica for ultra-high quality resizing with true Lanczos3 + if (algorithm === "pica") { + const pica = new Pica({ + features: ['js', 'wasm', 'ww'], + }); + + // Create source canvas from image + const sourceCanvas = document.createElement("canvas"); + sourceCanvas.width = image.width; + sourceCanvas.height = image.height; + const sourceCtx = sourceCanvas.getContext("2d"); + if (!sourceCtx) throw new Error("Failed to get source canvas context"); + sourceCtx.drawImage(image, 0, 0); + + // Create destination canvas + const destCanvas = document.createElement("canvas"); + destCanvas.width = width; + destCanvas.height = height; + + // Resize with pica (Lanczos3 by default) + await pica.resize(sourceCanvas, destCanvas, { + quality: 3, // 0-3, 3 is highest (Lanczos3) + alpha: true, + unsharpAmount: 80, + unsharpRadius: 0.6, + unsharpThreshold: 2, + }); + + return destCanvas; + } + + // For lanczos algorithm with large downscale (target < 50% of original), + // use multi-step downscaling for better quality + if (algorithm === "lanczos") { + const scaleFactorWidth = width / image.width; + const scaleFactorHeight = height / image.height; + + if (scaleFactorWidth < 0.5 || scaleFactorHeight < 0.5) { + return stepDownResize(image, width, height); + } + } + + // Standard single-step resize for other cases + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + + if (!ctx) { + throw new Error("Failed to get canvas context"); + } + + canvas.width = width; + canvas.height = height; + + // Apply algorithm-specific canvas smoothing settings + if (algorithm === "nearest") { + ctx.imageSmoothingEnabled = false; + } else { + ctx.imageSmoothingEnabled = true; + + // Map algorithm to imageSmoothingQuality + if (algorithm === "lanczos") { + ctx.imageSmoothingQuality = "high"; + } else if (algorithm === "bicubic") { + ctx.imageSmoothingQuality = "medium"; + } else if (algorithm === "bilinear") { + ctx.imageSmoothingQuality = "low"; + } + } + + ctx.drawImage(image, 0, 0, width, height); + + return canvas; +} + +export async function loadImageFromFile(file: File): Promise<{ + image: HTMLImageElement; + width: number; + height: number; +}> { + // Validate file is an image + if (!file.type.startsWith("image/")) { + throw new Error("File is not an image"); + } + + return new Promise((resolve, reject) => { + const reader = new FileReader(); + + reader.onload = (e) => { + const img = new Image(); + + img.onload = () => { + resolve({ + image: img, + width: img.width, + height: img.height, + }); + }; + + img.onerror = () => { + reject(new Error("Failed to load image")); + }; + + img.src = e.target?.result as string; + }; + + reader.onerror = () => { + reject(new Error("Failed to read file")); + }; + + reader.readAsDataURL(file); + }); +} + +export async function getImageDimensions(source: File | string): Promise<{ + width: number; + height: number; +}> { + if (source instanceof File) { + const { width, height } = await loadImageFromFile(source); + return { width, height }; + } + + // Source is a URL or base64 string + return new Promise((resolve, reject) => { + const img = new Image(); + + img.onload = () => { + resolve({ + width: img.width, + height: img.height, + }); + }; + + img.onerror = () => { + reject(new Error("Failed to load image from URL")); + }; + + img.src = source; + }); +} + +export function calculateDimensions(options: CalculateDimensionsOptions): { + width: number; + height: number; +} { + const { + originalWidth, + originalHeight, + targetWidth, + targetHeight, + percentage, + maxWidth, + maxHeight, + maintainAspectRatio = true, + } = options; + + const aspectRatio = originalWidth / originalHeight; + + // Calculate from percentage + if (percentage !== undefined) { + return { + width: Math.round(originalWidth * (percentage / 100)), + height: Math.round(originalHeight * (percentage / 100)), + }; + } + + // Both dimensions specified + if (targetWidth !== undefined && targetHeight !== undefined) { + if (maintainAspectRatio) { + // Fit within target dimensions + const targetRatio = targetWidth / targetHeight; + if (aspectRatio > targetRatio) { + return { + width: targetWidth, + height: Math.round(targetWidth / aspectRatio), + }; + } else { + return { + width: Math.round(targetHeight * aspectRatio), + height: targetHeight, + }; + } + } else { + return { width: targetWidth, height: targetHeight }; + } + } + + // Only width specified + if (targetWidth !== undefined) { + return { + width: targetWidth, + height: maintainAspectRatio + ? Math.round(targetWidth / aspectRatio) + : originalHeight, + }; + } + + // Only height specified + if (targetHeight !== undefined) { + return { + width: maintainAspectRatio + ? Math.round(targetHeight * aspectRatio) + : originalWidth, + height: targetHeight, + }; + } + + // Fit to max dimensions + if (maxWidth !== undefined || maxHeight !== undefined) { + let newWidth = originalWidth; + let newHeight = originalHeight; + + if (maxWidth !== undefined && newWidth > maxWidth) { + newWidth = maxWidth; + newHeight = maintainAspectRatio + ? Math.round(maxWidth / aspectRatio) + : newHeight; + } + + if (maxHeight !== undefined && newHeight > maxHeight) { + newHeight = maxHeight; + newWidth = maintainAspectRatio + ? Math.round(maxHeight * aspectRatio) + : newWidth; + } + + return { width: newWidth, height: newHeight }; + } + + // No constraints, return original dimensions + return { width: originalWidth, height: originalHeight }; +} + +export async function convertToFormat( + canvas: HTMLCanvasElement, + format: ImageFormat, + quality: number = 0.92, +): Promise { + // PNG is lossless, quality parameter is ignored + if (format === "png") { + return canvas.toDataURL("image/png"); + } + + // JPEG and WebP support quality parameter (0.0 - 1.0) + if (format === "jpeg") { + return canvas.toDataURL("image/jpeg", quality); + } + + if (format === "webp") { + return canvas.toDataURL("image/webp", quality); + } + + throw new Error(`Unsupported format: ${format}`); +} + +export function estimateFileSize(dataUrl: string): number { + // Remove data URL prefix (e.g., "data:image/png;base64,") + const base64String = dataUrl.split(",")[1] || dataUrl; + + // Calculate size: each base64 character represents 6 bits + // The actual byte size is (base64Length * 6) / 8 + // Account for padding characters (=) + const padding = (base64String.match(/=/g) || []).length; + const base64Length = base64String.length; + + return Math.floor((base64Length * 3) / 4 - padding); +} + +export function rotateImage( + canvas: HTMLCanvasElement, + degrees: 90 | 180 | 270, +): HTMLCanvasElement { + const newCanvas = document.createElement("canvas"); + const ctx = newCanvas.getContext("2d"); + + if (!ctx) { + throw new Error("Failed to get canvas context"); + } + + const radians = (degrees * Math.PI) / 180; + + // Adjust canvas size for 90 and 270 degree rotations + if (degrees === 90 || degrees === 270) { + newCanvas.width = canvas.height; + newCanvas.height = canvas.width; + } else { + newCanvas.width = canvas.width; + newCanvas.height = canvas.height; + } + + // Set transform origin and rotate + ctx.save(); + + if (degrees === 90) { + ctx.translate(newCanvas.width, 0); + ctx.rotate(radians); + } else if (degrees === 180) { + ctx.translate(newCanvas.width, newCanvas.height); + ctx.rotate(radians); + } else if (degrees === 270) { + ctx.translate(0, newCanvas.height); + ctx.rotate(radians); + } + + ctx.drawImage(canvas, 0, 0); + ctx.restore(); + + return newCanvas; +} + +export function flipImage( + canvas: HTMLCanvasElement, + direction: "horizontal" | "vertical", +): HTMLCanvasElement { + const newCanvas = document.createElement("canvas"); + const ctx = newCanvas.getContext("2d"); + + if (!ctx) { + throw new Error("Failed to get canvas context"); + } + + newCanvas.width = canvas.width; + newCanvas.height = canvas.height; + + ctx.save(); + + if (direction === "horizontal") { + ctx.scale(-1, 1); + ctx.drawImage(canvas, -canvas.width, 0); + } else { + ctx.scale(1, -1); + ctx.drawImage(canvas, 0, -canvas.height); + } + + ctx.restore(); + + return newCanvas; +}