UNPKG

@uppy/thumbnail-generator

Version:

Uppy plugin that generates small previews of images to show on your upload UI.

355 lines (354 loc) 13 kB
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); } } }