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.
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.
Table of Contents
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
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
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.