2019 / 10 / 12
Crop images in Vue

Some techniques for cropping an image in the browser, preview it and upload it.

frontend
vue
cropper.js
axios

What we want

  • Allow users to pick a local image
  • Display it on the browser
  • Let them manipulate it: crop, rotate, zoom, reset
  • Preview the results of the operations
  • Ability to upload it as a file

We’ll use the powerful Cropper.js as the workhorse for this component.

The code

As you can see from the demo above, this was done using Vuetify that’s why you’ll see v-card, v-file-input and other stuff.

Vuetify provides a lot of ready to use UI components, that allows you to create beautiful UIs in no time at all.

<template lang="pug">
v-card(max-width="520" raised)
  v-card-title
    v-icon(color="secondary" left) mdi-account-box
    span Avatar cropper

  v-card-text
    v-file-input(
      v-model="selectedFile"
      accept="image/png, image/jpeg"
      label="File"
      placeholder="Select a file"
      :show-size="1024"
      @change="setupCropper"
    ).my-4

    v-row(v-if="objectUrl")
      v-col(cols="12" sm="6").text-center
        .overline Original
        .image-container.elevation-4
          img.image-preview(ref="source" :src="objectUrl")

        .d-flex.justify-center
          v-btn(icon small @click="resetCropper")
            v-icon(small) mdi-aspect-ratio

          .mx-2
          v-btn(icon small @click="rotateLeft")
            v-icon(small) mdi-rotate-left

          v-btn(icon small @click="rotateRight")
            v-icon(small) mdi-rotate-right

      v-col(cols="12" sm="6").text-center
        .overline Preview
        .image-container.elevation-4
          img.image-preview(:src="previewCropped")

  v-card-actions
    v-spacer
    v-btn(color="primary" :disabled="!objectUrl")
      v-icon(left) mdi-send
      span Submit
</template>


<script>
import Cropper from 'cropperjs'
import debounce from 'lodash/debounce'

export default {
  data () {
    return {
      cropper: null,
      objectUrl: null,
      previewCropped: null,
      selectedFile: null,
      debouncedUpdatePreview: debounce(this.updatePreview, 257)
    }
  },

  methods: {
    resetCropper () {
      this.cropper.reset()
    },
    rotateLeft () {
      this.cropper.rotate(-90)
    },
    rotateRight () {
      this.cropper.rotate(90)
    },
    setupCropper (selectedFile) {
      if (this.cropper) {
        this.cropper.destroy()
      }

      if (this.objectUrl) {
        window.URL.revokeObjectURL(this.objectUrl)
      }

      if (!selectedFile) {
        this.cropper = null
        this.objectUrl = null
        this.previewCropped = null
        return
      }

      this.objectUrl = window.URL.createObjectURL(selectedFile)
      this.$nextTick(this.setupCropperInstance)
    },
    setupCropperInstance () {
      this.cropper = new Cropper(this.$refs.source, {
        aspectRatio: 1,
        crop: this.debouncedUpdatePreview
      })
    },
    updatePreview (event) {
      const canvas = this.cropper.getCroppedCanvas()
      this.previewCropped = canvas.toDataURL('image/png')
    }
  }
}
</script>


<style lang="sass" scoped>
.image-container
  display: inline-block

.image-preview
  display: block
  max-height: 229px
  max-width: 100%
</style>

<style lang="sass">
@import 'cropperjs/dist/cropper.css'
</style>

Basic techniques

So, what’s involved in writing something like this Avatar cropper component?

Glad you asked. It contains all the pieces you’d need to:

  • Manipulate a user selected local file —no upload required
  • A technique to auto preview the result of your crop box
  • Put the result into a FormData object that you can use with —say— Axios to make a POST request to upload the file to an API endpoint somewhere. Just like you’d do with a simple HTML form using an <input type="file">.

Let’s start by talking about wrapper components.

Vue.js wrapper component

This one is easy. It’s what you do when you want to integrate third party libraries with Vue.js.

It usually involves creating a normal Vue.js component that controls the plugin / library-object instantiation from within.

Sometimes you’ll do it on mounted(), other times you’ll do it from other places. In this example, it’s done on the @change handler for the file input.

If you want to browse another wrapping example —from the official docs— check this out.

