Jeremy Smith on November 01, 2021

GitHub Issue-style File Uploader Using Stimulus and Active Storage

I love GitHub’s Markdown editor for Issues and PRs. I guess that’s a good thing, since I spend so much time writing in it.

editor.png

One of the things I love most is the ability to drag-and-drop or copy-and-paste files and have appropriate Markdown embedded automatically. I decided I would try to reproduce this functionality using Stimulus and Active Storage for the latest iteration of my blog.

I’ll share the final code here, then walk through the various considerations.

The Implementation

Here’s the post body textarea:

<%= form.text_area
  :body,
  rows: 20,
  data: {
    controller: "markdown-upload",
    markdown_upload_url_value: rails_direct_uploads_url,
    action: "drop->markdown-upload#dropUpload paste->markdown-upload#pasteUpload"
  } %>

And here’s the final Stimulus controller:

// markdown_upload_controller.js

import { Controller } from "@hotwired/stimulus";
import { DirectUpload } from "@rails/activestorage";

export default class extends Controller {
  static values = { url: String };

  dropUpload(event) {
    event.preventDefault();
    this.uploadFiles(event.dataTransfer.files);
  }

  pasteUpload(event) {
    if (!event.clipboardData.files.length) return;

    event.preventDefault();
    this.uploadFiles(event.clipboardData.files);
  }

  uploadFiles(files) {
    Array.from(files).forEach(file => this.uploadFile(file));
  }

  uploadFile(file) {
    const upload = new DirectUpload(file, this.urlValue);

    upload.create((error, blob) => {
      if (error) {
        console.log("Error");
      } else {
        const text = this.markdownLink(blob);
        const start = this.element.selectionStart;
        const end = this.element.selectionEnd;
        this.element.setRangeText(text, start, end);
      }
    });
  }

  markdownLink(blob) {
    const filename = blob.filename;
    const url = `/rails/active_storage/blobs/${blob.signed_id}/${filename}`;
    const prefix = (this.isImage(blob.content_type) ? '!' : '');

    return `${prefix}[${filename}](${url})\n`;
  }

  isImage(contentType) {
    return ["image/jpeg", "image/gif", "image/png"].includes(contentType);
  }
}

Wiring Up the Actions

To handle both paste and drop events, I needed two different action descriptors (drop->markdown-upload#dropUpload paste->markdown-upload#pasteUpload). Under ideal circumstances, different events that have the same essential behavior would use the same underlying Stimulus controller action. Also, the action naming conventions say not to repeat the event’s name, but I think it’s warranted in this case.

The interfaces used by the Drag and Drop API and Clipboard API are different. The drop event will have a dataTransfer object, and the paste event will have a clipboardData property.

It would have been possible to use a single upload action and check for files each way, but this reduces clarity and hides the fact that the behavior really is distinct:

this.uploadFiles((event.clipboardData || event.dataTransfer).files);

Also, the paste event should not prevent default behavior if pasting text and not files. That’s what this guard clause is doing in the pasteUpload action:

if (!event.clipboardData.files.length) return;

Using DirectUpload

Assuming you have Active Storage set up on your Rails app, you can use the DirectUpload class to handle your uploading. The Rails guides on Active Storage has a helpful section on this under Integrating with Libraries or Frameworks.

You need to provide the URL endpoint you are uploading to as an argument. In the Stimulus controller, this is passed in as a string value: markdown_upload_url_value: rails_direct_uploads_url using the Rails URL helper.

Building the Markdown Link

Upon successful upload, DirectUpload will return an object with a number of properties for the ActiveStorage::Blob that was created. Here’s an example:

{
  "id": 8,
  "key": "cu5gzucsvb63osmgw4rjiuvbsdmj",
  "filename": "Screen Shot 2021-10-09 at 11.29.55 PM.png",
  "content_type": "image/png",
  "metadata": {},
  "service_name": "local",
  "byte_size": 10370,
  "checksum": "7d25gMDCvbvu+QzsYSt7Yw==",
  "created_at": "2021-10-10T04:05:53.804Z",
  "signed_id": "eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBEUT09IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--6d5115aec4ebd3ab69e6f2da5ee37bdfb59d59e7",
  "attachable_sgid": "BAh7CEkiCGdpZAY6BkVUSSIxZ2lkOi8vaHlicmQvQWN0aXZlU3RvcmFnZTo6QmxvYi84P2V4cGlyZXNfaW4GOwBUSSIMcHVycG9zZQY7AFRJIg9hdHRhY2hhYmxlBjsAVEkiD2V4cGlyZXNfYXQGOwBUMA==--05909c377b9c8c68c65af611c4f4d84cc5a0cab6"
}

From this, you can construct the blob’s redirect URL. If you open the Active Storage routes file, you’ll see something like this:

Rails.application.routes.draw do
  scope ActiveStorage.routes_prefix do
    get "/blobs/redirect/:signed_id/*filename" => "active_storage/blobs/redirect#show", as: :rails_service_blob
    get "/blobs/proxy/:signed_id/*filename" => "active_storage/blobs/proxy#show", as: :rails_service_blob_proxy
    get "/blobs/:signed_id/*filename" => "active_storage/blobs/redirect#show"

    ...
  end

  ...
end

The default routes_prefix for ActiveStorage is /rails/active_storage, so as long as you haven’t changed that, the redirect URL for the blob could be either /rails/active_storage/blobs/redirect/:signed_id/*filename or /rails/active_storage/blobs/:signed_id/*filename. For brevity, my Stimulus controller uses the latter.

Using this URL pattern and the blob filename and signed_id properties returned, we can construct the URL like so:

const filename = blob.filename;
const url = `/rails/active_storage/blobs/${blob.signed_id}/${filename}`;

Special Handling for Images

For links in Markdown, you use this format: [text](url) But if you want to inline an image, you need to prefix it with a ! like this: ![alt text](url).

Since the blob properties returns the file content_type, we can check that against a list of known image formats and then add that prefix automatically.

const prefix = (this.isImage(blob.content_type) ? '!' : '');
isImage(contentType) {
  return ["image/jpeg", "image/gif", "image/png"].includes(contentType);
}

More to Come?

Now, obviously, this doesn’t give us all the functionality that GitHub’s editor provides. We would need to add–among other things–a preview mode, a shortcut toolbar, and special text input handling (e.g. creating a new item when pressing return inside a list).

But one of the great things about composability with Stimulus is that we can create new controllers and layer on that new functionality with additional actions.


Need help building or maintaining a Rails app?

Jeremy is currently booked until mid-2023, but always happy to chat.

Email Jeremy