DEV Community

Cover image for Creating a Cloudinary Image Uploader With Crop Support in Vue
James Sinkala
James Sinkala

Posted on • Edited on • Originally published at jamesinkala.com

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.
Component Demonstration

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
Go to media library

Create a new folder
Create a new folder

Then proceed with creating the upload preset.
Go to settings > upload (tab)
Settings > Upload

Scroll down to and click on add upload preset.
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.
Set upload preset name and signed type
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.
Switch delete token on

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
Presets list

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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode



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: ''
        }
      }
    },
Enter fullscreen mode Exit fullscreen mode

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'
      },
    },
Enter fullscreen mode Exit fullscreen mode

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`
    },
Enter fullscreen mode Exit fullscreen mode

Populate the cloudinaryUploadUrl and cloudinaryDeleteUrl variables when the compoonent is mounted.


    methods: {
      showModal(){
        this.modelVisible = true
      },
      hideModal(){
        this.modelVisible = false
      },
Enter fullscreen mode Exit fullscreen mode

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
          }
          });
        })
      },
Enter fullscreen mode Exit fullscreen mode

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())
      },
Enter fullscreen mode Exit fullscreen mode

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)
        })
      },
Enter fullscreen mode Exit fullscreen mode

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()
        }
      },
Enter fullscreen mode Exit fullscreen mode

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
        })
      },
Enter fullscreen mode Exit fullscreen mode

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
          })
      }
    }
  }
Enter fullscreen mode Exit fullscreen mode

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
  }
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)