© Bryan Lim and Richard LaFranchi 2019
B. Lim, R. LaFranchiVue on Railshttps://doi.org/10.1007/978-1-4842-5116-4_6

6. Building an Image-Cropping Tool with Vue and Active Storage

Bryan Lim1  and Richard LaFranchi2
(1)
Singapore, Singapore
(2)
Boulder, CO, USA
 

You want to enjoy life, don't you? If you get your job done quickly and your job is fun, that's good isn't it? That's the purpose of life, partly. Your life is better.

—Yukihiro Matsumoto

As developers, we often look to third-party libraries for solutions to problems that aren’t easily solved or problems that we are not familiar with. This can be advantageous if we need to build a quick prototype, but there are downsides to this approach such as:
  • Needing to rely on support for the library.

  • Implementing with technologies such as Vue and Rails may not be straightforward.

  • Customization can sometimes be difficult.

  • Bloat of a library may include unneeded/unwanted features or behavior.

Support is typically not an issue with libraries like Vue and Ruby on Rails because of the overwhelming community support, but it can be an issue with many JavaScript libraries. One feature common in web apps is an image-cropping tool, which there are certainly a few libraries out there that accomplish this. In this tutorial, we will roll our own image cropper using Vue and integrate it with direct uploads and the latest feature in Rails as of version 5.2 which is Active Storage. The biggest advantage of rolling your own is the learning experience, and that’s what we will do in this chapter.

The Avatar

The avatar is one of the most common features in social media and other types of web applications. Often you would need to prepare a good image for yourself ahead of time because it is common for applications to automatically crop your avatar. This tutorial will show you how to build an interactive cropper with two basic goals in mind. It will accept any size image and allow you to pan and scale the image so that the area that is cropped is easily customized for the user.

It can be accomplished by supporting an original size uploaded image, allowing the user to pan the image to the appropriate location and a range slider to allow them to scale the image appropriately. Once these bounds are defined, the original image and bounds can be saved and processed as a variant as supported by Active Storage.

Active storage help and dependencies

This tutorial requires the web_processing ruby gem and activestorage npm package to be installed. More info and documentation about Active Storage can be found at https://edgeguides.rubyonrails.org/active_storage_overview.html

The User Profile

The demo application will consist of a simple profile edit page shown at the edit action for Users (see Figure 6-1). An erb form will be used to demonstrate how we can build a Vue cropper component and embed the cropper within the form so the appropriate form fields are applied. The User model consists of three fields – name, avatar, and avatar_crop. The avatar field will be the reference to the original size image, and avatar_crop is a string of the cropped geometry in the format of widthxheight+xoffset+yoffset which we will demonstrate how to generate this in the Vue cropper component and how to use the active storage variant helper to display it later in this chapter .
class User < ApplicationRecord
  has_one_attached :avatar
end
../images/465247_1_En_6_Chapter/465247_1_En_6_Fig1_HTML.jpg
Figure 6-1

The Edit Page for the User Profile

The form for the user will use Rails helpers to demonstrate how we can continue to use erb and spice things up a bit with some fancy Vue components. Inside the form is an element with a cropper id which we will mount the cropper Vue component to. Another important field to note is the active storage field helper, which is a file field with direct_upload: true option. All this option does is add a data-direct-upload-url attribute to the field so we know where to submit the image file.
<h1>Profile</h1>
<%= form_for @user do |f| %>
  <div class="form-group">
    <%= f.label :name %>
    <%= f.text_field :name, class: 'form-control' %>
  </div>
  <div class="form-group">
    <%= f.label :avatar %>
    <%= f.file_field :avatar, direct_upload: true, accept: "image/*" %>
    <div id="cropper"></div>
  </div>
  <%= f.submit class: 'btn btn-outline-dark' %>
<% end %>

Direct uploads

Direct uploads is a feature supported by Active Storage that allows you to save some server resources by having image and file uploads go directly from a user’s browser to a cloud object storage service such as Amazon S3.

Vue Cropper Component

The cropper component will use the DirectUpload module provided by the activestorage npm package to upload the image directly when a file is added to the input. Just like we used SVG in the Tic Tac Toe tutorial, we will also use an SVG to display a simple square that shows the area of the image to be cropped along with a few elements to show some padding with some slight transparency. <rect> SVG elements are a simple way to display such shapes and note that they take x,y,width, and height attributes where x and y are offsets from the top-left corner of an SVG. The SVG will be a square 400 x 400 with the cropped area being 300 x 300 with an x and y offset of 50 to center the cropped area (see Figure 6-2).
../images/465247_1_En_6_Chapter/465247_1_En_6_Fig2_HTML.jpg
Figure 6-2

