| | |
| | |
| | |
| | |
| |
|
| | export interface ResizeOptions { |
| | maxWidth?: number; |
| | maxHeight?: number; |
| | quality?: number; |
| | format?: 'jpeg' | 'png' | 'webp'; |
| | } |
| |
|
| | export interface ResizeResult { |
| | file: File; |
| | originalSize: number; |
| | newSize: number; |
| | originalDimensions: { width: number; height: number }; |
| | newDimensions: { width: number; height: number }; |
| | compressionRatio: number; |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | |
| | const DEFAULT_RESIZE_OPTIONS: ResizeOptions = { |
| | maxWidth: 1900, |
| | maxHeight: 1900, |
| | quality: 0.92, |
| | format: 'jpeg', |
| | }; |
| |
|
| | |
| | |
| | |
| | export function supportsClientResize(): boolean { |
| | try { |
| | |
| | if (typeof HTMLCanvasElement === 'undefined') return false; |
| | if (typeof FileReader === 'undefined') return false; |
| | if (typeof Image === 'undefined') return false; |
| |
|
| | |
| | const canvas = document.createElement('canvas'); |
| | const ctx = canvas.getContext('2d'); |
| |
|
| | return !!(ctx && ctx.drawImage && canvas.toBlob); |
| | } catch { |
| | return false; |
| | } |
| | } |
| |
|
| | |
| | |
| | |
| | function calculateDimensions( |
| | originalWidth: number, |
| | originalHeight: number, |
| | maxWidth: number, |
| | maxHeight: number, |
| | ): { width: number; height: number } { |
| | const { width, height } = { width: originalWidth, height: originalHeight }; |
| |
|
| | |
| | if (width <= maxWidth && height <= maxHeight) { |
| | return { width, height }; |
| | } |
| |
|
| | |
| | const widthRatio = maxWidth / width; |
| | const heightRatio = maxHeight / height; |
| | const scalingFactor = Math.min(widthRatio, heightRatio); |
| |
|
| | return { |
| | width: Math.round(width * scalingFactor), |
| | height: Math.round(height * scalingFactor), |
| | }; |
| | } |
| |
|
| | |
| | |
| | |
| | export function resizeImage( |
| | file: File, |
| | options: Partial<ResizeOptions> = {}, |
| | ): Promise<ResizeResult> { |
| | return new Promise((resolve, reject) => { |
| | |
| | if (!supportsClientResize()) { |
| | reject(new Error('Browser does not support client-side image resizing')); |
| | return; |
| | } |
| |
|
| | |
| | if (!file.type.startsWith('image/')) { |
| | reject(new Error('File is not an image')); |
| | return; |
| | } |
| |
|
| | const opts = { ...DEFAULT_RESIZE_OPTIONS, ...options }; |
| | const reader = new FileReader(); |
| |
|
| | reader.onload = (event) => { |
| | const img = new Image(); |
| |
|
| | img.onload = () => { |
| | try { |
| | const originalDimensions = { width: img.width, height: img.height }; |
| | const newDimensions = calculateDimensions( |
| | img.width, |
| | img.height, |
| | opts.maxWidth!, |
| | opts.maxHeight!, |
| | ); |
| |
|
| | |
| | if ( |
| | newDimensions.width === originalDimensions.width && |
| | newDimensions.height === originalDimensions.height |
| | ) { |
| | resolve({ |
| | file, |
| | originalSize: file.size, |
| | newSize: file.size, |
| | originalDimensions, |
| | newDimensions, |
| | compressionRatio: 1, |
| | }); |
| | return; |
| | } |
| |
|
| | |
| | const canvas = document.createElement('canvas'); |
| | const ctx = canvas.getContext('2d')!; |
| |
|
| | canvas.width = newDimensions.width; |
| | canvas.height = newDimensions.height; |
| |
|
| | |
| | ctx.imageSmoothingEnabled = true; |
| | ctx.imageSmoothingQuality = 'high'; |
| |
|
| | |
| | ctx.drawImage(img, 0, 0, newDimensions.width, newDimensions.height); |
| |
|
| | |
| | canvas.toBlob( |
| | (blob) => { |
| | if (!blob) { |
| | reject(new Error('Failed to create blob from canvas')); |
| | return; |
| | } |
| |
|
| | |
| | const extension = opts.format === 'jpeg' ? '.jpg' : `.${opts.format}`; |
| | const baseName = file.name.replace(/\.[^/.]+$/, ''); |
| | const newFileName = `${baseName}${extension}`; |
| |
|
| | const resizedFile = new File([blob], newFileName, { |
| | type: `image/${opts.format}`, |
| | lastModified: Date.now(), |
| | }); |
| |
|
| | resolve({ |
| | file: resizedFile, |
| | originalSize: file.size, |
| | newSize: resizedFile.size, |
| | originalDimensions, |
| | newDimensions, |
| | compressionRatio: resizedFile.size / file.size, |
| | }); |
| | }, |
| | `image/${opts.format}`, |
| | opts.quality, |
| | ); |
| | } catch (error) { |
| | reject(error); |
| | } |
| | }; |
| |
|
| | img.onerror = () => reject(new Error('Failed to load image')); |
| | img.src = event.target?.result as string; |
| | }; |
| |
|
| | reader.onerror = () => reject(new Error('Failed to read file')); |
| | reader.readAsDataURL(file); |
| | }); |
| | } |
| |
|
| | |
| | |
| | |
| | export function shouldResizeImage( |
| | file: File, |
| | fileSizeLimit: number = 512 * 1024 * 1024, |
| | ): boolean { |
| | |
| | if (file.size < fileSizeLimit * 0.1) { |
| | |
| | return false; |
| | } |
| |
|
| | |
| | if (!file.type.startsWith('image/')) { |
| | return false; |
| | } |
| |
|
| | |
| | if (file.type === 'image/gif') { |
| | return false; |
| | } |
| |
|
| | return true; |
| | } |
| |
|