Setting up ActiveStorage + CloudflareR2 + Quill

Hello, fellow readers! Today we will do a little hands-on project to implement a Post Editor. Our goal will be to implement a fully functional WYSIWYG editor with image upload capabilities. For this, we’ll be setting up ActiveStorage using Cloudflare R2, whose free tier is great for personal projects and for general dev purposes, and, for our WYSIWYG editor, we’ll be using Quill JS.

We’ll start with implementing Quill Editor so we can have something to connect to R2 later.

Implementing Quill Editor

First, we’ll create a new rails application and a Post Scaffold:

rails new WYSIWYG -d postgresql
cd WYSIWYG
rails db:create
rails g scaffold Post title content publication_date:datetime
rails db:migrate

By running rails s in the terminal, and visiting http://localhost:3000/posts in your browser you should be able to see a simple page with the scaffold index action rendered on it. Clicking on "New post" will lead you to the creation form, and that’s where we’ll start working. First, we’ll need to install Quill. We’ll be using the cdn as a source and we’ll also need jQuery. So, in your config/importmap.rb file, add the following lines:

pin "quill", to: 'https://cdn.jsdelivr.net/npm/quill@2.0.0-rc.2/dist/quill.js'
pin "jquery", to: 'https://code.jquery.com/jquery-3.7.1.min.js'

What this does is map the scripts in the cdn to that path (in this case quill and jquery). So, every time we write import "jquery" or import "quill" it will resolve to that script. Btw, remember to kill the server and start it again, otherwise, this won’t work.

Next, we’ll create a new JS Controller in javascript/controllers/quill_form_controller.js and add the following code inside:

import { Controller } from "@hotwired/stimulus"
import "jquery"
import "quill"

export default class extends Controller {
  connect() {
    const quill = new Quill('#quill_editor', {
      modules: {
        toolbar: [
          ['bold', 'italic', 'underline', 'strike'],        // toggled buttons
          ['blockquote', 'code-block'],
          ['link', 'image', 'video', 'formula'],

          [{ 'header': 1 }, { 'header': 2 }],               // custom button values
          [{ 'list': 'ordered'}, { 'list': 'bullet' }, { 'list': 'check' }],
          [{ 'script': 'sub'}, { 'script': 'super' }],      // superscript/subscript
          [{ 'indent': '-1'}, { 'indent': '+1' }],          // outdent/indent
          [{ 'direction': 'rtl' }],                         // text direction

          [{ 'size': ['small', false, 'large', 'huge'] }],  // custom dropdown
          [{ 'header': [1, 2, 3, 4, 5, 6, false] }],

          [{ 'color': [] }, { 'background': [] }],          // dropdown with defaults from theme
          [{ 'font': [] }],
          [{ 'align': [] }],

          ['clean']                                         // remove formatting button
        ]
      },
      theme: 'snow'
    });
  }
}

And in our app/views/posts/_form.html.erb we’ll do the following:

<div>
    ...
    <div>
      <%= form.label :title, style: "display: block" %>
      <%= form.text_field :title %>
    </div>

    <div>
      <%= form.label :publication_date, style: "display: block" %>
      <%= form.datetime_field :publication_date %>
    </div>

    <div data-controller="quill-form">
      <%= form.label :content, style: "display: block" %>
      <div id="quill_editor"></div>
    </div>
    ...
</div>

At this point, if you restart the server and reload the page, you will see a quite broken page:

broken editor