Bunny Cropper (Photo source: Pixabay, used under the Pixabay License1)

The dimensions are defined as data attributes on the component but will remain static for the purposes of this demo. In theory, it could also support resizing the cropping area, but that is outside the scope of this tutorial. It is important to note the data attributes defined on the component as seen in the following code and found in app/javascript/parts/cropper/cropper.js.
data: function() {
  return {
    directUploadUrl: null, // url used in direct uploads
    fileField: null, // the original active storage file input
    file: null, // the file object loaded
    blobSrc: null, // the image data for displaying the image
    blobSignedId: null, // id returned from direct upload
    name: null, // User's Name
    x: 50, // x offset for cropping area
    y: 50, // y offset for cropping area
    width: 300, // width of the cropping area
    height: 300, // height of the cropping area
    image_width: null, // actual width of the loaded image
    image_height: null, // actual height of the loaded image
    image_x: 0, // x offset of the image updated on panning
    image_y: 0, // y offset of the image updated on panning
    scale: 1, // image scale – adjusted to fit when loaded
    dragging: false // true when actively panning
  }
}
The complete Vue Cropper template found in app/javascript/parts/cropper/cropper.vue is shown in the following code. We will go into more detail about each element in this chapter and how we use Vue to make the elements interactive.
<template>
  <div id="cropper" v-if="blobSrc && image_width && image_height">
    <div class="form-group">
      <label for="imageScale">Image Scale</label>
      <input
        type="range"
        min="10"
        max="100"
        v-model="imageScale"
        name="imageScale"
        id="imageScale">
      <p class="text-muted">{{ this.imageScale }}% size of original image</p>
    </div>
    <div class="dropper">
      <svg
        width="400"
        height="400"
        @mousemove="pan($event)"
        @scroll="zoomImage($event)">
        <image
          :xlink:href="blobSrc"
          :x="image_x"
          :y="image_y"
          :width="scaledWidth"
          :height="scaledHeight">
        </image>
        <!-- BEGIN elements for showing darker background outside cropped area -->
        <rect
          x="0"
          y="0"
          :width="x"
          height="400"
          fill="#000000"
          fill-opacity="0.5"/>
        <rect
          :x="x"
          y="0"
          :width="width"
          :height="y"
          fill="#000000" fill-opacity="0.5"/>
        <rect
          :x="x"
          :y="y + height"
          :width="width"
          :height="400 - y - height"
          fill="#000000"
          fill-opacity="0.5"/>
        <rect
          :x="x + width"
          y="0"
          :width="400 - x - width"
          height="400"
          fill="#000000"
          fill-opacity="0.5"/>
        <!-- END -->
        <!-- allows panning image -->
        <rect
          :x="x"
          :y="y"
          :width="width"
          :height="height"
          fill="#FFFFFF"
          fill-opacity="0"
          :class="dragging ? 'grabbing' : 'grab'"
          @mousemove="pan($event)"
          @mousedown="dragging = true"
          @mouseup="dragging = false"
          @mouseleave="dragging = false"/>
      </svg>
    </div>
    <!-- hidden fields to reference direct upload image and area to crop-->
    <input type="hidden" name="user[avatar]" :value="blobSignedId">
    <input type="hidden" name="user[avatar_crop]" :value="croppedGeometry">
  </div>
</template>

Loading the Image

In the mounted() lifecycle hook of the Cropper component, we search for the avatar file input and listen for changes. When a file is added, we call the fileAdded() function defined in methods in the component as seen in the following code. The function first grabs the file from the event, creates a new upload using the DirectUpload module, loads the image source, sets the image height and width, sets the scale of the image to fit the SVG size, sets the blobSrc attribute to the data of the image, sets the blobSignedId to that of the response of the direct upload, and then removes the input field. Once a file is loaded, we see the cropping tool along with a range slider for scaling the image as seen in Figure 6-3.
../images/465247_1_En_6_Chapter/465247_1_En_6_Fig3_HTML.jpg
Figure 6-3

The Range Slider for scaling images

