Modern Front-End Magic With Rails 7: A Visual Editor For Markdown

In this blog article, we take a look at building our very own WYSIWYG ("what you see is what you get") editor for blog articles with Ruby on Rails 7 and the Hotwire stack. The goal for our editor is to make writing blog articles accessible to writers that are not familiar with the Markdown syntax, and to support visual thinkers that need to see the rendered page as part of the writing process and don't want to constantly click the "Preview" button to see the result of the writing. In the end though, we want the editor to produce a Markdown document, so we can easily export our articles and reuse the articles or parts of the articles on other content platforms such as a Medium blog, or an internal documentation site.

Demo of Bloggy

Our editor needs to be fully functional and support the entire spectrum of content types that writes typically use when writing blog articles: different sized headings to logically segment information, lists to present information, paragraphs of text, with the ability to format parts of the text, insert hyperlinks, add images, and source code examples, or command line examples. In the end, we will use the editor that we built to write this very blog article that you are reading - the full implementation is available on GitHub.

Introduction

Our goal for this build is to replicate a small but substantial subset of GitBook editor with the brand new alpha version of Rails 7. This version of Rails comes with a slew of modern frameworks that completely changes the way we look at modern front-end code and make creating such an interaction-rich user experience actually quite enjoyable for someone like me who tries to write as little JavaScript as possible.

I've always been fascinated by GitBook, because these guys absolutely nailed the core user experience of writing and editing documentation in a visual way - especially when it comes to making the editor accessible to non-technical users.

Instead of writing documentation in the Markdown text format directly, GitBook offers the very visual editing experience we are seeking: users can create textual content directly on the page, and see the rendered result in real-time as they write. Users can easily change formatting of elements, for example, turn a level-2 heading into a level-3 heading, or format a portion of the text as a code snippet with the use of context menus that pop up when interacting with text elements.

Another great addition in their editor is the ability to move parts of the document around, which it turns out is a major productivity helper when you are optimizing the flow of the article and making your paragraphs and heading flow nicely. Gone are the days of copy and pasting.

Where the GitBook front-end makes heavy use of a JavaScript SPA component framework (React and the Slate.js framework for building rich-text editors), the building blocks for our application are much simpler, and most importantly: keep our domain model on the backend, which is also where all the rendering happens. By pairing Stimulus controllers with a few function specific libraries (tippy.js for tooltips, and rangy for working with text selections), we can achieve a highly reactive editor experience with less than 200 lines of JavaScript code.

As we are riding the version seven rails, we ditch Webpacker in favour of ESBuild, the new kid on the block for bundling JavaScript code, and we use dart-sass for building and bundling our stylesheets. It's quite surprising how much development productivity and velocity you get back when you don't have to wait 30+ seconds on Webpacker to do its job before you can refresh the webpage and check your code is working as intended.

Initial Project Setup

We start a brand new Rails 7 project with rails new wysiwyg -j esbuild --css bulma -d postgresql. In case you don't have Rails 7 installed yet, you can do so by running gem install rails --pre. The above command asks the Rails 7 generator to create a new project named "wysiwyg", and to use "esbuild" as the JavaScript bundler, as well as dart-sass pre-configured for the Bulma style framework as the CSS Bundler.

After changing into the project directory with cd wysiwyg, we create the PostgreSQL database that we use as backing storage for documents and document fragments with rails db:create, and run the initial migrations with rails db:migrate.

The Fragment Model

At the core of our editor will be the concept of a document fragment. Each block element of content, such as headings, paragraphs, code blocks, lists or images will have a one-to-one mapping onto a Fragment model that is stored in our database. A document is made up of the entire collection of document fragments. Since we want to be able to re-order document fragments, our model will keep track of the order in which fragments are supposed to appear in the document by a position attribute. For each fragment we track the type of block element represented by the document fragment, the data (text, code, image urls, ...) associated with the block element, and any additional metadata required to enrich the rendering of the block element (source code language for code elements, or image alt text for image block element).

