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