// looking for the file input field when the component is mounted
mounted: function() {
  this.fileField = document.querySelector('input[type="file"]')
  this.directUploadUrl = this.fileField.dataset.directUploadUrl
  this.name = this.fileField.name
  this.fileField.onchange = this.fileAdded
}
// Method called when file is loaded into the input.
fileAdded(event) {
  const vm = this
  this.file = event.target.files[0]
  if (this.file) {
    const upload = new DirectUpload(this.file, this.directUploadUrl)
    upload.create((error, blob) => {
      if (error) {
        console.error(error)
        alert(error.toString())
      } else {
        console.debug(blob)
        const image = new Image()
        image.onload = function() {
          vm.image_width = image.width
          vm.image_height = image.height
          if (vm.image_height > 400) {
            vm.scale = 400 / vm.image_height
          }
        }
        image.src = URL.createObjectURL(this.file)
        vm.blobSrc = image.src
        vm.blobSignedId = blob.signed_id
        vm.fileField.value = null
        vm.fileField.name = null
        vm.fileField.remove()
      }
    })
  }
}

Panning the Image

Panning the image requires a bit of CSS to show the grab and grabbing cursor along with a few mouse events. We set the dragging attribute to true on @mousedown and set it to false on @mouseup or @mouseleave. The @mouseleave event is necessary so that there isn’t unexpected behavior if the mouse is held and moved outside the cropping area. The @mousemove event calls the pan() function and simply updates the image_x and image_y fields based on the movement of the mouse as seen in in the following code.
<!-- The cropping area SVG element that supports panning -->
<rect
  :x="x"
  :y="y"
  :width="width"
  :height="height"
  fill="#FFFFFF"
  fill-opacity="0"
  :class="dragging ? 'grabbing' : 'grab'"
  @mousemove="pan($event)"
  @mousedown="dragging = true"
  @mouseup="dragging = false"
  @mouseleave="dragging = false"/>
The pan() method simply updates the x and y attributes for the image as shown in the following code.
// app/javascript/parts/cropper.js
pan(evt) {
  if (this.dragging) {
    this.image_x += evt.movementX
    this.image_y += evt.movementY
  }
}

Scaling the Image

When the image is loaded into the Cropper, we scaled it down if needed to fit the bounds of the 400 x 400 SVG. We also allow the tool to change the scale of the image using a range slider. The slider is based on percentage of the original size of the image, so we define a computed property to convert the scale attribute percentage to decimal and vice versa. The scale attribute is then used to show the correct size of the image in view. When the slider is moved back and forth, the image is then scaled appropriately. Figure 6-4 illustrates image scaling.
../images/465247_1_En_6_Chapter/465247_1_En_6_Fig4_HTML.jpg
Figure 6-4

Demonstration of scaling an image (Photo source: Pixabay)

The input field for the range slider and the computed property are shown in the following code:
<input
  type="range"
  min="10"
  max="100"
  v-model="imageScale"
  name="imageScale"
  id="imageScale">
// app/javascript/parts/cropper.js
imageScale: {
  get: function() {
    return parseInt(this.scale ∗ 100)
  },
  set: function(newVal, oldVal) {
    this.scale = newVal / 100
  }
}

ImageMagick Processing

We mentioned earlier that we would use the avatar_crop attribute to store the image geometry we want to use. To get it in the correct format, we can define it as a computed property and bind it to a value on a hidden input field. When the form is submitted, the dimensions are saved, and we can apply the geometry as a variant when displaying the avatar. The computed method, input field, and avatar link can be seen in the following code.

The croppedGeometry() computed property returns a string in the proper format used by ImageMagick.
croppedGeometry: function() {
  const scaledWidth = parseInt(this.width / this.scale)
  const scaledHeight = parseInt(this.height / this.scale)
  const scaledXOffset = this.x - parseInt(this.image_x / this.scale)
  const scaledYOffset = this.y - parseInt(this.image_y / this.scale)
  return `${scaledWidth}x${scaledHeight}+${scaledXOffset}+${scaledYOffset}`
}
Next, we see the hidden input field for the image geometry which binds the previous computed property to its value attribute .
<input
  type="hidden"
  name="user[avatar_crop]"
  :value="croppedGeometry">
Finally, we can display the avatar at various sizes using the saved geometry. An example is shown in Figure 6-5.
<%= image_tag(@user.avatar.variant(crop: @user.avatar_crop, resize: '100x100')) %>
../images/465247_1_En_6_Chapter/465247_1_En_6_Fig5_HTML.jpg
Figure 6-5

Displaying a thumbnail of the upload profile picture (Photo source: Pixabay)

And that concludes the image-cropping tutorial. We can see how to achieve a very effective cropping tool with very little effort and avoid the frontend development emergency off ramp, and it was a lot of fun in the process.

Wrap-up and the Next Step

That concludes our hands-on part of the book. We find it best to learn by example and hope that these tutorials provided a lot of insight into what can be accomplished with Vue and Ruby on Rails. In the next chapter, we shift our gears to turbo mode for your Vue on Rails project with deployment, testing, and troubleshooting without losing your mind.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.17.68.14