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:
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:
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:
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.
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:
Paste it in the editor and save it. You should see something like this:
Now, if we go to the rails console
and check the contents there:
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"
Next, back on the R2 dashboard, click on "Manage R2 API Tokens", then "Create API token".
Pick a name and under "Permissions", select "Object Read & Write: Allows the ability to read, write, and list objects in specific buckets."
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"
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:
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!