We can't find the internet
Attempting to reconnect
Something went wrong!
Hang in there while we get back on track
2019 / 10 / 12
Crop images in Vue
Some techniques for cropping an image in the browser, preview it and upload it.
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!