Add client-side preview utility functions
This commit is contained in:
246
frontend/src/lib/preview.ts
Normal file
246
frontend/src/lib/preview.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user