Using QuillJS with Rails 6

Published 3rd May 2020 at 04:45pm UTC (Last Updated 19th June 2020 at 06:30pm UTC)

As alluded to in my previous post, I've run into a few difficulties since writing my last technical piece. I recently upgraded the version of Ruby on Rails on the site to version 6. Obviously, it's good practice to keep your application dependencies up-to-date so upgrading provided me with an excuse to do this but in all honesty, Rails 6's major selling point for me was its support for ActionText. At last, I could add a proper WYSIWYG to my app instead of relying on my old Markdown editor!


Now of course, it's always been possible to add these to rails projects, they just weren't supported natively until now. On top of that, my own hangups about the security and accessibility issues involved with making javascript a hard requirement had made me reluctant to add a WYSIWYG editor in the past. I've since been dragged kicking and screaming over to the dark side when it comes to javascript so I figure, what the hell?


So I set up the ActionText editor...which is essentially the Trix editor with a dependency on the new ActionText::RichText type.


Above: the Trix logo

It seemed like a good idea at first...


....that is, until I tried to use inline <code> tags.


You see, these don't really exist in Trix. You can add <pre> tags for code blocks but good luck writing...well writing sentences like this one in a technical article with just those!


The Trix editor also only seems to support one header type (<h1>) so all text must either be this size ("normal") or


this size ("H1")


which, when you take into account the effect header tags can have on SEO, is....I really can't think of a nice thing to say about this decision so I won't.


Cant Even GIF - Cant Even GIFsI think Annalise Keating would agree with me on this if she were real


It also doesn't have an underline button!


Its support for image uploads was kind of nice. There didn't seem to be a way to resize images of course and the captions were a little awkward to style with CSS. But I mean, they technically worked so that was something.


I tried to raise a PR to add a button for the <code> tag to the editor and the bloody thing worked locally. Unfortunately, it was a bit of a hack, a) because my coffeescript knowledge is a bit iffy to say the least and b) because it fundamentally changed the way in which the existing code button behaved and broke tests that relied on the text editor being able to delete inline code in a very specific way.


After weeks of turning the problem over in my head, I was forced to abandon the Trix editor altogether.


But now that I'd seen the light, I knew that there would be no going back to the old Markdown editor. I'd have to look for a more suitable replacement.


I tried to weigh my options. I figure that for my purposes, something simple and easy to customise was best. I'd had some experience with CKEditor in the past but didn't exactly have fond memories of it. I recall its toolbar being slow to load and a little unwieldy. Never really worked out how to persist images in that thing (though I'm sure it's possible).


I also briefly considered ProseMirror. I'd run into this text editor before whilst working at the Royal Pharmaceutical Society, home to the most dysfunctional engineering team I've ever seen. As I'm currently suing RPS (who, to be clear simply use ProseMirror in production as oppose to developing or maintaining the project itself), I'll admit that my thoughts regarding that editor could be a little biased. That being said, I wasn't a fan of the writing style used in the official documentation and the tool struck me as unnecessarily complicated for my needs.


Eventually, I stumbled upon Quill and it just seemed to do exactly what I needed.



Why Quill?


Screenshot of the Quill rich text editor


Now by default, Quill doesn't necessarily give you the buttons that I was after (the inline <code> tag being the most obvious of these). However these are fairly trivial to add and more generally it's actually quite easy to customise the toolbar beyond that.


As well as this, support for resizable images can be added with the use of existing javascript packages (like this one which is essentially a fork of the quill-image-resize module). There's a reasonably active community around the product which helps when it comes to more complicated features.



How do you use it?


So that's all very well and good but how does one even go about adding Quill to a Rails 6 project?


#1 - Define your model


First, you'll need to create a model with at least one string or text field. For example, you could generate an Article model with:

rails g model Article title:string body:text


In this scenario, the body field will be used by the Quill editor.


Once you're happy with the new migration file, run rails db:migrate and move on to the next step.


#2 - Add Quill to your views


Next, create the controller and views for the model:

rails g controller articles


