@uppy/thumbnail-generator
Version:
Uppy plugin that generates small previews of images to show on your upload UI.
355 lines (354 loc) • 13 kB
JavaScript
import { UIPlugin } from '@uppy/core';
import { dataURItoBlob, isObjectURL, isPreviewSupported } from '@uppy/utils';
// @ts-ignore untyped
import { rotation } from 'exifr/dist/mini.esm.mjs';
import packageJson from '../package.json' with { type: 'json' };
import locale from './locale.js';
/**
* Save a <canvas> element's content to a Blob object.
*
*/
function canvasToBlob(canvas, type, quality) {
try {
canvas.getContext('2d').getImageData(0, 0, 1, 1);
}
catch (err) {
if (err.code === 18) {
return Promise.reject(new Error('cannot read image, probably an svg with external resources'));
}
}
if (canvas.toBlob) {
return new Promise((resolve) => {
canvas.toBlob(resolve, type, quality);
}).then((blob) => {
if (blob === null) {
throw new Error('cannot read image, probably an svg with external resources');
}
return blob;
});
}
return Promise.resolve()
.then(() => {
return dataURItoBlob(canvas.toDataURL(type, quality), {});
})
.then((blob) => {
if (blob === null) {
throw new Error('could not extract blob, probably an old browser');
}
return blob;
});
}
function rotateImage(image, translate) {
let w = image.width;
let h = image.height;
if (translate.deg === 90 || translate.deg === 270) {
w = image.height;
h = image.width;
}
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
const context = canvas.getContext('2d');
context.translate(w / 2, h / 2);
if (translate.canvas) {
context.rotate(translate.rad);
context.scale(translate.scaleX, translate.scaleY);
}
context.drawImage(image, -image.width / 2, -image.height / 2, image.width, image.height);
return canvas;
}
/**
* Make sure the image doesn’t exceed browser/device canvas limits.
* For ios with 256 RAM and ie
*/
function protect(image) {
// https://stackoverflow.com/questions/6081483/maximum-size-of-a-canvas-element
const ratio = image.width / image.height;
const maxSquare = 5000000; // ios max canvas square
const maxSize = 4096; // ie max canvas dimensions
let maxW = Math.floor(Math.sqrt(maxSquare * ratio));
let maxH = Math.floor(maxSquare / Math.sqrt(maxSquare * ratio));
if (maxW > maxSize) {
maxW = maxSize;
maxH = Math.round(maxW / ratio);
}
if (maxH > maxSize) {
maxH = maxSize;
maxW = Math.round(ratio * maxH);
}
if (image.width > maxW) {
const canvas = document.createElement('canvas');
canvas.width = maxW;
canvas.height = maxH;
canvas.getContext('2d').drawImage(image, 0, 0, maxW, maxH);
return canvas;
}
return image;
}
const defaultOptions = {
thumbnailWidth: null,
thumbnailHeight: null,
thumbnailType: 'image/jpeg',
waitForThumbnailsBeforeUpload: false,
lazy: false,
};
/**
* The Thumbnail Generator plugin
*/
export default class ThumbnailGenerator extends UIPlugin {
static VERSION = packageJson.version;
queue;
queueProcessing;
defaultThumbnailDimension;
thumbnailType;
constructor(uppy, opts) {
super(uppy, { ...defaultOptions, ...opts });
this.type = 'modifier';
this.id = this.opts.id || 'ThumbnailGenerator';
this.title = 'Thumbnail Generator';
this.queue = [];
this.queueProcessing = false;
this.defaultThumbnailDimension = 200;
this.thumbnailType = this.opts.thumbnailType;
this.defaultLocale = locale;
this.i18nInit();
if (this.opts.lazy && this.opts.waitForThumbnailsBeforeUpload) {
throw new Error('ThumbnailGenerator: The `lazy` and `waitForThumbnailsBeforeUpload` options are mutually exclusive. Please ensure at most one of them is set to `true`.');
}
}
createThumbnail(file, targetWidth, targetHeight) {
const originalUrl = URL.createObjectURL(file.data);
const onload = new Promise((resolve, reject) => {
const image = new Image();
image.src = originalUrl;
image.addEventListener('load', () => {
URL.revokeObjectURL(originalUrl);
resolve(image);
});
image.addEventListener('error', (event) => {
URL.revokeObjectURL(originalUrl);
reject(event.error || new Error('Could not create thumbnail'));
});
});
const orientationPromise = rotation(file.data).catch(() => 1);
return Promise.all([onload, orientationPromise])
.then(([image, orientation]) => {
const dimensions = this.getProportionalDimensions(image, targetWidth, targetHeight, orientation.deg);
const rotatedImage = rotateImage(image, orientation);
const resizedImage = this.resizeImage(rotatedImage, dimensions.width, dimensions.height);
return canvasToBlob(resizedImage, this.thumbnailType, 80);
})
.then((blob) => {
return URL.createObjectURL(blob);
});
}
/**
* Get the new calculated dimensions for the given image and a target width
* or height. If both width and height are given, only width is taken into
* account. If neither width nor height are given, the default dimension
* is used.
*/
getProportionalDimensions(img, width, height, deg) {
let aspect = img.width / img.height;
if (deg === 90 || deg === 270) {
aspect = img.height / img.width;
}
if (width != null) {
let targetWidth = width;
// Thumbnail shouldn’t be enlarged / upscaled, only reduced.
// If img is already smaller than width/height, leave it as is.
if (img.width < width)
targetWidth = img.width;
return {
width: targetWidth,
height: Math.round(targetWidth / aspect),
};
}
if (height != null) {
let targetHeight = height;
if (img.height < height)
targetHeight = img.height;
return {
width: Math.round(targetHeight * aspect),
height: targetHeight,
};
}
return {
width: this.defaultThumbnailDimension,
height: Math.round(this.defaultThumbnailDimension / aspect),
};
}
/**
* Resize an image to the target `width` and `height`.
*
* Returns a Canvas with the resized image on it.
*/
resizeImage(image, targetWidth, targetHeight) {
// Resizing in steps refactored to use a solution from
// https://blog.uploadcare.com/image-resize-in-browsers-is-broken-e38eed08df01
let img = protect(image);
let steps = Math.ceil(Math.log2(img.width / targetWidth));
if (steps < 1) {
steps = 1;
}
let sW = targetWidth * 2 ** (steps - 1);
let sH = targetHeight * 2 ** (steps - 1);
const x = 2;
while (steps--) {
const canvas = document.createElement('canvas');
canvas.width = sW;
canvas.height = sH;
canvas.getContext('2d').drawImage(img, 0, 0, sW, sH);
img = canvas;
sW = Math.round(sW / x);
sH = Math.round(sH / x);
}
return img;
}
/**
* Set the preview URL for a file.
*/
setPreviewURL(fileID, preview) {
this.uppy.setFileState(fileID, { preview });
}
addToQueue(fileID) {
this.queue.push(fileID);
if (this.queueProcessing === false) {
this.processQueue();
}
}
processQueue() {
this.queueProcessing = true;
if (this.queue.length > 0) {
const current = this.uppy.getFile(this.queue.shift());
if (!current) {
this.uppy.log('[ThumbnailGenerator] file was removed before a thumbnail could be generated, but not removed from the queue. This is probably a bug', 'error');
return Promise.resolve();
}
return this.requestThumbnail(current)
.catch(() => { })
.then(() => this.processQueue());
}
this.queueProcessing = false;
this.uppy.log('[ThumbnailGenerator] Emptied thumbnail queue');
this.uppy.emit('thumbnail:all-generated');
return Promise.resolve();
}
requestThumbnail(file) {
if (isPreviewSupported(file.type) && !file.isRemote) {
return this.createThumbnail(file, this.opts.thumbnailWidth, this.opts.thumbnailHeight)
.then((preview) => {
this.setPreviewURL(file.id, preview);
this.uppy.log(`[ThumbnailGenerator] Generated thumbnail for ${file.id}`);
this.uppy.emit('thumbnail:generated', this.uppy.getFile(file.id), preview);
})
.catch((err) => {
this.uppy.log(`[ThumbnailGenerator] Failed thumbnail for ${file.id}:`, 'warning');
this.uppy.log(err, 'warning');
this.uppy.emit('thumbnail:error', this.uppy.getFile(file.id), err);
});
}
return Promise.resolve();
}
onFileAdded = (file) => {
if (!file.preview &&
file.data &&
isPreviewSupported(file.type) &&
!file.isRemote) {
this.addToQueue(file.id);
}
};
/**
* Cancel a lazy request for a thumbnail if the thumbnail has not yet been generated.
*/
onCancelRequest = (file) => {
const index = this.queue.indexOf(file.id);
if (index !== -1) {
this.queue.splice(index, 1);
}
};
/**
* Clean up the thumbnail for a file. Cancel lazy requests and free the thumbnail URL.
*/
onFileRemoved = (file) => {
const index = this.queue.indexOf(file.id);
if (index !== -1) {
this.queue.splice(index, 1);
}
// Clean up object URLs.
if (file.preview && isObjectURL(file.preview)) {
URL.revokeObjectURL(file.preview);
}
};
onRestored = () => {
const restoredFiles = this.uppy.getFiles().filter((file) => file.isRestored);
restoredFiles.forEach((file) => {
// Only add blob URLs; they are likely invalid after being restored.
if (!file.preview || isObjectURL(file.preview)) {
this.addToQueue(file.id);
}
});
};
onAllFilesRemoved = () => {
this.queue = [];
};
waitUntilAllProcessed = (fileIDs) => {
fileIDs.forEach((fileID) => {
const file = this.uppy.getFile(fileID);
this.uppy.emit('preprocess-progress', file, {
mode: 'indeterminate',
message: this.i18n('generatingThumbnails'),
});
});
const emitPreprocessCompleteForAll = () => {
fileIDs.forEach((fileID) => {
const file = this.uppy.getFile(fileID);
this.uppy.emit('preprocess-complete', file);
});
};
return new Promise((resolve) => {
if (this.queueProcessing) {
this.uppy.once('thumbnail:all-generated', () => {
emitPreprocessCompleteForAll();
resolve();
});
}
else {
emitPreprocessCompleteForAll();
resolve();
}
});
};
install() {
this.uppy.on('file-removed', this.onFileRemoved);
this.uppy.on('cancel-all', this.onAllFilesRemoved);
if (this.opts.lazy) {
this.uppy.on('thumbnail:request', this.onFileAdded);
this.uppy.on('thumbnail:cancel', this.onCancelRequest);
}
else {
this.uppy.on('thumbnail:request', this.onFileAdded);
this.uppy.on('file-added', this.onFileAdded);
this.uppy.on('restored', this.onRestored);
}
if (this.opts.waitForThumbnailsBeforeUpload) {
this.uppy.addPreProcessor(this.waitUntilAllProcessed);
}
}
uninstall() {
this.uppy.off('file-removed', this.onFileRemoved);
this.uppy.off('cancel-all', this.onAllFilesRemoved);
if (this.opts.lazy) {
this.uppy.off('thumbnail:request', this.onFileAdded);
this.uppy.off('thumbnail:cancel', this.onCancelRequest);
}
else {
this.uppy.off('thumbnail:request', this.onFileAdded);
this.uppy.off('file-added', this.onFileAdded);
this.uppy.off('restored', this.onRestored);
}
if (this.opts.waitForThumbnailsBeforeUpload) {
this.uppy.removePreProcessor(this.waitUntilAllProcessed);
}
}
}