Creating a small Stimulus.js app to handle Markdown Images

The problem.

I take a lot of photo’s on each trip I take and collect them on my blog . Each of these photo’s is inserted into a markdown file and after a dozen or more images the markdown becomes unreadable.

I upload the images to S3 with CyberDuck and from there AWS Lambda takes care of image resizing. This way I can drop an entire folder of images onto CyberDuck and everything is taken care of.

Well.. everything but inserting the images into the markdown file. I did have a script for that, but usually I like to arrange images in a different order, or group them together. This is really tedious with 50 images, as I have to refresh the page each time I move an image.

The solution.

What I want is a place where I can see all the images I can use and get the correct markdown tag to insert into my post.

I can build a 200MB Electron App with React, but we can do the job a lot easier with plain HTML and Stimulus.js . Their handbook already describes a good part of what I want, something that can set my clipboard to a markdown tag.

Dropzone.

The “app” will consist of a “drop zone” where I can drop images and an input where I can set the base url for the markdown tag.

The HTMl looks something like this:

<div
  data-controller="dropzone"
  class="dropzone"
  data-action="dragover->dropzone#acceptDrop drop->dropzone#handleDrop dragleave->dropzone#leaveDrop"
>
  <header>
    Base url: <input data-target="dropzone.baseUrl" type="text">
  </header>

  <ul data-target="dropzone.polaroidList" class="polaroid-list"></ul>
</div>

We have a Stimulus controller called “dropzone” that listens to three DOM events, dragOver, drop and dragLeave. It maps these to three Javascript functions in the Stimulus controller.

For example dragover->dropzone#acceptDrop means that Stimulus will call acceptDrop on the dropzone controller when dragOver is triggered.

To get a working URL to the image we need a base url that will prepend the image name, for this we use an input. To be able to read the input from Stimulus, we have to make it available. We do this by setting it as a target with data-target="dropzone.baseUrl".

We also need a place to render the dropped image, for this we also need a target, in this case it’s the UL with data-target="dropzone.polaroidList".

The Stimulus controller.

The Stimulus controller looks like this:

application.register("dropzone", class extends Stimulus.Controller {
  static get targets() {
    return ["polaroidList", "baseUrl"]
  }
  acceptDrop(ev) {
    ev.preventDefault();
    this.element.classList.add("dropzone--dropping");
  }
  leaveDrop(ev) {
    ev.preventDefault();
    this.element.classList.remove("dropzone--dropping");
  }

  handleDrop(ev) {
	  ev.preventDefault();
    /* [...] */
  }

  addImage(file) {
    /* [...] */
  }

  renderImage(file, event) {
    /* [...] */
  }
})

We can see the three bound functions, acceptDrop that adds a class to our element, so we can style it accordingly to let the user know that the files can be dropped. leaveDrop to remove the styling when the user decides to not drop the images. Finally handleDrop that will proces the dropped files to render images.

There are two other functions that we’ll need to render the image.

Drop the image.

Let’s start with handling the files that have been dropped, we need to get the data from the file, so we can render an image.

Mozilla’s MDN tells me this is the way to do it, so we’ll use that.

handleDrop(ev) {
  ev.preventDefault()
  this.element.classList.remove("dropzone--dropping");

  if (ev.dataTransfer.items) {
    // Use DataTransferItemList interface to access the file(s)
    for (var i = 0; i < ev.dataTransfer.items.length; i++) {
      // If dropped items aren't files, reject them
      if (ev.dataTransfer.items[i].kind === 'file') {
        var file = ev.dataTransfer.items[i].getAsFile();
        this.addImage(file)
      }
    }
  } else {
    // Use DataTransfer interface to access the file(s)
    for (var i = 0; i < ev.dataTransfer.files.length; i++) {
      this.addImage(ev.dataTransfer.files[i]);
    }
  }
}

We start by removing the dropzone—dropping class from the element, so it no longer tells the user it’s ok to drop files (as that’s already been done).

Then we check what kind of drop event we have, depending on the browser we need to handle the event differently. Both ways result in the addImage method being called with the dropped file, let’s see what it does.

Load the image.

Now that we have the raw data from the dropped file, we need to convert it into an Image, as we can’t display the raw data by itself. This is where FileReader comes in. It’s a way to read raw data and return a data: url that we can use in the image’s src tag. You can read more about it here

The FileReader works with a callback, and our function looks like this:

addImage(file) {
  var reader = new FileReader();
  reader.onload = (event =>  this.renderImage(file, event) );
  reader.readAsDataURL(file);
}

We create a new FileReader and set the onload event to call renderImage in our controller. We then pass in the raw data so it can load the data.

Render the image.

Now that we have a data: url that works with the image’s src attribute, we need some place to render it.

We can use a lot of document.createElement calls to eventually get the structure we need, but modern browsers have a much nicer way to accomplish this called templates .

Ours looks like this:

<template id="polaroid-template">
  <li data-controller="polaroid" class="polaroid">
    <img src="" data-target="polaroid.image" />
    <span class="polaroid-copied" data-target="polaroid.copied">
      Copied!
    </span>

    <div class="copy-elements">
      <input data-target="polaroid.source" type="text" value="" readonly />
      <a href="#" data-action="polaroid#copy" class="polaroid-button">
       Copy
      </a>
    </div>
  </li>
</template>

Because we use the