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