With that, we are ready to create our Fragment model:

rails g model Fragment element:string data:string position:integer meta:string

rails db:migrate

To make working with positions easier, we will use the amazing acts_as_list gem. To add the dependency to our Gemfile, we run bundle add acts_as_list. To include the functionality provided by acts_as_list on our Fragment model, we use the acts_as_list macro in our model.

class Fragment < ApplicationRecord
  acts_as_list
end

Now we are ready to add a few simple Fragments for testing the base functionality and interactions. Let's add a page header, and two paragraphs to get started.

rails console

Fragment.create(element: "h1", data: "My Article")
Fragment.create(element: "p", data: "Welcome to my article!")
Fragment.create(element: "p", data: "In the following we will talk about everything")

Rendering Markdown

We want to store all fragment representations in the backend in such a way that they have a Markdown representation, and we can easily map between that Markdown representation and a HTML DOM representation inside our editor, or mathematically speaking we have meta-representation of our document as fragments such that there is a morphism f that maps fragments from a Markdown representation to an HTML representation and a morphism g that maps fragments from their HTML representation to a Markdown representation. In an ideal WYSIWYG editor world, f • g = g• f, that is, going from one representation to the other does not change the fragments.

It is for this very reason that we opted to store each document fragment in our backend as a composite of two attributes: element and data. In a Markdown representation each Fragment corresponds to a 'paragraph' of plaintext (that is any text separated by at least two newline characters), and is a self-contained unit of information. In Markdown, the first characters of the paragraph denote the 'flavour' of the information, for example, if a paragraph starts with one or more # characters, the semantics of the plaintext data following these characters separated by an empty space are that of a heading, where the number of # characters denote the heading level.

Things are very similar in the HTML DOM representation of the document fragments. Here, the 'flavour' of the information is denoted by a HTML element in the DOM tree, and the information itself is represented as text within the opening and closing tag that make up the HTML element.

With this knowledge, we need to decide the mechanisms that we want to use to translate:

  • an HTML representation of a document fragment to the meta representation stored in our backend
  • a Markdown representation of a document fragment to the meta representation stored in our backend
  • the meta representation stored in our backend to HTML
  • and the meta representation stored in our backend to Markdown

The last one is actually the easiest one: for each fragment, we can inspect the element type and data and follow the Markdown grammar rules to generate a Markdown representation by concatenating the Markdown representation of the element with the data. In our Fragment model, we can express this mapping from our meta representation to Markdown with the following code:

# app/models/fragment.rb

class Fragment < ApplicationRecord
  ...
  MD_MAPPING = {
    "h1" => "# %{data}",
    "h2" => "## %{data}",
    "h3" => "### %{data}",
    "p" => "%{data}",
    "ol" => "%{data}",
    "ul" => "%{data}",
    "pre" => "```%{meta}\n%{data}\n```",
    "img" => "![%{meta}](%{data})"
  }.freeze

  def to_md
    MD_MAPPING[element] % {data: data, meta: meta}
  end
  ...
end

For the translation of Markdown to HTML, we can use an off-the-shelf library for Ruby: a Markdown to HTML renderer provided by the redcarpet gem. We can add a custom renderer implementation to our application by adding a file app/lib/markdown_renderer.rb with the following contents:

# app/lib/markdown_renderer.rb

require 'redcarpet'
require 'rouge'
require 'rouge/plugins/redcarpet'