Setting up the controller should be straightforward if you've built a rails application before. If you're unfamiliar, this post on the Ruby on Rails website should walk you through it. If the concept of CRUD in an application is also new to you, you may want to read through Nancy Do's article about this as well.


Typically when you're creating a CRUD app in rails, you'll want to create a partial view for a form so that it can be reused by both the new.html.erb and edit.html.erb views generated.

touch app/views/articles/_form.html.erb


In this file, add the following code:

<%= form_for @article do |f| %>
  <fieldset class="row">
    <div>
      <%= f.label :title, "Title" %>
    </div>
    <div>
      <%= f.text_field :title %>
    </div>
  </fieldset>
  <fieldset class="row">
    <div>
      <%= f.label :body, "Body" %>
    </div>
    <div>
      <%= f.hidden_field :body, class: "article-content" %>
      <div id="editor-container" style="height: 30rem; width: 100%;">
        <%= raw(@article.body) %>
      </div>
    </div>
  </fieldset>

  <fieldset class="row">
    <div>
      <%= f.button "Submit", class: "button" %>
    </div>
  </fieldset>
<% end %>

Most of the work is done in this section:

<div>
  <%= f.hidden_field :body, class: "article-content" %>
  <div id="editor-container" style="height: 30rem; width: 100%;">
    <%= raw(@article.body) %>
  </div>
</div>

The hidden field will add the formatted text input into the editor as a HTML string under the article's body attribute. This value will form part of the POST request sent by the form even though it isn't visible in the browser. The class name used here doesn't matter too much but it will be used in a later step so make a note of the name given.


The text editor itself will appear in the #editor-container div tag. Be sure to make a note of the ID used here as well.


As this partial will appear in the edit view, the editor container will need to display the existing contents of the article when the page is loaded. This is why a HTML escaped version of the @article.body string is nested within the #editor-container div tag.

To refer to the partial in the new and edit views, add the following tag:

<%= render partial: "articles/form" %>


Before we move on to the next step, it's also important to ensure that you've added a CSRF meta tag to your views if this hasn't been done already. This will protect your application against cross-site request forgery by adding a digital signature to the page that can be used to verify requests sent to the server have come from a user rather than a malicious application.

These are typically added to the <head> tag within the app/views/layouts/application.html.erb file (or in the corresponding partial file if these are being used):

<%= csrf_meta_tags %>


#3 - Setup ActiveStorage


Now if you want to be able to write articles with embedded images in your text editor you'll need to use ActiveStorage, which has been available since Rails 5.2. To set this up, run:

rails active_storage:install
rails db:migrate

This will create the active_storage_blobs and active_storage_attachments tables in your database which is where images will be stored as values in their BLOB columns.


Once you've done this, you should also create a config/storage.yml file to store the credentials for the different storage services that can be used by your application. An example setup for an app that uses the Google Cloud Platform in production would be:

test:
  service: Disk
  root: <%= Rails.root.join("tmp/storage") %>

local:
  service: Disk
  root: <%= Rails.root.join("storage") %>

google:
  service: GCS
  credentials:
    type: <%= Rails.application.credentials.dig(:gcs, :type) %>
    project_id: <%= Rails.application.credentials.dig(:gcs, :project_id) %>
    private_key_id: <%= Rails.application.credentials.dig(:gcs, :private_key_id) %>
    private_key: <%= Rails.application.credentials.dig(:gcs, :private_key).dump %>
    client_email: <%= Rails.application.credentials.dig(:gcs, :client_email) %>
    client_id: <%= Rails.application.credentials.dig(:gcs, :client_id) %>
    auth_uri: <%= Rails.application.credentials.dig(:gcs, :auth_uri) %>
    token_uri: <%= Rails.application.credentials.dig(:gcs, :token_uri) %>
    auth_provider_x509_cert_url: <%= Rails.application.credentials.dig(:gcs, :auth_provider_x509_cert_url) %>
    client_x509_cert_url: <%= Rails.application.credentials.dig(:gcs, :client_x509_cert_url) %>
  project: "project_name"
  bucket: <%= Rails.application.credentials.dig(:gcs, :bucket) %>


