From 4022cda3572007d424d01d690dfcdbcfad964d8e Mon Sep 17 00:00:00 2001 From: jason Date: Sun, 8 Mar 2026 16:22:21 -0500 Subject: [PATCH] Add client-side preview utility functions --- frontend/src/lib/preview.ts | 246 ++++++++++++++++++++++++++++++++++++ 1 file changed, 246 insertions(+) create mode 100644 frontend/src/lib/preview.ts diff --git a/frontend/src/lib/preview.ts b/frontend/src/lib/preview.ts new file mode 100644 index 0000000..5c4fb6e --- /dev/null +++ b/frontend/src/lib/preview.ts @@ -0,0 +1,246 @@ +export interface TransformOptions { + width?: number; + height?: number; + quality: number; + format: 'png' | 'webp' | 'jpeg'; + fit: 'inside' | 'cover'; + position?: string; +} + +/** + * Generate a client-side preview using Canvas API + * This provides instant feedback without server round-trip + */ +export async function generateClientPreview( + file: File, + options: TransformOptions +): Promise { + return new Promise((resolve, reject) => { + const img = new Image(); + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + + if (!ctx) { + reject(new Error('Canvas context not available')); + return; + } + + img.onload = () => { + try { + const { width, height } = calculateDimensions(img, options); + + canvas.width = width; + canvas.height = height; + + if (options.fit === 'cover' && options.width && options.height) { + drawCover(ctx, img, options.width, options.height, options.position || 'center'); + } else { + ctx.drawImage(img, 0, 0, width, height); + } + + // Convert to data URL with quality + const quality = options.quality / 100; + const mimeType = `image/${options.format === 'jpeg' ? 'jpeg' : 'png'}`; + const dataUrl = canvas.toDataURL(mimeType, quality); + + resolve(dataUrl); + } catch (error) { + reject(error); + } + }; + + img.onerror = () => { + reject(new Error('Failed to load image')); + }; + + img.src = URL.createObjectURL(file); + }); +} + +/** + * Calculate dimensions for resize operation + */ +function calculateDimensions( + img: HTMLImageElement, + options: TransformOptions +): { width: number; height: number } { + const originalWidth = img.naturalWidth; + const originalHeight = img.naturalHeight; + const originalAspect = originalWidth / originalHeight; + + // If no dimensions specified, return original + if (!options.width && !options.height) { + return { width: originalWidth, height: originalHeight }; + } + + // If only width specified + if (options.width && !options.height) { + return { + width: options.width, + height: Math.round(options.width / originalAspect) + }; + } + + // If only height specified + if (options.height && !options.width) { + return { + width: Math.round(options.height * originalAspect), + height: options.height + }; + } + + // Both dimensions specified + const targetWidth = options.width!; + const targetHeight = options.height!; + const targetAspect = targetWidth / targetHeight; + + if (options.fit === 'cover') { + // Fill the box, crop excess + return { width: targetWidth, height: targetHeight }; + } else { + // Fit inside box, maintain aspect ratio + if (originalAspect > targetAspect) { + // Image is wider + return { + width: targetWidth, + height: Math.round(targetWidth / originalAspect) + }; + } else { + // Image is taller + return { + width: Math.round(targetHeight * originalAspect), + height: targetHeight + }; + } + } +} + +/** + * Draw image with cover fit (crop to fill) + */ +function drawCover( + ctx: CanvasRenderingContext2D, + img: HTMLImageElement, + targetWidth: number, + targetHeight: number, + position: string +) { + const imgWidth = img.naturalWidth; + const imgHeight = img.naturalHeight; + const imgAspect = imgWidth / imgHeight; + const targetAspect = targetWidth / targetHeight; + + let sourceWidth: number; + let sourceHeight: number; + let sourceX = 0; + let sourceY = 0; + + if (imgAspect > targetAspect) { + // Image is wider, crop sides + sourceHeight = imgHeight; + sourceWidth = imgHeight * targetAspect; + sourceX = getPositionOffset(imgWidth - sourceWidth, position, 'horizontal'); + } else { + // Image is taller, crop top/bottom + sourceWidth = imgWidth; + sourceHeight = imgWidth / targetAspect; + sourceY = getPositionOffset(imgHeight - sourceHeight, position, 'vertical'); + } + + ctx.drawImage( + img, + sourceX, + sourceY, + sourceWidth, + sourceHeight, + 0, + 0, + targetWidth, + targetHeight + ); +} + +/** + * Calculate crop offset based on position + */ +function getPositionOffset( + availableSpace: number, + position: string, + axis: 'horizontal' | 'vertical' +): number { + const pos = position.toLowerCase(); + + if (axis === 'horizontal') { + if (pos.includes('left')) return 0; + if (pos.includes('right')) return availableSpace; + return availableSpace / 2; // center + } else { + if (pos.includes('top')) return 0; + if (pos.includes('bottom')) return availableSpace; + return availableSpace / 2; // center + } +} + +/** + * Estimate file size from data URL + */ +export function estimateSize(dataUrl: string): number { + const base64 = dataUrl.split(',')[1]; + if (!base64) return 0; + // Base64 is ~33% larger than binary, so divide by 1.33 + return Math.ceil((base64.length * 3) / 4); +} + +/** + * Format bytes to human-readable size + */ +export function formatFileSize(bytes: number): string { + if (bytes === 0) return '0 B'; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; +} + +/** + * Calculate savings/increase + */ +export function calculateSavings(original: number, modified: number): { + amount: number; + percent: number; + isReduction: boolean; + formatted: string; +} { + const diff = original - modified; + const percent = (Math.abs(diff) / original) * 100; + const isReduction = diff > 0; + + let formatted: string; + if (diff > 0) { + formatted = `↓ ${formatFileSize(diff)} saved (${percent.toFixed(1)}%)`; + } else if (diff < 0) { + formatted = `↑ ${formatFileSize(Math.abs(diff))} larger (${percent.toFixed(1)}%)`; + } else { + formatted = 'Same size'; + } + + return { + amount: Math.abs(diff), + percent, + isReduction, + formatted + }; +} + +/** + * Debounce function for performance + */ +export function debounce any>( + func: T, + wait: number +): (...args: Parameters) => void { + let timeout: ReturnType; + return (...args: Parameters) => { + clearTimeout(timeout); + timeout = setTimeout(() => func(...args), wait); + }; +} \ No newline at end of file