module MarkdownRenderer
  # Our own custom renderer
  class CustomHTML < Redcarpet::Render::HTML
    include Rouge::Plugins::Redcarpet

    def rouge_formatter(lexer)
      Rouge::Formatters::HTMLLegacy.new(
        css_class: "line-numbers language-#{lexer.tag.strip}",
      )
    end
  end

  def self.md_to_html(content, assigns = {})
    # Render the result via Redcarpet, using our Custom Renderer
    Redcarpet::Markdown.new(
      CustomHTML.new(link_attributes: { target: '_blank', rel: 'noopener' }),
      fenced_code_blocks: true,
      autolink: true,
      superscript: true,
      no_intra_emphasis: true,
      space_after_headers: false,
      highlight: true,
      with_toc_data: true
    ).render(content).html_safe
  end
end

We are subclassing CustomHTML from Redcarpet::Render::HTML so we can include the rouge library for syntax highlighting of fenced code blocks, and to inject the language-xxxx class required by front-end syntax highlighting libraries should be decide to use one of them later on. We also need to add the 'redcarpet' and 'rouge' libraries to our application dependencies by running bundle add redcarpet and bundle add rouge on the command line. With the Markdown renderer in place, we can update our Fragment model to include a render() method that turns the meta representation first into markdown, and with our MarkdownRenderer class the Markdown into HTML.

# app/models/fragment.rb

class Fragment < ApplicationRecord
  ...
  def render
    MarkdownRenderer.md_to_html(self.to_md)
  end
  ...
end

Rendering a Document

It's time to see our rendering code in action, and to render our collection of document fragments into a full HTML document. First, we add a new Document controller with an index action to our application by running rails g controller Document index on the command line. For simplicity sake, we will use this controller action as our application root path.

# config/routes.rb

Rails.application.routes.draw do
  root 'document#index'
end

The implementation of our index action is dead simple: we fetch all Fragments and order them by their position attribute, so they appear in the document in the same order that we defined, rather than the order of their unique id or creation timestamps.

# app/controllers/document_controller.rb

class DocumentController < ApplicationController
  def index
    @fragments = Fragment.all.order(position: :asc)
  end
end

Similarly, the view template is also straight forward at this point. We render a _fragment.html.erb partial for each element of our @fragments collection, where we call fragment.render() within the partial to get the HTML representation as generated by our MarkdownRenderer.

# app/views/document/index.html.erb

<section class="section">
  <div class="article container">
    <% @fragments.each do |fragment| %>
      <%= render fragment %>
    <% end %>
  </div>
</section>

# app/views/fragments/_fragment.html.erb

<div class="fragment <%= fragment.element %>">
  <%= fragment.render %>
</div>

At this point, we can start our application with rails s and for the first time see our test document rendered from our document fragments by opening http://localhost:3000 in a new browser window.

Making Things Interactive

So far our editor is only a viewer - we can render a document from the fragments representation in our backend, but we can't do much editing (yet). To add a rich editing experience to our application we will mainly draw on two core technologies that Rails 7 brings out of the box: Hotwire Turbo and Stimulus.

The Turbo framework will offer us the ability to process changes to document fragments in the backend application, and then render an updated representation of the fragment directly back onto the page, without the need for a full page refresh. We will make extensive use of Turbo Frames, which are small interactive windows of content where interactions lead to re-rendering and replacement of the content asynchronously through the client-side library that Turbo automatically brings to the table.

At the same time we will use Stimulus to define client-side interactions with our content, such as context menus that pop up when selecting text, or when we want to add additional fragments. Here, Stimulus takes on the job of a very lightweight JavaScript framework to define client interactions and how these interactions trigger actions on the backend.

Wrapping Fragments in Turbo Frames

As a pre-cursor to adding interactivity in our editor application, we will first wrap each rendered document fragment in its own individual turbo frame tag, with a DOM identifier that corresponds to the fragment's unique id as stored in the backend. We change our app/views/fragments/_fragment.html.erb partial like so:

# app/views/fragments/_fragment.html.erb

<%= turbo_frame_tag dom_id(fragment) do %>
  <div class="fragment <%= fragment.element %>">
    <%= fragment.render %>
  </div>
<% end %>