Note the use of the variables defined in the config/credentials.yml.enc file. More detail about the credentials file's usage can be found in the official Rails guide. For now it's important to know that it can be modified with the command below:

EDITOR=vim bin/rails credentials:edit

If you're using GCP, the images uploaded via the text editor in production will be stored in a storage bucket which can be associated with a service account. This Stack Overflow answer goes into detail about how to obtain the credentials for a given service account in GCP but in short, you will need to generate a key for the account and download its credentials as a JSON file. These credentials must be downloaded immediately and stored securely as there isn't an option to download service account credentials a second time. Of course once you've downloaded this file, its contents can then be assigned to environment variables in config/credentials.yml.enc.

e.g.,

gcs:
  type: service_account
  project_id: project_name
  private_key_id: 2f6d5255889009da25ba6bf2486d6b7e2bb5aaef
  private_key: "-----BEGIN PRIVATE KEY-----\nexampleKey\n-----END PRIVATE KEY-----\n"
  client_email: 00000001-compute@developer.gserviceaccount.com
  client_id: 1111111
  auth_uri: https://accounts.google.com/o/oauth2/auth
  token_uri: https://oauth2.googleapis.com/token
  auth_provider_x509_cert_url: https://www.googleapis.com/oauth2/v1/certs
  client_x509_cert_url: https://www.googleapis.com/robot/v1/metadata/x509/00000001-compute%40developer.gserviceaccount.com
  bucket: project_name_bucket

More information on how to add other service providers (e.g., AWS and Azure) can also be found in the official ActiveStorage documentation.


You will then need to refer to the ActiveStorage service you intend to use in your environment configuration. In config/environment/test.rb and config/environment/development.rb you should store images locally:

Rails.application.configure do
  ...
  config.active_storage.service = :local
  ...
end

But in production (config/environment/production.rb), you should use your cloud provider of choice. Here I've used GCP:

Rails.application.configure do
  ...
  config.active_storage.service = :google
  ...
end


#4 - Add Quill to your javascript code


Hopefully if you're using Rails 6, you'll already have Webpacker set up in your app. If not, this blog post by Gaurav Tiwari goes into it a little and may be worth a read.


To use QuillJS in your app, it will need to be added as a dependency in your package.json file:

...
  "dependencies": {
    "@babel/preset-react": "^7.7.4",
    "@rails/activestorage": "^6.0.2",
    "@rails/webpacker": "4.2.2",
    "@taoqf/quill-image-resize-module": "^3.0.1",
    "quill": "^1.3.7",
    "quill-image-drop-module": "^1.0.3",
    "svg-inline-loader": "0.8.2"
  },
...


Note, I use npm instead of yarn for package management because I'm not convinced it's compatible with Heroku (and even if it were, its use feels like duplication to me). If you're using yarn, these dependencies will need to go in the yarn.lock file via the yarn add command (described here) instead.


To ensure that you're able to import packages added to the node_modules folder, add the following to the config/initializers/assets.rb file:

Rails.application.config.assets.paths << Rails.root.join('node_modules')


I'd suggest adding a separate file for Quill configuration in the javascript/packs folder:

touch app/javascript/packs/quill-editor.js


In this file add the following imports...

import { DirectUpload } from "@rails/activestorage"
import ImageResize from "@taoqf/quill-image-resize-module/image-resize.min";
import Quill from 'quill/quill';
export default Quill;


...and register the ImageResize module from @taoqf's fork:

Quill.register('modules/imageResize', ImageResize);

Note, I suggest using this fork because in February 2020, the original ImageResize module was quite broken and the fix for this issue has yet to be merged three months later.


The basic configuration for a Quill toolbar, as described on the official site might be something like this:

document.addEventListener("DOMContentLoaded", function (event) {
  var quill = new Quill('#editor-container', {
    modules: {
      toolbar: [
        [{ header: [1, 2, false] }],
        ['bold', 'italic', 'underline'],
        ['image', 'code-block']
      ]
    },
    placeholder: 'Compose an epic...',
    theme: 'snow'
  });

  document.querySelector('form').onsubmit = function () {
    var body = document.querySelector('input[class=article-content]');
    body.value = quill.root.innerHTML
  };
});


