97 lines
3.2 KiB
JavaScript
97 lines
3.2 KiB
JavaScript
|
|
// @ts-check
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Client-side image resize utility.
|
|||
|
|
*
|
|||
|
|
* Accepts a File/Blob, resizes it to fit within maxSize × maxSize pixels
|
|||
|
|
* (never upscales), and returns a data URI (WebP preferred, JPEG fallback).
|
|||
|
|
*
|
|||
|
|
* Used by the profile page before uploading an avatar image so that
|
|||
|
|
* data URIs stay well within the 512 KiB backend limit.
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
/** Accepted MIME types for avatar uploads. */
|
|||
|
|
const ACCEPTED_TYPES = new Set(['image/png', 'image/webp', 'image/jpeg']);
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Load an image File/Blob as a data URL via FileReader.
|
|||
|
|
* @param {File | Blob} file
|
|||
|
|
* @returns {Promise<string>}
|
|||
|
|
*/
|
|||
|
|
function _readAsDataUrl(file) {
|
|||
|
|
return new Promise((resolve, reject) => {
|
|||
|
|
const reader = new FileReader();
|
|||
|
|
reader.onload = () => resolve(/** @type {string} */ (reader.result));
|
|||
|
|
reader.onerror = () => reject(new Error('FileReader failed'));
|
|||
|
|
reader.readAsDataURL(file);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Load a data URL into an HTMLImageElement (waits for `onload`).
|
|||
|
|
* @param {string} src
|
|||
|
|
* @returns {Promise<HTMLImageElement>}
|
|||
|
|
*/
|
|||
|
|
function _loadImage(src) {
|
|||
|
|
return new Promise((resolve, reject) => {
|
|||
|
|
const img = new Image();
|
|||
|
|
img.onload = () => resolve(img);
|
|||
|
|
img.onerror = () => reject(new Error('Image failed to load'));
|
|||
|
|
img.src = src;
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Convert a canvas to a data URI, preferring WebP at quality 0.85.
|
|||
|
|
* Falls back to JPEG if the browser does not support WebP encoding.
|
|||
|
|
* @param {HTMLCanvasElement} canvas
|
|||
|
|
* @returns {string}
|
|||
|
|
*/
|
|||
|
|
function _canvasToDataUri(canvas) {
|
|||
|
|
const webp = canvas.toDataURL('image/webp', 0.85);
|
|||
|
|
// toDataURL returns a PNG if the MIME type is not supported — detect by prefix
|
|||
|
|
if (webp.startsWith('data:image/webp')) return webp;
|
|||
|
|
return canvas.toDataURL('image/jpeg', 0.85);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Resize an image File to fit within maxSize × maxSize, then return
|
|||
|
|
* a data URI (WebP at quality 0.85, or JPEG as fallback).
|
|||
|
|
*
|
|||
|
|
* - Images already within maxSize × maxSize are not upscaled.
|
|||
|
|
* - Only `image/png`, `image/webp`, and `image/jpeg` are accepted;
|
|||
|
|
* all other MIME types throw an Error.
|
|||
|
|
*
|
|||
|
|
* @param {File} file Image file to resize
|
|||
|
|
* @param {number} [maxSize=512] Maximum width and height in pixels
|
|||
|
|
* @returns {Promise<string>} data URI of the (possibly resized) image
|
|||
|
|
*/
|
|||
|
|
export async function resizeImageToDataUrl(file, maxSize = 104) {
|
|||
|
|
if (!ACCEPTED_TYPES.has(file.type)) {
|
|||
|
|
throw new Error(`Unsupported image type: ${file.type}. Accepted: PNG, WebP, JPEG.`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const dataUrl = await _readAsDataUrl(file);
|
|||
|
|
const img = await _loadImage(dataUrl);
|
|||
|
|
|
|||
|
|
const { naturalWidth: w, naturalHeight: h } = img;
|
|||
|
|
|
|||
|
|
// Compute output dimensions — scale down proportionally if needed, never upscale
|
|||
|
|
let outW = w;
|
|||
|
|
let outH = h;
|
|||
|
|
if (w > maxSize || h > maxSize) {
|
|||
|
|
const ratio = Math.min(maxSize / w, maxSize / h);
|
|||
|
|
outW = Math.round(w * ratio);
|
|||
|
|
outH = Math.round(h * ratio);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const canvas = document.createElement('canvas');
|
|||
|
|
canvas.width = outW;
|
|||
|
|
canvas.height = outH;
|
|||
|
|
const ctx = canvas.getContext('2d');
|
|||
|
|
if (!ctx) throw new Error('Could not get 2D canvas context');
|
|||
|
|
ctx.drawImage(img, 0, 0, outW, outH);
|
|||
|
|
|
|||
|
|
return _canvasToDataUri(canvas);
|
|||
|
|
}
|