Any actions within the turbo frame that would normally lead to a page navigation event, now instead lead to a replacement of the turbo frame with the content that would have been rendered on a new page instead. That may sound confusing at first, but becomes super powerful and sexy really fast, because it's the magic that turns our Rails 7 application into something that closely resembles a modern reactive Single Page App.

Making Fragments Editable

With the Turbo Frames in place, we are ready to implement our first user story: "As a user I would like to be able to type directly on the HTML page to modify the contents of a document fragment". For our WYSIWYG editor, this interaction is unlocked with the contenteditable attribute that is supported in most modern web browsers, albeit each browser implementation being a bit different and leading to interesting side-effects if we are careless in our implementation (for example, an Enter key press may result in a br or a div element to be inserted, depending on which browser we are using).

To interact with each document fragment, we create a new Editable Stimulus controller defining 3 core interactions: click, blur and keydown. We want to control the switching on and off of the contenteditable attribute when a user clicks on a fragment, or the fragment loses focus respectively, as well as save all the edits to our fragment back to the backend when focus is lost.

// app/javascript/controllers/editable_controller.js

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  click(event) {
    this.element.setAttribute("contenteditable", "true")
    this.element.focus()
  }

  blur(event) {
    this.element.removeAttribute("contenteditable")
    this.save()
  }

  keydown(event) {
    if (event.keyCode == 13) {
      event.preventDefault()
      this.element.removeAttribute("contenteditable")
    }
  }

  save() {
    // TODO
  }
}

In Rails 7, Stimulus controllers are no longer automatically picked up, like was the case with webpack in Rails 6. Luckily, the stimulus-rails gem provides us with a rails task that makes adding controllers to our application bundle manifest quick and painless. Run rails stimulus:manifest:update and ESBuild will automatically pick up, compile and bundle our new controller into our application.js script bundle.

We also need to connect the new Editable controller to our Fragment element in the fragments/_fragment.html.erb partial to add the behavior to each fragment element in our browser.

<%= turbo_frame_tag dom_id(fragment) do %>

  <div
    class="fragment <%= fragment.element %>"
    data-controller="editable"
    data-action="click->editable#click blur->editable#blur keydown->editable#keydown">
    <%= fragment.render %>
  </div>

<% end %>

At this point, you can refresh your browser to reload http://localhost:3000 and try clicking on the heading or the paragraphs. You should get an edit cursor and you are able to insert, select, delete and replace text like you would in a regular editor. Common rich-text formatting shortcuts also work, try selecting some text and hitting Cmd or Ctrl+B (depending on whether you are following along on a Mac or PC) to mark some text bold. When we refresh the page, all edits are gone - we have yet to implement saving our fragment edits back to the application. We will take a look at that in the next section.

Saving Edits to the Backend

We can edit fragments now thanks to the contenteditable attribute and our Editable Stimulus controller, but right now when we hit the refresh button in our browser, all our changes are lost. Let's take a look at how we can automatically persist our edits when we are done typing (or hit Enter).

In general, we have two options to go about this task. In the "olden days", we wouldn't think twice and grab the Rails.ujs library to make an AJAX call to a backend API that would update our ActiveRecord model with the latest edits. While this works perfectly fine even in Rails 7 (we just have to add Rails.ujs back - it's removed by default), we loose the magic of Turbo Frames with this approach. Instead of AJAX calls, we will use the save() method in our Stimulus controller to populate and submit a hidden form to our backend. The backend will then persist the data in the ActiveRecord model and then return and updated render of the document fragment. These updates will automatically be picked up by Turbo and Turbo will replace the Turbo Frame contents of our fragment with the updated render.

Since Turbo does all of this (interception of the form submission, reading of the response and replacing the Turbo Frame) behind the scenes, automatically and with async calls, we get the same level of reactivity we would have had with an AJAX call!

First, let's create a new Rails controller to handle CRUD operations for our Fragment model. We create the file app/controllers/fragments_controller.rb with the following content:

# app/controllers/fragments_controller.rb

class FragmentsController < ApplicationController
  before_action :set_fragment, only: [:save]

  def save
    @fragment.update(data: params[:data])
    render @fragment
  end

  private

  def set_fragment
    @fragment = Fragment.find(params[:id])
  end
end

Next, we need a way to convert the HTML representation of a fragment into our meta representation that we store in our backend. There is an excellent JavaScript library out there called turndown that we can use to do exactly that. To add the turndown library to our project, we run yarn add turndown. The turndownService object is the core piece that when given an element in our browser's DOM parses the element subtree into Markdown. Through custom filters, we can manipulate the results further. Let's add a new file app/javascript/lib/turndown_service.js and define our custom turndownService.

import TurnDown from "turndown"

export function turndownService() {
  // Create a new instance of TurnDown to convert HTML to Markdown
    let service = new TurnDown({
      headingStyle: "atx",
      bulletListMarker: "-",

    })

    // Strip '#' from headings
    service.addRule("headings", {
      filter: ['h1', 'h2', 'h3'],
      replacement: function(content, node, options) {
        return content
      }
    })

    // Ignore <form></form> elements
    service.remove(['form'])

    return service
}

With our custom turndownService in place, we can now implement the save() method in our Editable Stimulus controller to turn the HTML DOM element to which the controller is connected to into a Markdown representation and save that data to our backend via a form submission.

import { Controller } from "@hotwired/stimulus"
import {turndownService} from "../lib/turndown_service"

export default class extends Controller {
  // ...

  save() {
    // Convert the element this controller is attached to
    let markdown = turndownService().turndown(this.element)

    // Dynamically fill out the form data and submit
    this.element.querySelector("#data").value = markdown
    this.element.querySelector("form").requestSubmit()
  }
}

Great! Let's head back into our browser at http://localhost:3000 and refresh the page to pick up the latest updates to our JavaScript code that ESBuild has prepared for us. Now, when you edit a fragment, such as the heading or a paragraph and hit Enter (or, click outside the fragment to blur the focus) your edits will be persisted to the database. In the rails console logs and in the browser XHR request logs we can see the emitted PATCH requests from the browser.

Formatting Fragment Text

Demo of formatting text

In the last section, we've played with making changes to our document fragments and saw our edits persisted in the backend. When editing text, we can press Cmd or Ctrl+B (depending on whether you are on a Mac or PC), and Cmd or Ctrl+I to apply bold or italic formatting to text while editing - similar to how we'd format a document in Word.

In addition to bold and italic formats for emphasis, we may also want to offer a way for users to easily format selected text as inline code, and strikethrough - two formats that are frequently used by writers of technical blog articles and which don't have keyboard shortcuts readily available. A common way for providing such formatting options is via a pop-up menu that appears after a portion of the text has been selected.

Unfortunately, the Web API does not currently provide us with an event for text selection outside of input and textarea elements. However, we can still keep track of text selections in arbitrary contenteditable elements by listening to mouseUp events and determining whether a text selection has happened after the mouse button was released by grabbing a Selection object using the rangy JavaScript library.

By now we have a pretty good idea which tool to grab in Rails 7 when we want to provide some form of interactivity in the browser... yeah that's right: Stimulus. We can extend our existing Editable Stimulus controller to listen for mouseUp events and tracking text selections. In case there is a selection, we can then use the amazing tippy.js JavaScript library to attach a tooltip to the bounding box of the selected text, and have the tooltip show an interactive menu of formatting options.

The actual formatting of the selected text is taken care of by the amazing rangy library, which offers a plugin called classApplier that we can use to wrap the selection in any tag and CSS class we chose, but more importantly also implements the quite complicated logic of removing the same element and class on the selection or parts of the selection where the same tag and class already exists - so we can easily toggle a formatting on and off.