Here the toolbar is a two-dimensional array of button groups. Drop-down buttons are defined as objects with values that are specific to the button type.

Note also, the use of the addEventListener method called on the document in the first line of the code block. This is needed to ensure that your text editor code is only run after the DOM has finished loading and not including it can produce a Quill is not defined error in the console like the one mentioned in this SO question. More info on the DOMContentLoaded event can be found in the MDN web docs.


Of course this'll only get you a pretty basic toolbar: bold, italic, underline, code blocks, image uploads and H1 and H2 tags.

Basic quill editor


Personally, I still wanted a few more buttons. My configuration wound up looking more like this:

document.addEventListener("DOMContentLoaded", function (event) {
  var quill = new Quill('#editor-container', {
    modules: {
      toolbar: [
        [{ header: [1, 2, 3, 4, 5, 6, false] }],
        [{ color: [] }],
        [{ size: [] }],
        [
          'bold', 'italic', 'underline', 'strike',
          { 'script': 'super'},
          { 'script': 'sub' },
          'code', 'link'
        ],
        ['blockquote', 'code-block', 'image'],
        [{ list: 'ordered' }, { list: 'bullet' }],
        [{ align: ['center', 'right', 'justify', false] }],
        [{ indent: '-1'}, { indent: '+1' }]


      ],
      imageResize: {
        displaySize: true,
        displayStyles: {
          backgroundColor: 'black',
          border: 'none',
          color: 'white'
        },
        modules: [ 'Resize', 'DisplaySize', 'Toolbar' ]
      }
    },
    value: document.querySelector('input[class=rich-text-content]').value,
    theme: 'snow'
  });

  document.querySelector('form').onsubmit = function () {
    var body = document.querySelector('input[class=article-content]');
    body.value = quill.root.innerHTML
  };

  // More on this in a bit!
  quill.getModule('toolbar').addHandler('image', () => {
    importImage(quill);
  });
});

...


Now I have more header sizes, font colours and sizes, text alignment, inline code, subscript and superscript, hyperlinks and lists. As mentioned earlier, document.querySelector('form').onsubmit function towards the end sets the value of the hidden .article-content field based on the inner HTML in the quill editor