If you want to read more about wrapping —not exactly the same type we are doing here, but interesting nonetheless— this one is a good read.

Displaying images using object URLs

There are several ways to display an image that’s been selected through an <input type="file">, but I think the easiest is to simply use object URLs.

For this component that’s done on the @change event handler that’s being called with the selected file as a parameter:

v-file-input(@change="setupCropper")

Just call createObjectURL with the file that’s been passed down:

setupCropper (selectedFile) {
  // ...
  this.objectUrl = window.URL.createObjectURL(selectedFile)
}

This allows us to use this.objectUrl as the :src attribute of an image element, like this:

img.image-preview(ref="source" :src="objectUrl")

With this in place we can have a live preview of the image file the user selected from his computer.

Setup Cropper.js

There surely are many ways to go about this. For me, the easiest way here was to create and destroy a Cropper.js instance everytime the file input changed.

I think it’s important to mention that the code for creating the Cropper.js instance is inside a $nextTick call.

The reason for this is that the reference that we need —this.$refs.source— is inside some code that’s guarded by a v-if:

v-row(v-if="objectUrl")
  v-col(cols="12" sm="6").text-center
    .overline Original
    .image-container.elevation-4
      img.image-preview(ref="source" :src="objectUrl")
setupCropper (selectedFile) {
  // ...
  this.objectUrl = window.URL.createObjectURL(selectedFile)
  this.$nextTick(this.setupCropperInstance)
},

setupCropperInstance () {
  this.cropper = new Cropper(this.$refs.source, {
    aspectRatio: 1,
    crop: this.debouncedUpdatePreview
  })
}

So if we don’t delay it, the call to this.$refs.source would be undefined.

Display canvas content inside an img element

Once we have a hold of the Cropper.js instance —it’s in this.cropper— we can get a canvas reference that contains whatever image information the cropbox is surrounding with this:

const canvas = this.cropper.getCroppedCanvas()
this.previewCropped = canvas.toDataURL('image/png')

Then we can use previewCropped as the :src of another image element, to get an updated live preview whenever we move or resize the cropbox.

img.image-preview(:src="previewCropped")

Debounce preview updates

Cropper.js emits an event whenever you tweak the cropbox, and it can emit a really big number of events in a very short amount of time.

So, to avoid having meaningless updates firing very quickly, we will debounce the call to updatePreview by 257 ms:

import debounce from 'lodash/debounce'

export default {
  data () {
    return {
      // ...
      debouncedUpdatePreview: debounce(this.updatePreview, 257)

  // ...
  methods: {
    // ...
    setupCropperInstance () {
      this.cropper = new Cropper(this.$refs.source, {
        aspectRatio: 1,
        // this fires whenever you tweak the cropbox
        crop: this.debouncedUpdatePreview
      })
    },

    updatePreview (event) {
      const canvas = this.cropper.getCroppedCanvas()
      this.previewCropped = canvas.toDataURL('image/png')
    }

Debounce and throttle is a pair of very useful functions for controlling the number of times you call a function over time.

Grasp how they work, and they’ll serve you well.

Preparing for upload

The Submit button above does nothing, it’s just for show. But it could very well prepare the image data to be used by Axios in a POST request for file uploading.

Basically, what you need to do is create a new FormData object and assign to it what’s inside this.cropper.getCroppedCanvas().

That function returns a canvas element and we can get its content as a blob using canvas.toBlob() then use that on the FormData object.

For example, something like this could work:

submitFormData () {
  const canvas = this.cropper.getCroppedCanvas()

  canvas.toBlob((blob) => {
    const formData = new FormData()

    formData.append('my-avatar-file', blob, 'avatar.png')

    this.$axios.post('/api/files', formData, {
      headers: {
        'Content-Type': 'multipart/form-data'
      }
    })
  })
}

I lied, the submit button, indeed do something.
If you open the browser console, and submit a file, you’ll see there the content of formData. :)

I swapped this.$axios.post() for:

console.log('formData', formData.entries().next())

Go, take a look!

Beyond this example

Cropper.js has rich and powerful API, here we only implemented a pretty small set of operations, namely reset() and rotate().

There is a lot more for you to explore and to play with.

Good luck & have fun!

References