UNPKG

12.2 kBJavaScriptView Raw
1const { Plugin } = require('@uppy/core')
2const Translator = require('@uppy/utils/lib/Translator')
3const dataURItoBlob = require('@uppy/utils/lib/dataURItoBlob')
4const isObjectURL = require('@uppy/utils/lib/isObjectURL')
5const isPreviewSupported = require('@uppy/utils/lib/isPreviewSupported')
6const MathLog2 = require('math-log2') // Polyfill for IE.
7const exifr = require('exifr/dist/mini.legacy.umd.js')
8
9/**
10 * The Thumbnail Generator plugin
11 */
12
13module.exports = class ThumbnailGenerator extends Plugin {
14 static VERSION = require('../package.json').version
15
16 constructor (uppy, opts) {
17 super(uppy, opts)
18 this.type = 'modifier'
19 this.id = this.opts.id || 'ThumbnailGenerator'
20 this.title = 'Thumbnail Generator'
21 this.queue = []
22 this.queueProcessing = false
23 this.defaultThumbnailDimension = 200
24 this.thumbnailType = this.opts.thumbnailType || 'image/jpeg'
25
26 this.defaultLocale = {
27 strings: {
28 generatingThumbnails: 'Generating thumbnails...'
29 }
30 }
31
32 const defaultOptions = {
33 thumbnailWidth: null,
34 thumbnailHeight: null,
35 waitForThumbnailsBeforeUpload: false,
36 lazy: false
37 }
38
39 this.opts = { ...defaultOptions, ...opts }
40
41 if (this.opts.lazy && this.opts.waitForThumbnailsBeforeUpload) {
42 throw new Error('ThumbnailGenerator: The `lazy` and `waitForThumbnailsBeforeUpload` options are mutually exclusive. Please ensure at most one of them is set to `true`.')
43 }
44
45 this.i18nInit()
46 }
47
48 setOptions (newOpts) {
49 super.setOptions(newOpts)
50 this.i18nInit()
51 }
52
53 i18nInit () {
54 this.translator = new Translator([this.defaultLocale, this.uppy.locale, this.opts.locale])
55 this.i18n = this.translator.translate.bind(this.translator)
56 this.setPluginState() // so that UI re-renders and we see the updated locale
57 }
58
59 /**
60 * Create a thumbnail for the given Uppy file object.
61 *
62 * @param {{data: Blob}} file
63 * @param {number} targetWidth
64 * @param {number} targetHeight
65 * @returns {Promise}
66 */
67 createThumbnail (file, targetWidth, targetHeight) {
68 // bug in the compatibility data
69 // eslint-disable-next-line compat/compat
70 const originalUrl = URL.createObjectURL(file.data)
71
72 const onload = new Promise((resolve, reject) => {
73 const image = new Image()
74 image.src = originalUrl
75 image.addEventListener('load', () => {
76 // bug in the compatibility data
77 // eslint-disable-next-line compat/compat
78 URL.revokeObjectURL(originalUrl)
79 resolve(image)
80 })
81 image.addEventListener('error', (event) => {
82 // bug in the compatibility data
83 // eslint-disable-next-line compat/compat
84 URL.revokeObjectURL(originalUrl)
85 reject(event.error || new Error('Could not create thumbnail'))
86 })
87 })
88
89 const orientationPromise = exifr.rotation(file.data).catch(_err => 1)
90
91 return Promise.all([onload, orientationPromise])
92 .then(([image, orientation]) => {
93 const dimensions = this.getProportionalDimensions(image, targetWidth, targetHeight, orientation.deg)
94 const rotatedImage = this.rotateImage(image, orientation)
95 const resizedImage = this.resizeImage(rotatedImage, dimensions.width, dimensions.height)
96 return this.canvasToBlob(resizedImage, this.thumbnailType, 80)
97 })
98 .then(blob => {
99 // bug in the compatibility data
100 // eslint-disable-next-line compat/compat
101 return URL.createObjectURL(blob)
102 })
103 }
104
105 /**
106 * Get the new calculated dimensions for the given image and a target width
107 * or height. If both width and height are given, only width is taken into
108 * account. If neither width nor height are given, the default dimension
109 * is used.
110 */
111 getProportionalDimensions (img, width, height, rotation) {
112 var aspect = img.width / img.height
113 if (rotation === 90 || rotation === 270) {
114 aspect = img.height / img.width
115 }
116
117 if (width != null) {
118 return {
119 width: width,
120 height: Math.round(width / aspect)
121 }
122 }
123
124 if (height != null) {
125 return {
126 width: Math.round(height * aspect),
127 height: height
128 }
129 }
130
131 return {
132 width: this.defaultThumbnailDimension,
133 height: Math.round(this.defaultThumbnailDimension / aspect)
134 }
135 }
136
137 /**
138 * Make sure the image doesn’t exceed browser/device canvas limits.
139 * For ios with 256 RAM and ie
140 */
141 protect (image) {
142 // https://stackoverflow.com/questions/6081483/maximum-size-of-a-canvas-element
143
144 var ratio = image.width / image.height
145
146 var maxSquare = 5000000 // ios max canvas square
147 var maxSize = 4096 // ie max canvas dimensions
148
149 var maxW = Math.floor(Math.sqrt(maxSquare * ratio))
150 var maxH = Math.floor(maxSquare / Math.sqrt(maxSquare * ratio))
151 if (maxW > maxSize) {
152 maxW = maxSize
153 maxH = Math.round(maxW / ratio)
154 }
155 if (maxH > maxSize) {
156 maxH = maxSize
157 maxW = Math.round(ratio * maxH)
158 }
159 if (image.width > maxW) {
160 var canvas = document.createElement('canvas')
161 canvas.width = maxW
162 canvas.height = maxH
163 canvas.getContext('2d').drawImage(image, 0, 0, maxW, maxH)
164 image = canvas
165 }
166
167 return image
168 }
169
170 /**
171 * Resize an image to the target `width` and `height`.
172 *
173 * Returns a Canvas with the resized image on it.
174 */
175 resizeImage (image, targetWidth, targetHeight) {
176 // Resizing in steps refactored to use a solution from
177 // https://blog.uploadcare.com/image-resize-in-browsers-is-broken-e38eed08df01
178
179 image = this.protect(image)
180
181 var steps = Math.ceil(MathLog2(image.width / targetWidth))
182 if (steps < 1) {
183 steps = 1
184 }
185 var sW = targetWidth * Math.pow(2, steps - 1)
186 var sH = targetHeight * Math.pow(2, steps - 1)
187 var x = 2
188
189 while (steps--) {
190 var canvas = document.createElement('canvas')
191 canvas.width = sW
192 canvas.height = sH
193 canvas.getContext('2d').drawImage(image, 0, 0, sW, sH)
194 image = canvas
195
196 sW = Math.round(sW / x)
197 sH = Math.round(sH / x)
198 }
199
200 return image
201 }
202
203 rotateImage (image, translate) {
204 var w = image.width
205 var h = image.height
206
207 if (translate.deg === 90 || translate.deg === 270) {
208 w = image.height
209 h = image.width
210 }
211
212 var canvas = document.createElement('canvas')
213 canvas.width = w
214 canvas.height = h
215
216 var context = canvas.getContext('2d')
217 context.translate(w / 2, h / 2)
218 if (translate.canvas) {
219 context.rotate(translate.rad)
220 context.scale(translate.scaleX, translate.scaleY)
221 }
222 context.drawImage(image, -image.width / 2, -image.height / 2, image.width, image.height)
223
224 return canvas
225 }
226
227 /**
228 * Save a <canvas> element's content to a Blob object.
229 *
230 * @param {HTMLCanvasElement} canvas
231 * @returns {Promise}
232 */
233 canvasToBlob (canvas, type, quality) {
234 try {
235 canvas.getContext('2d').getImageData(0, 0, 1, 1)
236 } catch (err) {
237 if (err.code === 18) {
238 return Promise.reject(new Error('cannot read image, probably an svg with external resources'))
239 }
240 }
241
242 if (canvas.toBlob) {
243 return new Promise(resolve => {
244 canvas.toBlob(resolve, type, quality)
245 }).then((blob) => {
246 if (blob === null) {
247 throw new Error('cannot read image, probably an svg with external resources')
248 }
249 return blob
250 })
251 }
252 return Promise.resolve().then(() => {
253 return dataURItoBlob(canvas.toDataURL(type, quality), {})
254 }).then((blob) => {
255 if (blob === null) {
256 throw new Error('could not extract blob, probably an old browser')
257 }
258 return blob
259 })
260 }
261
262 /**
263 * Set the preview URL for a file.
264 */
265 setPreviewURL (fileID, preview) {
266 this.uppy.setFileState(fileID, { preview })
267 }
268
269 addToQueue (item) {
270 this.queue.push(item)
271 if (this.queueProcessing === false) {
272 this.processQueue()
273 }
274 }
275
276 processQueue () {
277 this.queueProcessing = true
278 if (this.queue.length > 0) {
279 const current = this.uppy.getFile(this.queue.shift())
280 if (!current) {
281 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')
282 return
283 }
284 return this.requestThumbnail(current)
285 .catch(err => {}) // eslint-disable-line handle-callback-err
286 .then(() => this.processQueue())
287 } else {
288 this.queueProcessing = false
289 this.uppy.log('[ThumbnailGenerator] Emptied thumbnail queue')
290 this.uppy.emit('thumbnail:all-generated')
291 }
292 }
293
294 requestThumbnail (file) {
295 if (isPreviewSupported(file.type) && !file.isRemote) {
296 return this.createThumbnail(file, this.opts.thumbnailWidth, this.opts.thumbnailHeight)
297 .then(preview => {
298 this.setPreviewURL(file.id, preview)
299 this.uppy.log(`[ThumbnailGenerator] Generated thumbnail for ${file.id}`)
300 this.uppy.emit('thumbnail:generated', this.uppy.getFile(file.id), preview)
301 })
302 .catch(err => {
303 this.uppy.log(`[ThumbnailGenerator] Failed thumbnail for ${file.id}:`, 'warning')
304 this.uppy.log(err, 'warning')
305 this.uppy.emit('thumbnail:error', this.uppy.getFile(file.id), err)
306 })
307 }
308 return Promise.resolve()
309 }
310
311 onFileAdded = (file) => {
312 if (!file.preview && isPreviewSupported(file.type) && !file.isRemote) {
313 this.addToQueue(file.id)
314 }
315 }
316
317 /**
318 * Cancel a lazy request for a thumbnail if the thumbnail has not yet been generated.
319 */
320 onCancelRequest = (file) => {
321 const index = this.queue.indexOf(file.id)
322 if (index !== -1) {
323 this.queue.splice(index, 1)
324 }
325 }
326
327 /**
328 * Clean up the thumbnail for a file. Cancel lazy requests and free the thumbnail URL.
329 */
330 onFileRemoved = (file) => {
331 const index = this.queue.indexOf(file.id)
332 if (index !== -1) {
333 this.queue.splice(index, 1)
334 }
335
336 // Clean up object URLs.
337 if (file.preview && isObjectURL(file.preview)) {
338 URL.revokeObjectURL(file.preview)
339 }
340 }
341
342 onRestored = () => {
343 const { files } = this.uppy.getState()
344 const fileIDs = Object.keys(files)
345 fileIDs.forEach((fileID) => {
346 const file = this.uppy.getFile(fileID)
347 if (!file.isRestored) return
348 // Only add blob URLs; they are likely invalid after being restored.
349 if (!file.preview || isObjectURL(file.preview)) {
350 this.addToQueue(file.id)
351 }
352 })
353 }
354
355 waitUntilAllProcessed = (fileIDs) => {
356 fileIDs.forEach((fileID) => {
357 const file = this.uppy.getFile(fileID)
358 this.uppy.emit('preprocess-progress', file, {
359 mode: 'indeterminate',
360 message: this.i18n('generatingThumbnails')
361 })
362 })
363
364 const emitPreprocessCompleteForAll = () => {
365 fileIDs.forEach((fileID) => {
366 const file = this.uppy.getFile(fileID)
367 this.uppy.emit('preprocess-complete', file)
368 })
369 }
370
371 return new Promise((resolve, reject) => {
372 if (this.queueProcessing) {
373 this.uppy.once('thumbnail:all-generated', () => {
374 emitPreprocessCompleteForAll()
375 resolve()
376 })
377 } else {
378 emitPreprocessCompleteForAll()
379 resolve()
380 }
381 })
382 }
383
384 install () {
385 this.uppy.on('file-removed', this.onFileRemoved)
386 if (this.opts.lazy) {
387 this.uppy.on('thumbnail:request', this.onFileAdded)
388 this.uppy.on('thumbnail:cancel', this.onCancelRequest)
389 } else {
390 this.uppy.on('file-added', this.onFileAdded)
391 this.uppy.on('restored', this.onRestored)
392 }
393
394 if (this.opts.waitForThumbnailsBeforeUpload) {
395 this.uppy.addPreProcessor(this.waitUntilAllProcessed)
396 }
397 }
398
399 uninstall () {
400 this.uppy.off('file-removed', this.onFileRemoved)
401 if (this.opts.lazy) {
402 this.uppy.off('thumbnail:request', this.onFileAdded)
403 this.uppy.off('thumbnail:cancel', this.onCancelRequest)
404 } else {
405 this.uppy.off('file-added', this.onFileAdded)
406 this.uppy.off('restored', this.onRestored)
407 }
408
409 if (this.opts.waitForThumbnailsBeforeUpload) {
410 this.uppy.removePreProcessor(this.waitUntilAllProcessed)
411 }
412 }
413}