If you ignore the fact that the code block and inline code buttons share the same icon, it works pretty well (I was too lazy to change this in my own example but I'll explain how to fix it in step #7).


kohrVid editor


Now in the previous code block you may have spotted this code:

quill.getModule('toolbar').addHandler('image', () => {
  importImage(quill);
});


This code is used to persist images uploaded with the quill editor into a (usually external) file storage essentially by calling a new importImage() function defined below:

var importImage = function (textEditor) {
  const input = document.createElement('input');
  input.setAttribute('type', 'file');
  input.click();

  input.onchange = () => {
    const file = input.files[0];

    // Ensure only images are uploaded
    if (/^image\//.test(file.type)) {
      uploadImage(textEditor, file);
    } else {
      alert('Only images allowed');
    }
  };
};


This function essentially creates a temporary file upload field that can be used to import an image and check that it is of the correct file type before calling the uploadImage() function. When called, this function takes the quill variable in our earlier code as a parameter (textEditor) and later passes said variable to the uploadImage() function along with the new file.


You may also want to set a maximum file size for image uploads. If so, add a constant like the following just below the package imports closer to the top of the file:

const MAX_FILE_SIZE = 1000000


And add a nested if statement to check this in the importImage() function:

if (file.size > MAX_FILE_SIZE) {
 alert("Only support attachment files upto size 1MB!")
 return
}

Here, we're limiting images stored in our database to about 1MB which is great for photos but perhaps a little tricky for GIFs. YMMV.


The uploadImage() function looks like this:

var uploadImage = function (textEditor, file) {
  var fd = new FormData();
  fd.append('blob', file);

  var upload = new DirectUpload(file, '/rails/active_storage/direct_uploads')
  upload.create((error, blob) => {
    if (error) {
      console.log(error)
    } else {
      insertImage(
        textEditor,
        `/rails/active_storage/blobs/${blob.signed_id}/${blob.filename}`
      );
    }
  });
};


This method will take the file selected by the user and send it in the payload of a POST request to the /rails/active_storage_direct_uploads endpoint provided by ActiveStorage. Provided the request doesn't return an error, the image should be saved as a blob within the active_storage_blobs table of the database and the create method used should return the new row as a response (blob).


The filename and signed_id of this row can then be used to construct the new file's URL which is used (again, along with the quill textEditor) as a parameter in the insertImage() function:

var insertImage = function (textEditor, fileUrl) {
  const range = textEditor.getSelection();
  textEditor.insertEmbed(range.index, 'image', fileUrl);
};


This function is used to insert the image into the inner HTML of the text editor, rendering the image on the page.


Once you're happy with your script, it is important that you remember to import the file in app/javascript/packs/application.js:

require("@rails/activestorage").start()
import "./quill-editor.js"


(And obviously make sure that this is included in the <head> tag in the app/views/layouts/application.html.erb file:

<%= javascript_pack_tag 'application', 'data-turbolinks-track': 'reload' %>

)


At first, this won't look quite right because of the way in which Rails 6 handles SVG files. It actually requires the addition of a separate loader to package.json (or yarn.lock). In this case, I've used the svg-inline-loader package which was added as a dependency a little earlier:

  ...
  "dependencies": {
    ...
    "svg-inline-loader": "0.8.2"
  }
...


This, along with the file-loader package (which is already included as a Webpacker dependency), should then be referred to in the config/webpack/environment.js file like so:

const { environment } = require('@rails/webpacker')

const fileLoader = environment.loaders.get('file')
fileLoader.exclude = /node_modules[\\/]quill/
...

const svgLoader = {
  test: /\.svg$/,
  loader: 'svg-inline-loader'
}

environment.loaders.prepend('svg', svgLoader)

module.exports = environment


The file loader will allow you to resolve the SVG paths for the icons in the text editor into their respective files. Excluding the node_modules/quill folder as is done in the example above is also needed to get the icons to display correctly. As the order in which loaders are added is important, the SVG loader must then be prepended, ensuring that it gets appended before the file loader does.



So that was a lot of javascript for one day.

Here's an old photo of a cat!


QT the cat in a bag from etefy, a company with a truly ridiculous(!) name that went bust and fucked over its workers a few years ago. As you can see, this is QT's homage to the Maschinenmensch


#5 - Style the editor


Now you'll want to make sure that the Quill core and Snow styles are added either to the stylesheets in Sprockets or in Webpacker. Personally, I chose to add this to sprockets because I ran into issues with Heroku when these were added to Webpacker. I did this by adding the following to the app/assets/stylesheets/application.css file:

*= require_self
*= require 'quill/dist/quill.core.css'
*= require 'quill/dist/quill.snow.css'


This file is of course included in your application by referring to it in the head of the app/views/layouts/application.html.erb file:

<%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track': 'reload' %>


If you choose to go down the Webpacker route for CSS, some information on how to import the quill styles can be found in the Webpacker repo here.


I must admit though that my styling is something of a hot mess, no thanks in part to issues with asset versioning in Heroku. I'd suggest that you experiment with this in your own setup.


#6 - Add environment configuration


I've covered most of the configuration that you would need to set this up in previous sections already. However it's still important that you have a Content Security Policy with sensible settings. Something like the configuration below should ensure that your application is able to serve the right assets to your users without the risk of their being hijacked by browser extensions and the like:

Rails.application.config.content_security_policy do |p|
  p.default_src :self, :https, "http://localhost:3000"
  p.font_src    :self, :https, :data

  p.img_src     :self, :https, :data, :blob, "http://domain-name.com",
    "http://www.domain-name.com",
    "http://cloud-hostname.storage.googleapis.com"

  p.object_src  :none
	

  p.script_src  :self, :https, "http://domain-name.com", "http://www.domain-name.com",
    "http://localhost:3000"

  p.connect_src :self, :https, "http://localhost:3035",
    "ws://localhost:3035" if Rails.env.development?

  p.style_src   :self, :https, :unsafe_inline

  # Specify URI for violation reports
  p.report_uri  "/csp_reports"
end


At this point, you may start to see errors like the following:

Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource at https://storage.googleapis.com/cloud-hostname/s%2F2vkzje. (Reason: CORS request did not succeed).

To remove this, you will need to configure your CORS settings which are specific to your cloud provider. if Google is used this, needs to be set up in the GCP console. Full instructions on how to do this can be found here but essentially, you will need to create a cors.json file like the following...

[
  {
    "origin": ["https://www.domain-name.com", "https://domain-name.herokuapp.com"],
    "method": ["PUT"],
    "responseHeader": ["Origin", "Content-Type", "Content-MD5", "Content-Disposition"],
    "maxAgeSeconds": 3600
  }
]

...where the origin list should contain your domain name and any other public-facing URLs associated with your app (e.g., you Heroku URL or staging URL, &c.). You should then log into the console and whilst in the cloud shell you can upload the file...


kohrVid cloud shell


...and run the following command:

gsutil cors set cors.json gs://project_name_bucket


This will allow your site to host images stored in your project's bucket.


#7 - Optional Steps


Overriding the DirectUploadsController

If you plan to override any of the default behaviour in the ActiveStorage endpoint referred to in step 3 (the endpoint for which is obviously used in step 4), the easiest way is to create a new controller that inherits from the ActiveStorage::DirectUploadsController class. This Stack Overflow question and answer go into more detail but essentially, you would need to create a controller that looks like this:

class DirectUploadsController < ActiveStorage::DirectUploadsController
  protect_from_forgery with: :null_session,
    if: Proc.new { |c| c.request.format == 'application/json' }

  def create
    # Override the create method here
  end

  private

  def blob_args
    params.require(:blob).permit(
      :filename,
      :byte_size,
      :checksum,
      :content_type,
      :metadata
    ).to_h.symbolize_keys
  end
end


And add this controller as a resource to config/routes.rb:

Rails.application.routes.draw do
  ...
  resources :direct_uploads, only: [:create]
end


Overriding SVG icons

I mentioned earlier that one of the drawbacks of using Quill is that, by default, the inline code and code-block buttons use the same icon. If you're so inclined, you can overwrite this or any of the other buttons with the SVG code for a new icon design.


So for example, I could replace the default icon with an SVG from the Bootstrap library by adding the following code snippet to the javascript code:

var icons = Quill.import('ui/icons');

icons['code'] = `<svg class="bi bi-code" width="1.5em" height="1.5em" viewBox="0 0 16 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
  <path fill-rule="evenodd" d="M5.854 4.146a.5.5 0 010 .708L2.707 8l3.147 3.146a.5.5 0 01-.708.708l-3.5-3.5a.5.5 0 010-.708l3.5-3.5a.5.5 0 01.708 0zm4.292 0a.5.5 0 000 .708L13.293 8l-3.147 3.146a.5.5 0 00.708.708l3.5-3.5a.5.5 0 000-.708l-3.5-3.5a.5.5 0 00-.708 0z" clip-rule="evenodd"/>
</svg>`;

...

Essentially, assigning the inline code icon (icons['code']) to an inline SVG object. Note, this must be defined before the Quill editor is instantiated within your code in order to work.


With the correct styling, <i> tags can be used instead. This Github comment provides an example to that effect.



Summary


So I've told you why I chose to use the Quill editor on my site and how it compares with ActionText. I've also explained how I added this to my Rails application and gone over some of the steps needed in a production environment. Lastly I've taken a brief look at optional steps that can be taken to further customise the editor in your own applications.


Should you choose to use QuillJS in Rails 6, I hope that this article proves helpful and at the very least saves some of the time that might have otherwise been spent googling the process.



Special thanks


Special thanks goes to Stas and the anonymous poster in the comments below who both pointed out a mistake I'd overlooked in the original version of this post.



External Resources


Other than the links I've already mentioned in this post, the following links could prove helpful to readers: