Add client-side preview utility functions

This commit is contained in:
2026-03-08 16:22:21 -05:00
parent d7509daf0d
commit 4022cda357

246
frontend/src/lib/preview.ts Normal file
View File

@@ -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<string> {
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<T extends (...args: any[]) => any>(
func: T,
wait: number
): (...args: Parameters<T>) => void {
let timeout: ReturnType<typeof setTimeout>;
return (...args: Parameters<T>) => {
clearTimeout(timeout);
timeout = setTimeout(() => func(...args), wait);
};
}