Creating a Cloudinary Image Uploader With Crop Support in Vue
I came across cloudinary about a year plus ago and since then it has been my primary image delivery network for most of my personal projects as it provides a very extensive free plan besides having fair paid plans. Another plus for me is that it's easy to set up and use.
Since I've been using it for some time and given that I work on lots of Vue projects, I decided to create a vue component that takes care of that for me. In it I've added a cropping feature that you can apply before uploading the image to cloudinary.
On completion the expectation of how the component will function is as demonstrated below.
I have also created a similar Vue component that works with laravel that I will also be writing about.
Before proceeding, you first need to create an account on cloudinary.
So let's create our uploader.
Setting up cloudinary
Log into the cloudinary account and create an upload preset that will be handling the images being uploaded. Before setting up an upload preset, first create a media library folder that will hold the images being uploaded. Go to the Media Library
Create a new folder
Then proceed with creating the upload preset. Go to settings > upload (tab)
Scroll down to and click on add upload preset.
Set the Upload preset name, select the unsigned signing mode and fill in the name of the folder that you created above. Note: Unsigned upload presets are used when implementing upload capabilities from client-side apps.
Go to Upload control and switch the return delete token to on if you want to be able to delete uploaded images.
Note: You can only use the delete token to delete an uploaded file within a span of ten minutes after uploading it.
Set up the rest of the settings to your preferences and click on 'save' to save this new upload preset.
Back on the settings page and upload tab the new upload preset will be listed amongst the existing presets
The Vue Component
Onto creating the Vue component that will carry out uploading images to cloudinary throught the created upload preset.
The Template
<template>
<div id="vue-cloudinary-uploader">
<input type="hidden" :value="uploadedImageData.secureUrl" :name="inputName">
<button v-if="uploadedImageData.secureUrl" class="vcu-button button-danger" type="button" @click="deleteImageFromCloud()">Change Image</button>
<button id="uploader-button" class="vcu-button button-info" type="button" v-else @click.prevent="showModal()" :disabled="processingUpload || modelVisible">Select Image File</button>
<div id="modal-wrapper" v-show="modelVisible">
<div class="image-cropper">
<div class="editor">
<div class="input">
<div>
<input type="file" ref="photo" accept="image/*" @change="addLocalImage()" id="vcu-file-input">
</div>
</div>
<div v-if="showUploadProgress" class="vcu-progress-wrapper">
<div class="vcu-progress" :style="'width: ' + uploadProgress + '%'"></div>
</div>
<div class="img">
<img ref="working_image" id="image" :src="localFileDataUrl">
</div>
</div>
<div class="options">
<div>
<button class="vcu-button button-danger" type="button" @click="destroyUploaderInstance(true)" :disabled="processingUpload">CANCEL</button>
</div>
<div>
<button class="vcu-button button-info" type="button" @click="getCroppedCanvas()" :disabled="processingUpload">CROP</button>
</div>
</div>
</div>
</div>
</div>
</template>
The above template contains the buttons that trigger the launching of the uploader modal, within this modal the cropper container that offers image cropping capabilities is placed.
The Javascript
Install cropperjs which will be used to crop the images and axios that will be the http client used to upload the images.
#install cropperjs
npm i cropperjs
#install axios
npm i axios
Next is a brekdown of the js code.
import axios from 'axios'
import Cropper from 'cropperjs'
import 'cropperjs/dist/cropper.css'
export default {
name: "CloudinaryVueUploader",
data(){
return {
showFileSelector: true,
showImageCropper: false,
showUploadProgress: false,
modelVisible: false,
processingUpload: false, // this will be true when image is being uploaded to prevent any other upload request
cropperInstance: null,
uploadProgress: 0,
localFileDataUrl: '',
cloudinaryUploadUrl: '',
cloudinaryDeleteUrl: '',
uploadedImageData: {
deleteToken: '',
publicId: '',
secureUrl: ''
}
}
},
Initiate the variables to use within the component.
props: {
CloudinaryCloudName: {
type: String,
default: 'CLOUDINARY_CLOUD_NAME',
validator: (x) => x !== ''
},
cloudinaryUploadPreset: {
type: String,
default: 'CLOUDINARYY_UPLOAD_PRESET_NAME',
validator: (x) => x !== ''
},
aspectRatio: {
type: Number,
default: 0
},
inputName: {
type: String,
default: 'imageToUpload'
},
},
Since this will be a Vue component, the above props will simplify passing in the CloudinaryCloudName and cloudinaryUploadPreset variables data. The CloudinaryCloudName is the first item under Account Details on cloudinary's dashboard.
mounted(){
this.cloudinaryUploadUrl = `https://api.cloudinary.com/v1_1/${this.CloudinaryCloudName}/upload`
this.cloudinaryDeleteUrl = `https://api.cloudinary.com/v1_1/${this.CloudinaryCloudName}/delete_by_token`
},
Populate the cloudinaryUploadUrl and cloudinaryDeleteUrl variables when the compoonent is mounted.
methods: {
showModal(){
this.modelVisible = true
},
hideModal(){
this.modelVisible = false
},
These two methods will be responsible with the toggling of the component's modal.
editImage(){
this.showImageCropper = true
if(this.cropperInstance){
this.cropperInstance.destroy()
this.showImageCropper = false
}
this.$nextTick(() => {
this.cropperInstance = new Cropper(this.$refs.working_image, {
aspectRatio: this.aspectRatio,
viewMode: 2,
background: false,
crop(event) {},
ready(){
this.showImageCropper = true
}
});
})
},
editImage(): Initiates cropper instance on the selected image.
async addLocalImage(){
if(this.$refs.photo.files.length < 1){
console.log('No photo selected')
return false
}
let photo = this.$refs.photo.files[0];
this.localFileDataUrl = window.URL.createObjectURL(photo)
this.$nextTick(this.editImage())
},
addLocalImage(): Calls editImage() when an image has been selected.
async getCroppedCanvas(){
if(!this.cropperInstance){
alert("Select Image File!")
return false
}
if(!this.cropperInstance.getCroppedCanvas()){
alert("No Image Detected!")
return false
}
if(this.processingUpload){ // don't initiate another upload while one is running
alert("Previous upload not completed!")
return false
}
let canvas = this.cropperInstance.getCroppedCanvas()
await canvas.toBlob( (blob) => {
let formData = new FormData()
formData.append('file', blob)
formData.append('upload_preset', this.cloudinaryUploadPreset)
this.uploadImageToCloud(formData)
})
},
getCroppedCanvas(): Get's cropper's canvas and passes that data to uploadImageToCloud().
destroyUploaderInstance(closeCropper = false){
// destroy cropper instance
if(this.cropperInstance && closeCropper){
this.cropperInstance.destroy()
}
// set all other variables to their defaults
this.cropperInstance = null
this.localFileDataUrl = ''
this.processingUpload = false
this.showFileSelector = true
this.showImageCropper = false
this.showUploadProgress = false
this.uploadProgress = 0
document.getElementById("vcu-file-input").value = "";
this.uploadedImageData = { deleteToken: '', publicId: '', secureUrl: '' }
this.$emit('uploaderDestroyed', "" )
if(closeCropper){
this.hideModal()
}
},
destroyUploaderInstance(): Resets the component's variables.
uploadImageToCloud(formData){
this.showUploadProgress = true
this.processingUpload = true
this.uploadProgress = 0
axios.post(this.cloudinaryUploadUrl, formData, {
onUploadProgress: (progressEvent) => {
this.uploadProgress = progressEvent.lengthComputable ? Math.round( (progressEvent.loaded * 100) / progressEvent.total ) : 0 ;
}
})
.then( (response) => {
this.uploadedImageData = {
secureUrl: response.data.secure_url,
deleteToken: response.data.delete_token,
publicId: response.data.public_id
}
this.showUploadProgress = false
this.processingUpload = false
this.$emit('imageUrl', response.data.secure_url )
this.hideModal()
})
.catch( (error) => {
if(error.response){
console.log(error.message)
}else{
console.log(error)
}
this.showUploadProgress = false
this.processingUpload = false
})
},
uploadImageToCloud(): This method uploads the modified image and obtains the uploade image url, delete token and public id from cloudinary.
deleteImageFromCloud(){
if(this.uploadedImageData.deleteToken === ''){ // if delete token is not provided
console.log("uploadedImageData ", this.uploadedImageData)
alert("No Delete token")
}
let formData = new FormData()
formData.append('token', this.uploadedImageData.deleteToken)
axios.post(this.cloudinaryDeleteUrl, formData)
.then(response => {
if(this.cropperInstance){
this.cropperInstance.destroy()
}
this.destroyUploaderInstance()
this.showModal()
this.$emit('remoteImageDeleted')
})
.catch(error=>{
console.log(error)
return false
})
}
}
}
deleteImageFromCloud(): This method uses the deleteToken obtained on uploding the image to delete the previously uploaded image. Note: Upload deletes by the deleteToken can only be done no more than ten minutes after the image has been uploaded.
The Style
After, add the following style or modify it to your liking at the end of the component.
:root{
--default-font-family: Arial, Helvetica, sans-serif;
--default-font-weight-small: 300;
--default-font-weight-medium: 600;
--default-font-weight-heavy: 900;
--default-space-x-small: 5px;
--default-space-small: 10px;
--default-space-medium: 15px;
--default-space-large: 25px;
--color-primary: rgb(233,233,239);
--color-secondary: rgb(248,248,250);
--color-tertiary: rgb(255,255,255);
--color-danger: rgb(220, 20, 60);
--color-danger-dark: rgb(200, 20, 60);
--color-danger-light: rgb(240, 20, 60);
--color-info: rgb(13, 125, 216);
--color-info-dark: rgb(10, 94, 196);
--color-info-light: rgb(10, 94, 236);
--color-success: rgb(16, 190, 10);
--color-text-button: aliceblue;
}
* {
font-family: var(--default-font-family);
}
.vcu-progress-wrapper{
height: 30px;
width: 100%;
padding: 0;
}
.vcu-progress{
margin: 0;
height: inherit;
background: var(--color-success)
}
/*button styles*/
.vcu-button{
position: relative;
background: var(--color-primary);
border: none;
border-radius: 2px;
color: var(--color-text-button);
font-weight: 500;
padding: var(--default-space-small);
margin-left: 5px;
cursor: pointer;
}
.vcu-button:disabled{
background: var(--color-primary) !important;
color: black !important;
}
.close-button{
position: absolute;
top: 0; right: 0;
margin: var(--default-space-x-small);
padding: var(--default-space-small);
font-weight: var(--default-font-weight-medium);
cursor: pointer;
z-index: 5;
}
.close-button:hover{
background-color: var(--color-danger-light);
}
.close-button:active{
background-color: var(--color-danger-dark);
}
.button-danger{
background-color: var(--color-danger);
}
.button-danger:hover{
background-color: var(--color-danger-light);
}
.button-danger:active{
background-color: var(--color-danger-dark);
}
.button-info{
background-color: var(--color-info);
}
.button-info:hover{
background-color: var(--color-info-light);
}
.button-info:active{
background-color: var(--color-info-dark);
}
#modal-wrapper{
position: fixed;
top: 10px; bottom: 10px;
left: 100px; right: 100px;
border: 1px solid var(--color-primary);
margin: var(--default-space-small);
z-index: 99999;
display: flex;
box-shadow: 0 0 5px 0 #b1b0b0;
}
.image-cropper{
flex-grow: 1;
/* padding: var(--default-space-small); */
display: flex;
flex-direction: column;
background-color: var(--color-tertiary);
max-height: inherit;
max-width: inherit;
}
.image-cropper > .editor{
flex-grow: 1;
display: flex;
flex-direction: column;
background-color: var(--color-tertiary);
padding: var(--default-space-small);
max-height: inherit;
max-width: inherit;
overflow: hidden;
}
.image-cropper > .editor > .input{
height: 50px;
display: flex;
flex-direction: row;
align-items: flex-start;
}
.image-cropper > .editor > .input{
height: 50px;
display: flex;
flex-direction: row;
justify-content: center;
}
.image-cropper > .editor > div > .input > input, .image-cropper > .editor > .input > button{
min-height: 20px;
font-size: 16px;
margin-left: var(--default-space-small);
}
input[type="file"]{
position: relative !important;
top: 1% !important;
z-index: 1 !important;
width: initial !important;
height: initial !important;
-webkit-appearance: initial !important;
opacity: 1 !important;
cursor: pointer !important;
}
.image-cropper > .editor > .img{
position: relative;
max-height: -webkit-fill-available;
padding: var(--default-space-small);
flex-grow: 1;
background: var(--color-tertiary);
min-height: 20px;
margin-bottom: 20px;
}
.image-cropper > .options{
display: flex;
flex-direction: row;
justify-items: center;
justify-content: flex-end;
height: 50px;
background-color: var(--color-secondary);
padding: var(--default-space-x-small);
border-top: 1px solid var(--color-primary);
}
.image-cropper > .options > div{
display: flex;
justify-content: center;
align-items: center;
width: 100px;
padding: var(--default-space-small);
margin-left: var(--default-space-x-small);
cursor: pointer;
font-weight: var(--default-font-weight-small);
}
@media screen and (orientation: portrait){
#modal-wrapper{
left: 20px; right: 20px;
}
.image-cropper > .options{
height: 10vmin;
}
}
img {
max-width: 100%;
}
/*model styes*/
.show{
display: flex !important
}
.hide{
display: none !important
}
Getting The Uploaded Image URL
There are two ways to get the image url of the uploaded image from this component. The first is by listening to the imageUrl event on the component and the second is by passing a inputName prop which will populate the hidden input's name attribute within the component. The later is the favorable way to use this component outside the Vue environment such as inside a blade form.
Now go out there and wreck the internet.