Browse Source

core: Resize thumbnails using <canvas>

PR #199 changed thumbnails to be `blob:` Object URLs instead of base64
data URLs, which significantly speeds up rerenders. However, there are
some unintended side effects: GIFs play in previews and really large
images (say, 5+) load in slowly from top to bottom.

This PR uses `blob:` Object URLs to read the image and to display the
previews, but uses a `<canvas>` in between to resize the image, like how
it worked before #199. IME this has most of the speed benefits of Object
URLs, while also making GIFs preview as static images, and not
attempting to display unsupported `image/` mime-type files (such as GIMP
`.xcf` files).
Renée Kooi 7 years ago
parent
commit
8e2e51772f
2 changed files with 102 additions and 6 deletions
  1. 27 1
      src/core/Core.js
  2. 75 5
      src/core/Utils.js

+ 27 - 1
src/core/Core.js

@@ -300,7 +300,11 @@ class Uppy {
         }
 
         if (Utils.isPreviewSupported(fileTypeSpecific) && !isRemote) {
-          newFile.preview = Utils.getThumbnail(file.data)
+          Utils.createThumbnail(file, 200).then((thumbnail) => {
+            this.setPreviewURL(fileID, thumbnail)
+          }).catch((err) => {
+            console.warn(err.stack || err.message)
+          })
         }
 
         const isFileAllowed = this.checkRestrictions(false, newFile, fileType)
@@ -333,12 +337,34 @@ class Uppy {
     return this.getState().files[fileID]
   }
 
+  /**
+   * Set the preview URL for a file.
+   */
+  setPreviewURL (fileID, preview) {
+    const { files } = this.state
+    this.setState({
+      files: Object.assign({}, files, {
+        [fileID]: Object.assign({}, files[fileID], {
+          preview: preview
+        })
+      })
+    })
+  }
+
   removeFile (fileID) {
     const updatedFiles = Object.assign({}, this.getState().files)
+    const removedFile = updatedFiles[fileID]
     delete updatedFiles[fileID]
+
     this.setState({files: updatedFiles})
     this.calculateTotalProgress()
     this.emit('core:file-removed', fileID)
+
+    // Clean up object URLs.
+    if (removedFile.preview && Utils.isObjectURL(removedFile.preview)) {
+      URL.revokeObjectURL(removedFile.preview)
+    }
+
     this.log(`Removed file: ${fileID}`)
   }
 

+ 75 - 5
src/core/Utils.js

@@ -3,6 +3,7 @@ const throttle = require('lodash.throttle')
 // because of this https://github.com/sindresorhus/file-type/issues/78
 // and https://github.com/sindresorhus/copy-text-to-clipboard/issues/5
 const fileType = require('../vendor/file-type')
+const html = require('yo-yo')
 
 /**
  * A collection of small utility functions that help with dom manipulation, adding listeners,
@@ -249,15 +250,83 @@ function getFileNameAndExtension (fullFileName) {
   return [fileName, fileExt]
 }
 
-function getThumbnail (fileData) {
-  return URL.createObjectURL(fileData)
-}
-
 function supportsMediaRecorder () {
   return typeof MediaRecorder === 'function' && !!MediaRecorder.prototype &&
     typeof MediaRecorder.prototype.start === 'function'
 }
 
+/**
+ * Check if a URL string is an object URL from `URL.createObjectURL`.
+ *
+ * @param {string} url
+ * @return {boolean}
+ */
+function isObjectURL (url) {
+  return url.indexOf('blob:') === 0
+}
+
+function getProportionalHeight (img, width) {
+  const aspect = img.width / img.height
+  return Math.round(width / aspect)
+}
+
+/**
+ * Create a thumbnail for the given Uppy file object.
+ *
+ * @param {{data: Blob}} file
+ * @param {number} width
+ * @return {Promise}
+ */
+function createThumbnail (file, width) {
+  const originalUrl = URL.createObjectURL(file.data)
+
+  const onload = new Promise((resolve, reject) => {
+    const image = new Image()
+    image.src = originalUrl
+    image.onload = () => {
+      URL.revokeObjectURL(originalUrl)
+      resolve(image)
+    }
+    image.onerror = () => {
+      // The onerror event is totally useless unfortunately, as far as I know
+      URL.revokeObjectURL(originalUrl)
+      reject(new Error('Could not create thumbnail'))
+    }
+  })
+
+  return onload.then((image) => {
+    const height = getProportionalHeight(image, width)
+
+    const canvas = html`<canvas></canvas>`
+    canvas.width = width
+    canvas.height = height
+
+    const context = canvas.getContext('2d')
+    context.drawImage(image, 0, 0, width, height)
+
+    return canvasToBlob(canvas)
+  }).then((blob) => {
+    return URL.createObjectURL(blob)
+  })
+}
+
+/**
+ * Save a <canvas> element's content to a Blob object.
+ *
+ * @param {HTMLCanvasElement} canvas
+ * @return {Promise}
+ */
+function canvasToBlob (canvas) {
+  if (canvas.toBlob) {
+    return new Promise((resolve) => {
+      canvas.toBlob(resolve)
+    })
+  }
+  return Promise.resolve().then(() => {
+    return dataURItoBlob(canvas.toDataURL(), {})
+  })
+}
+
 function dataURItoBlob (dataURI, opts, toFile) {
   // get the base64 data
   var data = dataURI.split(',')[1]
@@ -442,7 +511,8 @@ module.exports = {
   getFileType,
   getArrayBuffer,
   isPreviewSupported,
-  getThumbnail,
+  isObjectURL,
+  createThumbnail,
   secondsToTime,
   dataURItoBlob,
   dataURItoFile,