Introduction
In Part 1 of this series, we created a basic WYSIWYG editor for markdown blog articles. We performed the initial setup of a brand new Rails 7 project with ESBuild
as our JavaScript bundler, with StimulusJS as the default JS framework, and dart-sass
as our CSS Bundle, with Bulma
as the CSS framework. We added a representation of articles as a collection of document fragments, and added the Fragment
model backed by a Postgres database to our application. We used Stimulus controllers to make fragments editable directly in the browser by dynamically setting and removing the contenteditable
flag on elements, and using a combination of the excellent tippy.js and rangy JavaScript libraries to dynamically render context menus on text selections and trigger formatting of the selections from these menus. We also added the ability for adding additional document fragments before and after existing fragments. Everything was made interactive and reactive using the Hotwire
framework, and in particular Turbo
Frames, a new tool that comes out of the box with Rails 7.
In this second part of our series we will round out the basic feature set to create a simple, yet powerful editor that you can easily extend down the road to support additional fragment element types or dynamic features. As usual, you can find the full project on GitHub if you want pick up the latest code changes and CSS styles to follow along.
Adding Different Fragment Types
Right now when we hover over and click on the Add Fragment Button, Turbo
intercepts the form submission and calls the backend , which creates a new paragraph
fragment (as specified in the form) and inserts it at the indicated position in the document. Chances are that we don't always want to be adding paragraphs, for example we may want to structure the text and use heading elements as well. For the purpose of this article series, we will limit the feature set to three document heading levels: h1
, h2
and h3
. When you are building and extending your own editor down the road feel free to add more levels following the same blueprint.
First, we will change our app/views/fragments/_new.html.erb
partial to make use of a new Stimulus controller that we will add to control the creation of new document fragmens.
<div class="add-fragment" data-controller="add-fragment">
<div class="add-fragment-button-container">
<%= form_with(
model: [@document, @document.fragments.build(
element: "p",
data: "edit me",
position: (fragment.position + 1))]) do |form| %>
<%= form.text_field :data, hidden: true %>
<%= form.text_field :element, hidden: true %>
<%= form.text_field :meta, hidden: true %>
<%= form.text_field :position, hidden: true %>
<button
class="button is-light is-round is-small"
data-action="click->add-fragment#showMenu">
<i class="gg-math-plus"></i>
</button>
<% end %>
</div>
<hr class="add-fragment-indicator"></hr>
</div>
Next, we add the AddFragment
Stimulus Controller. For each fragment type, the controller code dynamically sets the fragment_element
form field value and submits the form to the backend.
import { Controller } from "@hotwired/stimulus"
import { show_add_fragment_menu } from "../lib/add_fragment_menu"
export default class extends Controller {
showMenu(event) {
event.preventDefault()
show_add_fragment_menu(this.element.querySelector("button"))
}
paragraph(event) {
event.preventDefault()
this.create_fragment("p", "write something amazing ...")
}
h1(event) {
event.preventDefault()
this.create_fragment("h1", "Title")
}
h2(event) {
event.preventDefault()
this.create_fragment("h2", "Title")
}
h3(event) {
event.preventDefault()
this.create_fragment("h3", "Title")
}
create_fragment(element, data, meta = "") {
this.element.querySelector("#fragment_element").value = element
this.element.querySelector("#fragment_data").value = data
this.element.querySelector("#fragment_meta").value = meta
this.element.querySelector("form").requestSubmit()
}
}
Finally, we also add the show_add_fragment_menu
and add_fragment_menu
functions to our app/javascript/lib/add_fragment_menu.js
module.
import tippy from "tippy.js"
const add_fragment_h1 = `
<a class="dropdown-item" data-action="mousedown->add-fragment#h1">
<span class="has-text-weight-bold">Heading 1</span>
</a>
`
const add_fragment_h2 = `
<a class="dropdown-item" data-action="mousedown->add-fragment#h2">
<span class="has-text-weight-semibold">Heading 2</span>
</a>
`
const add_fragment_h3 = `
<a class="dropdown-item" data-action="mousedown->add-fragment#h3">
<span class="has-text-weight-semibold">Heading 3</span>
</a>
`
const add_fragment_p = `
<a class="dropdown-item" data-action="mousedown->add-fragment#paragraph">
Paragraph
</a>
`
const add_fragment_pre = `
<a class="dropdown-item" data-action="mousedown->add-fragment#pre">
Code Block
</a>
`
function add_fragment_menu() {
return(`
<div class="add-fragment-menu">
<div class="dropdown-content context-menu">
${add_fragment_h1}
${add_fragment_h2}
${add_fragment_h3}
${add_fragment_p}
${add_fragment_pre}
</div>
</div>
`)
}
export function show_add_fragment_menu(element) {
return tippy(element, {
allowHTML: true,
content: add_fragment_menu(),
interactive: true,
interactiveBorder: 100,
inlinePositioning: true,
hideOnClick: true,
placement: "bottom",
offset: [0,0],
theme: "light",
onHidden: (instance) => {instance.destroy()}
}).show()
}
As a final step, we add a call to render the new fragment partial below each fragment. Let's update our fragment partial at app/views/fragments/_fragment.html.erb
to include the _new
partial we just created.
<%= turbo_frame_tag dom_id(fragment) do %>
<!-- ... -->
<% end %>
<%= render "fragments/new", fragment: fragment %>
Let's refresh our browser window to pick up these latest code changes, and try to click on the Add Fragment button. A popup menu appears that allows you to select the type of document fragment that you want to add. Try adding some heading and paragraph elements.
After adding enough elements we discover a UX issue: when the content grows large enough (i.e., the browser window needs to scroll vertically), the window position scrolls back to the top of the document after adding a new fragment. This happens because the form submission in the _new.html.erb
partial happens outside of a Turbo Frame, causing Turbo navigation to target the default target of _top
which is a full page refresh. We can easily remediate this by wrapping all fragments in a new Turbo Frame like this in our app/views/document/index.html.erb
:
<%= turbo_frame_tag :fragments do %>
<% @fragments.each do |fragment| %>
<%= render fragment %>
<% end %>
<% end %>
Now when we add new fragments, instead of a full page refresh and scroll, just the :fragments
frame is updated and the new fragments appears in place right next to our current cursor position.
Changing the Fragment Type
When writing content, we constantly evaluate our words and formatting - "should this level 2 heading rather be a level 3 heading?", "shouldn't we turn this paragraph rather into a code block?" are examples of these considerations we take every day. It would be neat to give users the ability to change the element type for document fragments directly in the WYSIWYG editor interface. To implement this feature, we will add a new Stimulus Controller, and a button with a drop-down menu to trigger the actions of the controller.
For this feature, we want the change button to appear to the left side of a fragment in the document margin, when the mouse hovers over the fragment. To achieve this effect, we need to change the markup of the app/views/fragments/_fragment.html.erb
partial to wrap the actual rendered content in a nested div container that puts a change button to the left - similar to what we did with the add fragment button that appears in the left document margin.
<%= turbo_frame_tag dom_id(fragment) do %>
<div class="fragment-wrapper <%= fragment.element %> data-controller="editable change-fragment">
<div class="fragment-button-container">
<button
class="button is-small is-light is-round"
data-action="click->change-fragment#showMenu">
<i class="gg-chevron-down"></i>
</button>
</div>
<div class="fragment-container">
<div
class="fragment <%= fragment.element %>"
data-editable-target="editable"
data-action="
click->editable#click
blur->editable#blur
keydown->editable#keyDown
paste->editable#paste
mouseup->editable#mouseUp
mousedown->editable#mouseDown">
<%= fragment.render %>
</div>
<%= form_with(model: [@document, fragment]) do |form| %>
<%= form.text_area(:data, hidden: true) %>
<%= form.text_field :element, hidden: true %>
<%= form.text_field :meta, hidden: true %>
<%= form.text_field :position, hidden: true %>
<%= form.submit("Save", hidden: true) %>
<% end %>
</div>
</div>
<% end %>
<%= render "fragments/new", fragment: fragment %>
Then, we implement the ChangeFragment
Stimulus controller that we connected to the change fragment button in the above markup. Add a new file app/javascript/controllers/change_fragment_controller.js
with the following content:
import { Controller } from "@hotwired/stimulus"
import { show_change_fragment_menu } from "../lib/change_fragment_menu"
export default class extends Controller {
showMenu(event) {
event.preventDefault()
show_change_fragment_menu(this.element.querySelector("button"))
}
h1(event) {
event.preventDefault()
this.change_to("h1")
}
h2(event) {
event.preventDefault()
this.change_to("h2")
}
h3(event) {
event.preventDefault()
this.change_to("h3")
}
paragraph(event) {
event.preventDefault()
this.change_to("p")
}
pre(event) {
event.preventDefault()
var language = prompt("Language", "plain");
if (language == null) {
language = "plain"
}
this.change_to("pre", language)
}
change_to(element, meta="") {
this.element.querySelector("#fragment_element").value = element
this.element.querySelector("#fragment_meta").value = meta
this.element.querySelector("form").requestSubmit()
}
}
Next, we will need to implement the dropdown menu in app/javascript/lib/change_fragment_menu.js
import tippy from "tippy.js"
const change_fragment_h1 = `
<a class="dropdown-item" data-action="mousedown->change-fragment#h1">
<span class="has-text-weight-bold">Heading 1</span>
</a>
`
const change_fragment_h2 = `
<a class="dropdown-item" data-action="mousedown->change-fragment#h2">
<span class="has-text-weight-semibold">Heading 2</span>
</a>
`
const change_fragment_h3 = `
<a class="dropdown-item" data-action="mousedown->change-fragment#h3">
Heading 3
</a>
`
const change_fragment_p = `
<a class="dropdown-item" data-action="mousedown->change-fragment#paragraph">
Paragraph
</a>
`
const change_fragment_pre = `
<a class="dropdown-item" data-action="mousedown->change-fragment#pre">
Code Block
</a>
`
function change_fragment_menu() {
return(`
<div class="change-fragment-menu">
<div class="dropdown-content context-menu">
${change_fragment_h1}
${change_fragment_h2}
${change_fragment_h3}
${change_fragment_p}
${change_fragment_pre}
</div>
</div>
`)
}
export function show_change_fragment_menu(element) {
return tippy(element, {
allowHTML: true,
content: change_fragment_menu(),
interactive: true,
interactiveBorder: 100,
inlinePositioning: true,
hideOnClick: true,
placement: "bottom",
offset: [0,0],
theme: "light",
onHidden: (instance) => {instance.destroy()}
}).show()
}
We need to run rails stimulus:manifest:update
to pick up the new ChangeFragment
controller in our application bundle. After refreshing our browser window, we have a new dropdown menu button appear in the left document margin when we hover over a fragment. Clicking on the button opens the dropdown menu and by selecting one of the options the fragment type changes to the selected type. In the background, our Stimulus controller is dynamically filling out the fragment form, submitting the form to the backend where the fragment model is updated and saved, and then re-rendered. The re-rendered fragment is picked up by the Turbo framework, and the content of the turbo frame is replaced with the re-rendered fragment.
Deleting Fragments
Now that we have a change fragment menu next to each document fragment, adding the functionality for deleting a fragment is straightforward: we add another menu option to the change fragment menu for deleting, and have the corresponding action in our ChangeFragment
Stimulus controller call the :destroy
action on the Fragment
controller of our backend.
First, let's add a hidden delete button below the change fragment form that we can call from our ChangeFragment
Stimulus controller. We are using the new turbo_method: :delete
feature that turns links with methods into form submissions and targets the :fragments
turbo frame for rendering the result of this action.
<%= button_to "Delete",
document_fragment_path(@document, fragment),
method: :delete,
data: {turbo_method: :delete, turbo_frame: :fragments},
hidden: true
%>
Next, we add a delete action to our ChangeFragment
controller that triggers this button.
delete(event) {
this.element.querySelector("form.button_to").requestSubmit()
}
Last, let's add the markup for showing the delete options to the change_fragment_menu
:
const change_fragment_delete = `
<a class="dropdown-item has-text-danger" data-action="mousedown->change-fragment#delete">
Delete
</a>
`
function change_fragment_menu() {
return(`
<div class="change-fragment-menu">
<div class="dropdown-content context-menu">
${change_fragment_h1}
${change_fragment_h2}
${change_fragment_h3}
${change_fragment_p}
${change_fragment_pre}
<hr class="dropdown-divider">
${change_fragment_delete}
</div>
</div>
`)
}
After refreshing our browser window, we can now see the Delete option in the dropdown menu on each fragment. Go ahead and insert a dummy paragraph, then delete it. Careful - there is no going back, since we haven't implemented Undo and Redo yet!
Changing the Fragment Order
The last feature for Part 2 of our series is the ability to re-order fragments using drag and drop. For this feature we will make use of the excellent SortableJS library which has a neat and clean API, and supports touch, all modern browsers including IE9 and has neat CSS animation when moving items around.
For this feature, we want to add a drag handle in the left document margin, next to the change fragment dropdown button. Similarly to the dropdown button, we want the drag handle only to appear when our mouse is hovering over a document fragment. When we start dragging a fragment to another position, we want the fragment to render as a thin indicator line, similar to the one that appears when we hover over one of the add fragment buttons. When we drop the item in place, the fragment should render back as a full fragment, and have their new position updated and saved in the backend.
Let's start by updating our app/views/document/index.html.erb
template and surrounding the @fragments
render loop with a container that we can use to attach an instance of SortableJS
to.
<%= turbo_frame_tag :fragments do %>
<div class="fragments-container" data-controller="sortable">
<%= render @fragments %>
</div>
<% end %>
Next, we add the SortableController
Stimulus controller to app/javascript/controllers/sortable_controller.js
and use the connect()
callback to configure and attach a new instance of SortableJS
to the controller's element.
import { Controller } from "@hotwired/stimulus"
import { Sortable } from "sortablejs"
export default class extends Controller {
connect() {
this.sortable = Sortable.create(this.element, {
handle: ".handle",
ghostClass: "being-dragged",
direction: "vertical",
swapThreshold: 0.5,
invertSwap: true,
animation: 150,
onEnd: this.moved.bind(this)
})
}
moved(event) {
event.item.querySelector("#fragment_position").value = event.newIndex + 1
console.log(event.item.querySelector("form"))
event.item.querySelector("form").requestSubmit()
}
}
Note that we update the fragment position with event.newIndex + 1
: acts_as_list
is 1-indexed whereas SortableJS
is 0-indexed, and we need to account for that difference. Finally, we add a new drag handle to the left document margin so that we can move fragments around. Let's update app/views/fragments/_fragment.html.erb
to include this drag handle:
<div class="fragment-button-container">
<button
class="button is-small is-light is-round"
data-action="click->change-fragment#showMenu">
<i class="gg-chevron-down"></i>
</button>
<span class="button is-outlined is-round is-small handle ml-2"><i class="gg-controller"></i></span>
</div>
To collapse the fragment to a small indicator line while being dragged, we update our application stylesheets to include the being-dragged
style we defined as ghostClass
on our SortableJS instance.
.being-dragged {
.fragment {
position: relative;
width: 100%;
max-height: 1px;
overflow: hidden;
padding: 0;
background-color: $link;
}
}
After refreshing the browser window, we can try out the new drag and drop functionality. Hover over a document fragment and see the drag handle appear in the left document margin. Hold down the mouse button on the drag handle and the fragment collapses to our blue indicator line that shows where the fragment would be inserted when we let go of the button. Drag the fragment above or below another fragment and let go. The original fragment appears in the new position and the position is updated in the backend. Refresh the page and see that the fragment is still rendered in the correct updated position.
What's Next?
This concludes Part 2 of our series on building a Markdown WYSIWYG editor. In the next article, we will extend and polish our editor to create a feature complete editing experience. In particular, we will work on adding hyperlinks to selected text, cleaning up some of the context menus, and exporting the entire document as Markdown.