This is because we have not included the Quill stylesheet yet. We can add the css directly to app/views/layouts/application.html.erb, but this would add the quill css for all pages that use this layout. So, instead, we’ll do a little trick to include this dynamically. In javascript/controllers/quill_form_controller.js we’ll do:

  ...
  connect() {
    var link = document.createElement('link');
    link.type = 'text/css';
    link.rel = 'stylesheet';
    link.href = 'https://cdn.jsdelivr.net/npm/quill@2.0.0-rc.2/dist/quill.snow.css';

    document.head.appendChild(link);

    ...

This will dynamically append a link to Quill’s CDN stylesheet. Now, reloading again:

form with quill

That’s nice and all, but if you noticed, this is not quite connected to anything yet. If we add some text here and click on Create Post, nothing will be created in the content field. To do so, we’ll need to work a little bit more magic to connect the content to our backend. We’ll leverage Quill’s events to fill a hidden field in our form with the contents of the editor as we write. In app/views/posts/_form.html.erb we’ll do the following:

...
  <div data-controller="quill-form">
    <%= form.label :content, style: "display: block" %>
    <%= form.hidden_field :content, id: 'post_quill_content' %>
    <div id="quill_editor"></div>
  </div>
...

And in javascript/controllers/quill_form_controller.js we’ll do:

...
export default class extends Controller {
  connect() {
    ...

    quill.on('text-change', (eventName, ...args) => {
      document.getElementById('post_quill_content').value = JSON.stringify(quill.getContents());
    });
  }
}

Now, if you create a new post or edit an existing one:

funny format

This looks a bit odd because we are saving a Quill Delta, not the HTML-parsed contents. Also, if we try to edit the post we just created, you will notice the editor is initialized empty. Let’s start by solving the latter. In javascript/controllers/quill_form_controller.js, right before the listener, we added:

    ...
    try {
      quill.setContents(JSON.parse(document.getElementById('post_quill_content').value))
    }
    catch(err) {
      // console.log(err)
    }
    ...

And this should solve the edit issue. Also, we added the try block there, because when we create the post the content will be empty and it will throw a JSON parse error. Alternatively, we can simply define a default value ("{}") for the content field of the Post model. Suit yourself on what solution to implement here.

Now we’ll use an NPM Lib called @ahmgeeks/quill-delta-to-html to convert quill’s delta to html. Again, alternatively, you could simply store both the quill delta and the parsed html and render the stored html directly, but we’ll opt to use that library to be a bit more database-light. So, in the terminal:

npm install @ahmgeeks/quill-delta-to-html
bin/importmap pin @ahmgeeks/quill-delta-to-html

The first command will install the lib to node_modules and the second will run the importmap tool to pin the lib so we can use it within our js env. If you check config/importmap.rb you should see something like this:

# Pin npm packages by running ./bin/importmap

pin "application"
pin "@hotwired/turbo-rails", to: "turbo.min.js"
pin "@hotwired/stimulus", to: "stimulus.min.js"
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
pin_all_from "app/javascript/controllers", under: "controllers"

pin "quill", to: 'https://cdn.jsdelivr.net/npm/quill@2.0.0-rc.2/dist/quill.js'
pin "jquery", to: 'https://code.jquery.com/jquery-3.7.1.min.js'
pin "lodash.isequal" # @4.5.0
pin "@ahmgeeks/quill-delta-to-html", to: "@ahmgeeks--quill-delta-to-html.js" # @0.12.2

After that, we’ll create a new JS Controller (javascript/controllers/quill_content_controller.js), this time to process the quill delta and display the resulting content:

import { Controller } from "@hotwired/stimulus"
import { QuillDeltaToHtmlConverter } from '@ahmgeeks/quill-delta-to-html';

export default class extends Controller {
  connect() {
    const delta = JSON.parse(this.data.get("content"));
    var converter = new QuillDeltaToHtmlConverter(delta.ops, {inlineStyles: true});

    this.element.innerHTML = converter.convert()
  }
}

And then in app/views/posts/_post.html.erb

<div id="<%= dom_id post %>">
  <p>
    <strong>Title:</strong>
    <%= post.title %>
  </p>

  <p>
    <strong>Publication date:</strong>
    <%= post.publication_date %>
  </p>

  <div data-controller="quill-content" data-quill-content-content="<%= post.content %>"></div>
</div>

And that’s it! Eventually, you can adjust the lib’s behavior to generate better html/css for your needs.
rendered page

Now, it’s Cloudflare R2 and ActiveStorage’s turn

So, if you try to paste an image in the Quill editor, you will notice it works fine, but the reason it does is that Quill converts the image to Base64. Let’s do a quick experiment:

Copy this image:
image

Paste it in the editor and save it. You should see something like this:

rendered page with image

Now, if we go to the rails console and check the contents there:

alt text

So, yeah, not very database-friendly…
to solve this issue, we’ll implement ActiveStorage + use Cloudflare R2 as our cloud storage service.

As for the reason we picked Cloudflare R2 here (will call it simply R2 from now on), to keep it short, is that it already comes with CDN capabilities, the free plan is awesome (10GB storage and 10m requests), and because setting it up is much simpler (and even cheaper) than other combinations like S3 + Cloudfront, for instance.

First, let’s set up a bucket on R2. You’ll need a Cloudflare account to do so.

With your authenticated user, go to your dashboard and click on "R2" and then in "create bucket", give it a name and then "Create Bucket"

r2 dashboard

create bucket

Next, back on the R2 dashboard, click on "Manage R2 API Tokens", then "Create API token".

dashboard manage token

create token

Pick a name and under "Permissions", select "Object Read & Write: Allows the ability to read, write, and list objects in specific buckets."

token name and permissions

Next, under "Specify bucket(s)", select "Apply to specific buckets only" and select the bucket you just created
under "TTL", you can define for how long this token will be valid, but in our case, we’ll simply pick "Forever"

specific buckets and ttl

Click on "Create API Token" and on the next page be sure to copy all of the information displayed, as we’ll need these later. There, you can either use Rails’ credentials feature or simply use a .env. I’ll opt for the .env for simplicity (since we’re planning to use this project in future endeavors). You can follow the instructions in the dotenv gem’s GH to get this working. There you will set these values as indicated in the image below:

token credentials

The dotenv will look something like:

R2_ENDPOINT=<value you copied>
R2_ACCESS_KEY_ID=<value you copied>
R2_SECRET_ACCESS_KEY=<value you copied>
R2_BUCKET_NAME=<your bucket's name>

Then, in the config/storage.yml file, you will add these lines:

cloudflare:
  service: S3
  endpoint: <%= ENV.fetch("R2_ENDPOINT", nil) %>
  access_key_id: <%= ENV.fetch("R2_ACCESS_KEY_ID", nil) %>
  secret_access_key: <%= ENV.fetch("R2_SECRET_ACCESS_KEY", nil) %>
  region: auto
  bucket: <%= ENV.fetch("R2_BUCKET_NAME", nil) %>

Since R2 is an S3-like service, we’ll need an adapter to make it work. Doing so is as simple as running the code below in the terminal:

bundle add aws-sdk-s3 --require=false

The command above should automatically add the line gem "aws-sdk-s3", "~> 1.143", :require => false to your Gemfile and install it.

Then configure the service in the relevant environment file (in our case, config/environments/development.rb) by adding this line in the config (or replacing, if the config already exists):

config.active_storage.service = :cloudflare

Then let’s install ActiveStorage, so in the terminal:

rails active_storage:install
rails db:migrate

That should be pretty much it for the sake of ActiveStorage’s config.

Now we need to do two things: enable quill to perform uploads, and add an endpoint it can use to send the images.
First, on quill’s side, we’ll use the quill-image-uploader npm lib to do so:

npm install quill-image-uploader
bin/importmap pin quill-image-uploader

Next, we’ll change the javascript/controllers/quill_form_controller.js to the following:

import { Controller } from "@hotwired/stimulus"
import "jquery"
import Quill from "quill"
import ImageUploader from "quill-image-uploader"

export default class extends Controller {
  connect() {
    var link = document.createElement('link');
    link.type = 'text/css';
    link.rel = 'stylesheet';
    link.href = 'https://cdn.jsdelivr.net/npm/quill@2.0.0-rc.2/dist/quill.snow.css';
    document.head.appendChild(link);

    Quill.register("modules/imageUploader", ImageUploader)

    const quill = new Quill('#quill_editor', {
      modules: {
        toolbar: [
          ['bold', 'italic', 'underline', 'strike'],        // toggled buttons
          ['blockquote', 'code-block'],
          ['link', 'image', 'video', 'formula'],

          [{ 'header': 1 }, { 'header': 2 }],               // custom button values
          [{ 'list': 'ordered'}, { 'list': 'bullet' }, { 'list': 'check' }],
          [{ 'script': 'sub'}, { 'script': 'super' }],      // superscript/subscript
          [{ 'indent': '-1'}, { 'indent': '+1' }],          // outdent/indent
          [{ 'direction': 'rtl' }],                         // text direction

          [{ 'size': ['small', false, 'large', 'huge'] }],  // custom dropdown
          [{ 'header': [1, 2, 3, 4, 5, 6, false] }],

          [{ 'color': [] }, { 'background': [] }],          // dropdown with defaults from theme
          [{ 'font': [] }],
          [{ 'align': [] }],

          ['clean']                                         // remove formatting button
        ],
        imageUploader: {
          upload: (file) => {
            return new Promise((resolve, reject) => {
              let formData = new FormData();
              formData.append("file", file);

              fetch("/api/v1/uploads", {method: "POST", body: formData})
              .then((response) => response.json())
              .then((result) => resolve(result.url))
              .catch((error) => reject("Upload failed"))
            });
          },
        }
      },
      theme: 'snow'
    });

    try {
      quill.setContents(JSON.parse(document.getElementById('post_quill_content').value))
    }
    catch(err) {
      // console.log(err)
    }

    quill.on('text-change', (eventName, ...args) => {
      document.getElementById('post_quill_content').value = JSON.stringify(quill.getContents());
    });
  }
}

Create a file in app/controllers/api/v1/uploads_controller.rb with the contents below:

class Api::V1::UploadsController < ApplicationController
  skip_before_action :verify_authenticity_token

  def create
    blob = ActiveStorage::Blob.create_and_upload!(io: params[:file], filename: params[:file].original_filename)
    render json: { url: url_for(blob) }
  end
end

And in the config/routes.rb file, we’ll add the following:

  namespace :api do
    namespace :v1 do
      post 'uploads'
    end
  end

Upon trying to add an image, voilà! It should work like magic!

If you save the post, you should be able to see the rendered version of it, as well as that the images have been saved in the CDN, and there’s only an image tag with a source property correctly filled.

Conclusion

So, in this article, you learned how to implement a WYSIWYG editor with rails + setting up ActiveStorage with Cloudflare R2.

In our next post, we’ll see how to set up a CI/CD using GitHub Actions + Render for a very cool developer experience!

That’s all for now! See you in the next one!


Other links:

https://kirillplatonov.com/posts/activestorage-cloudflare-r2/ – A cool post by Kirill Platonov that has very similar instructions to setting up Cloudflare R2 but with some extra spice on setting CORS policies.

_Cover Photo by Pierre Bamin on Unsplash_

We want to work with you. Check out our "What We Do" section!