Let's start off by adding a context menu with formatting options that pops up when a user selects text in a fragment by adding a mouseUp(event) function to our Editable Stimulus controller. We also add a corresponding mouseDown(event) function that we use to clear the current selection.

// app/javascript/controllers/editable_controller.js

import rangy from "rangy"
import "rangy/lib/rangy-textrange"
import { show_format_selection_menu } from "../lib/context_menus"

export default class extends Controller {
  // ...
  mouseDown(event) {
    rangy.getSelection().removeAllRanges()
  }

  mouseUp(event) {
    // get the current selection from window
    let selection = rangy.getSelection()

    // we can return early when the selection is collapsed
    if (selection.isCollapsed) { return }

    // Trim whitespace from the selection
    selection.trim()

    // show format selection menu
    show_format_selection_menu(this.element)
  }  
  // ...
}

Next, we will create a new file app/javascript/lib/context_menus.js that will contain all the code related to the various context menus in our editor and implement the show_format_selection_menu function we use in our mouseUp event.

// app/javascript/lib/context_menus.js

import tippy from "tippy.js"

function format_selection_menu() {
  return(`
  <div class="p-1" data-controller="format">
    <a class="has-text-white" data-action="mousedown->format#bold">
      Bold
    </a>
    <span class="ml-1 mr-1 has-text-grey">|</span>
    <a class="has-text-white" data-action="mousedown->format#italic">
      Italic
    </a>
    <span class="ml-1 mr-1 has-text-grey">|</span>
    <a class="has-text-white" data-action="mousedown->format#strikethrough">
      Strike
    </a>
    <span class="ml-1 mr-1 has-text-grey">|</span>
    <a class="has-text-white" data-action="mousedown->format#code">
      Code
    </a>
  </div>
  `)
}

export function show_format_selection_menu(element) {
  let box = window.getSelection().getRangeAt(0).getBoundingClientRect()
  return tippy(element, {
    allowHTML: true,
    content: format_selection_menu(),
    interactive: true,
    interactiveBorder: 100,
    inlinePositioning: true,
    maxWidth: 250,
    getReferenceClientRect: () => box,
    onHidden: (instance) => {instance.destroy()}
  }).show()
}

The format_selection_menu function responsible for creating the markup that we want to show in the interactive tooltip makes use of a new Stimulus controller called Format that we add to our application at app/javascript/controllers/format_controller.js. After adding the new controller script, we need to run rails stimulus:manifest:update for ESBuild to pick up our new Stimulus controller.

// app/javascript/controllers/format_controller.js

import { Controller } from "@hotwired/stimulus"
import rangy from "rangy"
import "rangy/lib/rangy-classapplier"

export default class extends Controller {
  bold(event) {
    event.preventDefault()
    let applier = rangy.createClassApplier("bold", { elementTagName: "strong" })
    applier.toggleSelection()
  }

  italic(event) {
    event.preventDefault()
    let applier = rangy.createClassApplier("italic", { elementTagName: "em" })
    applier.toggleSelection()
  }

  strikethrough(event) {
    event.preventDefault()
    let applier = rangy.createClassApplier("strikethrough", { elementTagName: "del" })
    applier.toggleSelection()
  }

  code(event) {
    event.preventDefault()
    let applier = rangy.createClassApplier("code", { elementTagName: "code" })
    applier.toggleSelection()
  }
}

Finally we need to connect the mouseup and mousedown events to our mouseDown and mouseUp functions of our Stimulus controller inside the app/views/fragments/_fragment.html.erb partial.

<%= turbo_frame_tag dom_id(fragment) do %>

  <div
    class="fragment <%= fragment.element %>"
    data-controller="editable"
    data-action="
      click->editable#click
      blur->editable#blur
      keydown->editable#keyDown
      mouseup->editable#mouseUp
      mousedown->editable#mouseDown">

    <%= fragment.render %>

    <%= form_with(url: fragment_path(fragment.id), method: :patch) do |form| %>
      <%= form.text_area(:data, hidden: true) %>
      <%= form.submit("Save", hidden: true) %>
    <% end %>

  </div>

<% end %>

With everything set up, we can refresh our browser window to reload http://localhost:3000 and try selection some text. A popup window opens above the selection with our four formatting options. Click on any of the options to apply that formatting to the selection.

Creating New Fragments

Demo of creating document fragments

Now that we can edit and format the contents of our existing fragments, let's consider how we can add new fragments to our document. We had already created a few paragraph fragments manually using the Rails console at the beginning of this article, but let's consider how we can do this from the editor UI directly. Creating new Fragments is a matter of adding new records of our Fragment model to our database. All we need to do is to drive that creation from a front-end interaction. In this case, we don't even need a Stimulus Controller, and can simply resort to a regular old form element - the front-end magic is again taken care of by Turbo Frames as long as our create action returns a redirect. First we create a new endpoint in our backend that when called creates a new empty fragment based on the data passed in via request parameters. Let's add this endpoint to our existing FragmentsController under app/controllers/fragments_controller.rb.

class FragmentsController < ApplicationController
  before_action :set_fragment, only: [:update]

  def create
    @fragment = Fragment.new(fragment_params)
    if @fragment.valid?
      @fragment.save
    end
    redirect_to(root_path)
  end

  # ...

  private

  # ...

  def fragment_params
    params.require(:fragment).permit(:element, :data, :meta, :position)
  end
end

Next, let's create a new partial at app/views/fragments/_new.html.erb that contains the markup for a hidden form and the only visible element: the form submit button we use to call the create action on our Fragments controller.

<div class="add-fragment">
  <div class="add-fragment-button-container">
    <%= form_with(model: Fragment.new(
        element: "p",
        data: "edit me",
        position: (fragment.position + 1))) do |form| %>
      <%= form.text_field :element, hidden: true %>
      <%= form.text_field :data, hidden: true %>
      <%= form.text_field :position, hidden: true %>

      <button
        class="button is-light is-round"
        data-controller="add-fragment"
        data-action="click->add-fragment#click">
        +
      </button>
    <% end %>
  </div>
  <hr class="add-fragment-indicator"></hr>
</div>

As we want to have the ability to insert a new document fragment at any position before or after existing fragments, we want to render the form contained in the partial after each fragment Turbo Frame. Add the following line to app/views/fragments/_fragment.html.erb:

<%= turbo_frame_tag dom_id(fragment) do %>
  <!-- ... -->
<% end %>

<%= render "fragments/new", fragment: fragment %>

All that's left to do for this feature is to implement some CSS markup to make things look nice.

.add-fragment {
  opacity: 0;
  position: relative;
  height: 5px;
  left: -50px;
  display: flex;
  flex-direction: row;
  align-items: center;
  transition: opacity 0.25s ease;

  &:hover {
    opacity: 1;
    transition: opacity 0.25s ease;
  }

  .add-fragment-button-container {
    width: 50px;
  }

  .add-fragment-indicator {
    flex-grow: 1;
    width: 100%;
    height: 1px;
    background-color: $link;
  }
}

.button.is-round {
  border-radius: 100%;
  padding: 0;
  margin: 0;
  width: 2em;
  height: 2em;
}

Let refresh the editor page in our browser pointed to http://localhost:3000 and see our feature in action. When we hover our mouse in the space between two fragments, a blue divider bar appears with a circular plus button. When we click on the button, a new paragraph fragment is inserted at that position.

What's Next?

Wow! We went through quite a lot of features already in setting up our initial editor. In the next part of our series, we will be looking at some quality-of-life features that will go a long way for making the experience of authoring Markdown content more productive and pleasurable. In particular, we will be looking at how we can easily re-order document fragments via drag and drop, how we can delete fragments altogether, as well as converting existing document fragments into other fragment types.