This article was originally published on Rails Designer
In the first part of Build a Notion-like editor with Rails article, the foundation was built by creating the data model and the basic HTML layout. This article continues where that article ended.
Besides making things look a fair bit prettier with Tailwind CSS, there will also be a fair amount of JavaScript written using Stimulus.
Above GIF is what the aim is for this second part.
Updating the UI with Tailwind CSS
Let's update the two views that are there now. While I often look at bare HTML to focus on the functionality first, the designer in me needs to add some CSS rather sooner than later. Let's update some of the partials to make things more editor-like:
# app/views/pages/edit.html.erb
<ol id="blocks" class="flex flex-col mx-auto max-w-prose gap-y-4">
<%= render partial: "pages/block", collection: @page.blocks %>
</ol>
<%= button_to "Add text field", page_blocks_path(@page), params: {blockable_type: "Block::Text"} %>
<%# etc. %>
Just some classes to the ol-element to space things a bit better.
# app/views/pages/_block.html.erb
<li>
<%= form_with model: [block.page, block] do |form| %>
<%= form.fields_for :blockable do |blockable_form| %>
<%= render partial: "blocks/editable/#{block.blockable_type.underscore}",
locals: {
form: blockable_form, base_css: "w-full field-sizing resize-none focus:outline-0"
}
%>
<% end %>
<% end %>
</li>
Let's remove the submit button and pass some base CSS classes that are needed for every block. Notice the field-sizing
class? It will resize the textarea based on its content. It is still an experimental feature and support is limited, so for browsers that don't support it, a Stimulus controller will be added.
# app/views/blocks/editable/block/_heading.html.erb
<%# locals: (form:, base_css:) -%>
<%= form.text_area :content, rows: 1, class: class_names(base_css, "font-bold text-gray-800", { "text-5xl": form.object.level == 1, "text-3xl": form.object.level == 2, "text-2xl": form.object.level == 3, "text-xl": form.object.level == 4 }) %>
Here the the base_css
is passed along with some level-specific classes for the font-size.
# app/views/blocks/editable/block/_text.html.erb
<%# locals: (form:, base_css:) -%>
<%= form.text_area :content, rows: 1, class: class_names(base_css, "text-lg font-gray-900") %>
Just some basic CSS for the text blocks.
And with that the editor is already looking better. Nice work, Picasso! π¨
Making things work smoothly using Stimulus
You might see the editor and think to create one Stimulus controller for it. But I see functionality that can be used elsewhere as well, so instead I am going to create a few controllers. Let's add them from simplest to more involved.
Resizing the textarea
First is the controller to resize the blocks. This is to make sure the textarea's show all the content without the user need to scroll, that would look weird.
// app/javascript/controllers/resizer_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
connect() {
this.now()
}
now() {
this.element.style.height = "auto"
this.element.style.height = `${this.element.scrollHeight}px`
}
}
The now
method (see later why it is named this) simply sets the element's height and then overrides that with the element's scroll height.
Let's pass this data controller to app/views/pages/_block.html.erb:
# app/views/pages/_block.html.erb
<li>
<%= form_with model: [block.page, block] do |form| %>
<%= form.fields_for :blockable do |blockable_form| %>
<%= render partial: "blocks/editable/#{block.blockable_type.underscore}",
locals: {
form: blockable_form,
data: {
controller: "resize",
action: "
resize#now // See how this reads nicely? Also putting the action on a separate line as it won't be the last action
"
},
base_css: "w-full field-sizing resize-none focus:outline-0"
}
%>
<% end %>
<% end %>
</li>
Let's make sure they are now actually used in both app/views/blocks/editable/block/_text.html.erb:
<%# locals: (form:, base_css:, data: {}) -%>
<%= form.text_area :content, id: dom_id(form.object, :content), rows: 1, data: data, class: class_names(base_css, "text-lg font-gray-900") %>
And app/views/blocks/editable/block/_heading.html.erb:
<%# locals: (form:, base_css:, data: {}) -%>
<%= form.text_area :content, rows: 1, id: dom_id(form.object, :content), data: data, class: class_names(base_css, "font-bold text-gray-800", { "text-5xl": form.object.level == 1, "text-3xl": form.object.level == 2, "text-2xl": form.object.level == 3, "text-xl": form.object.level == 2 }) %>
I've also added an id
using the dom_id
helper to both partials. When you add more text to the textarea beyond the one row, you will see it gets expanded. Onto the next step.
Auto-update content
You don't want to have a Save button, but instead auto-save content when new content is added. This is easily done. Let's update the HTML (following the outside-in approach). This will guide you to create the Stimulus controller first and then the Rails controller.
# app/views/pages/_block.html.erb
<li>
<%= form_with model: [block.page, block], data: {controller: "form"} do |form| %>
<%= form.fields_for :blockable do |blockable_form| %>
<%= render partial: "blocks/editable/#{block.blockable_type.underscore}",
locals: {
form: blockable_form,
data: {
controller: "resize",
action: "
resize#now
form#submit
"
},
base_css: "w-full field-sizing resize-none focus:outline-0"
}
%>
<% end %>
<% end %>
</li>
Added are the data-attributes to the form_with
block and the form#submit
to the data-action
. Let's create the new controller now.
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = {
debounce: { type: Number, default: 500 }
}
initialize() {
this.timeout = null
}
disconnect() {
clearTimeout(this.timeout)
}
submit() {
clearTimeout(this.timeout)
this.timeout = setTimeout(() => this.element.requestSubmit(), this.debounceValue)
}
}
I think it's straight-forward to follow, but:
- it creates a custom βdebounceβ feature;
- then submits the form (
this.element
) using requestSubmit.
Any time the content is changed, the form is submitted. Great! This is the kind of controller you can use many times over (hence why I choose to use various controllers instead too).
Add new blocks on enter
When you press enter a new block should be created. This can be done with Stimulus and a Turbo Stream. Let's start with the HTML again.
# app/views/pages/edit.html.erb
<ol id="blocks" data-controller="blocks" data-blocks-create-url-value="<%= page_blocks_path(@page) %>" class="flex flex-col mx-auto max-w-prose gap-y-4">
<%= render partial: "pages/block", collection: @page.blocks %>
</ol>
<%# etc. %>
Add two data attributes: data-controller
and data-blocks-create-url-value
.
# app/views/pages/_block.html.erb
<li>
<%= form_with model: [block.page, block], data: {controller: "form"} do |form| %>
<%= form.fields_for :blockable do |blockable_form| %>
<%= render partial: "blocks/editable/#{block.blockable_type.underscore}",
locals: {
form: blockable_form,
data: {
controller: "resize",
blocks_target: "field",
action: "
resize#now
form#submit
keydown.enter->blocks#create:prevent
"
},
base_css: "w-full field-sizing resize-none focus:outline-0"
}
%>
<% end %>
<% end %>
</li>
Added is the block_target: "field"
and adding keydown.enter->blocks#create:prevent
: on keydown of enter run create on blocks controller. Unsure what :prevent
does? Read this article on custom action options in Stimulus.
Now onto that blocks controller:
import { Controller } from "@hotwired/stimulus"
import { post } from "@rails/request.js"
export default class extends Controller {
static targets = ["field"]
static values = { createUrl: String, type: { type: String, default: "Block::Text" } }
async create(event) {
if (this.#notLastField(event.target)) return
if (this.#notAtEnd(event.target)) return
await post(this.createUrlValue, { body: JSON.stringify({ blockable_type: this.typeValue }), responseKind: "turbo-stream" })
}
// private
#notLastField(field) {
return this.fieldTargets.indexOf(field) < this.fieldTargets.length - 1
}
#notAtEnd(field) {
return field.selectionStart < field.value.length
}
}
This controller too is straightforward to read, I think. If not a last field or at the end of the field, return early. Otherwise send a POST request to the given createUrlValue
. Don't forget to install @rails/request.js using your favorite tool (yarn, npm, importmap)!
All that is left is to update the create action on the BlocksController and create the turbo stream response.
class Pages::BlocksController < ApplicationController
before_action :set_page, only: %w[create]
def create
@block = @page.blocks.create!(
blockable: params[:blockable_type].constantize.new(new_block_params)
)
end
# β¦
end
<%= turbo_stream.append "blocks" do %>
<%= render partial: "pages/block", locals: { block: @block } %>
<template
data-controller="focus"
data-focus-selector-value="#<%= dom_id(@block.blockable, :content) %>"
></template>
<% end %>
Hold up. There is one more Stimulus controller needed. This one is sent along with the turbo stream and will set the focus to the newly created textarea. It's a technique I learned from Matt Swanson.
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = {
selector: String
}
connect() {
this.#setFocus()
this.element.remove()
}
// private
#setFocus() {
if (!this.hasSelectorValue) return
requestAnimationFrame(() => {
document.querySelector(this.selectorValue)?.focus()
})
}
}
You will pass it the selector you want to set the focus on and then the controller removes itself from the DOM. What's the requestAnimation
doing there? It says: βwait for the next screen update before trying to find and focus this elementβ (instead of manually adding a delay using setTimeout
).
Feel overwhelmed? All a bit much? Check out JavaScript for Rails Developers. π‘
Alright! Now you can press enter on the last textarea and it will create a new textarea and set the focus. π
Navigate blocks with arrow keys
Because all blocks are separate textarea's it is not possible to navigate them using the arrow (up, right, down, left) keys. Let's make that happen now. Again first the HTML:
# app/views/pages/edit.html.erb
<ol id="blocks" data-controller="blocks navigation" data-blocks-create-url-value="<%= page_blocks_path(@page) %>" class="flex flex-col mx-auto max-w-prose gap-y-4">
<%= render partial: "pages/block", collection: @page.blocks %>
</ol>
<%# etc. %>
Added navigation
to data-controller
. Let's added the actions needed:
# app/views/pages/_block.html.erb
<li>
<%= form_with model: [block.page, block], data: {controller: "form"} do |form| %>
<%= form.fields_for :blockable do |blockable_form| %>
<%= render partial: "blocks/editable/#{block.blockable_type.underscore}",
locals: {
form: blockable_form,
data: {
controller: "resize",
blocks_target: "field",
navigation_target: "field",
action: "
resize#now
form#submit
keydown.enter->blocks#create:prevent
keydown.up->navigation#up
keydown.right->navigation#right
keydown.down->navigation#down
keydown.left->navigation#left
"
},
base_css: "w-full field-sizing resize-none focus:outline-0"
}
%>
<% end %>
<% end %>
</li>
With the navigation_target
added and those four new actions added, create the navigation controller:
// app/javascript/controllers/navigation_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["field"]
up(event) {
this.#navigateTo(event, -1)
}
down(event) {
this.#navigateTo(event, 1)
}
left(event) {
const { target } = event
if (target.selectionStart !== 0) return
this.#navigateTo(event, -1)
}
right(event) {
const { target } = event
if (target.selectionEnd !== target.value.length) return
this.#navigateTo(event, 1, 0)
}
// private
#navigateTo(event, direction, position) {
const currentIndex = this.fieldTargets.indexOf(event.target)
const newIndex = currentIndex + direction
if (newIndex < 0 || newIndex >= this.fieldTargets.length) return
event.preventDefault()
const field = this.fieldTargets[newIndex]
field.focus()
const cursorPosition = position ?? field.value.length
field.setSelectionRange(cursorPosition, cursorPosition)
}
}
This controller too, while a bit chunky, is good to follow. Each method (up, right, down, left) has some guards before they call the private #navigateTo
. Two things of note are:
- destructuring syntax:
const { target } = event
creates a new constant variable βtarget
with the value ofevent.target
; -
??
is a nullish coalescing operator; useposition
if notnull
orundefined
; otherwise, it will use βfield.value.length
as a fallback value (similar to Ruby's "safe navigation":cursor_position = position || field.value.length
).
Scared by this? Check out JavaScript for Rails Developers. π‘
Remove empty blocks
Still with me? π¬ Currently if you remove all content from a block. That is it. The block is kept around but not visible, causing confusing behavior. Let's add that functionality now. Again starting with the HTML:
# app/views/pages/_block.html.erb
<li>
<%= form_with model: [block.page, block], data: {controller: "form"} do |form| %>
<%= form.fields_for :blockable do |blockable_form| %>
<%= render partial: "blocks/editable/#{block.blockable_type.underscore}",
locals: {
form: blockable_form,
data: {
controller: "resize",
blocks_target: "field",
navigation_target: "field",
navigation_endpoint_param: page_block_path(block.page, block),
action: "
resize#now
form#submit
keydown.enter->blocks#create:prevent
keydown.up->navigation#up
keydown.right->navigation#right
keydown.down->navigation#down
keydown.left->navigation#left
keydown.backspace->navigation#back
"
},
base_css: "w-full field-sizing resize-none focus:outline-0"
}
%>
<% end %>
<% end %>
</li>
All that is needed is the navigation_endpoint_param
and the keydown.backspace->navigation#back
action. Let's add that method to the navigation controller created earlier:
// app/javascript/controllers/navigation_controller.js
import { Controller } from "@hotwired/stimulus"
import { destroy } from "@rails/request.js"
export default class extends Controller {
static targets = ["field"]
async back(event) {
if (event.target.value !== "") return
this.#navigateTo(event, -1)
const response = await destroy(event.params.endpoint)
if (response.ok) event.target.closest("li").remove()
}
// β¦
This method first checks if the value is not empty. But if it is:
- move to the previous textarea;
- send a request the endpoint send as a action param (see this article to learn more);
- if the response is
ok
(200..299), remove the parent'sli
element.
If you check out the default KeyboardEvent Filters you notice backspace is not available. Luckily you can extend the schema to include it. Update app/javascript/controllers/application.js as follows:
// app/javascript/controllers/application.js
import { Application, defaultSchema } from "@hotwired/stimulus" // get `defaultSchema` from the package
const customSchema = {
...defaultSchema,
keyMappings: {
...defaultSchema.keyMappings,
backspace: "Backspace"
}
}
const application = Application.start(document.documentElement, customSchema) // pass the root element + the new customSchema
One last thing for this feature to be ready is to create the destroy action in the BlocksController. Luckily it is simple:
class Pages::BlocksController < ApplicationController
# β¦
def destroy
Block.find(params[:id]).destroy
end
# β¦
end
Update type of block
I want to conclude with one more feature, updating the block's type. This will be possible in two ways:
- hovering using the button, left from the block;
- pressing
/
in the textarea (that shows the dropdown with the options).
As done before, let's again start with HTML:
<%%# app/views/pages/_block.html.erb %>
<li id="<%= dom_id(block, :item) %>" data-controller="dropdown" class="relative flex items-start gap-x-2 group/item">
<button data-action="" class="text-gray-400 opacity-0 shrink-0 translate-y-1 transition group-hover/item:opacity-100 hover:text-gray-700 peer">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-grip-vertical"><circle cx="9" cy="12" r="1"/><circle cx="9" cy="5" r="1"/><circle cx="9" cy="19" r="1"/><circle cx="15" cy="12" r="1"/><circle cx="15" cy="5" r="1"/><circle cx="15" cy="19" r="1"/></svg>
</button>
<ul data-dropdown-target="items" class="absolute z-10 hidden pb-1 bg-white border shadow-md bottom-top right-full rounded-md hover:block peer-hover:block">
<li>
<%= button_to "paragraph",
page_block_type_path(block.page, block),
method: :patch,
params: {
block: {
blockable_type: "Block::Text",
blockable_attributes: {id: block.blockable_type == "Block::Heading" ? block.blockable_id : nil }
}
},
class: "block px-2 py-1"
%>
</li>
<li>
<%= button_to "h1",
page_block_type_path(block.page, block),
method: :put,
params: {
block: {
blockable_type: "Block::Heading",
blockable_attributes: {level: 1, id: block.blockable_type == "Block::Heading" ? block.blockable_id : nil }
}
},
class: "block px-2 py-1"
%>
</li>
<%# β¦ %>
</ul>
</li>
This is adding quite a few things to the partial:
-
data-controller
,id
andclass
to the li-element; - button and the ul-element with the type options and buttons to change it.
Then add keydown.slash->dropdown#show:prevent
to the actions passed to the block's partial.
Let's create the dropdown Stimulus controller, which is really simple (and probably could do with a different name, eg. toggle-class
):
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["items"]
show() {
this.itemsTarget.classList.toggle("hidden")
}
}
Let's also extend the customSchema
above to allow /
:
// app/javascript/controllers/application.js
import { Application, defaultSchema } from "@hotwired/stimulus"
const customSchema = {
...defaultSchema,
keyMappings: {
...defaultSchema.keyMappings,
backspace: "Backspace",
slash: "/"
}
}
Now the logic to actually update the type. It's fairly straight-forward, but the logic needed is a bit involved. The reason is that the data model is using Delegated Type, so to change the type, a new associated object needs to be created.
Let's go over it step-by-step, add the route:
resources :pages, only: %w[show edit] do
# β¦
resources :block_types, module: :pages, only: %w[update]
end
Next the controller:
class Pages::BlockTypesController < ApplicationController
def update
@block = Block.find(params[:id])
target_type = block_params[:blockable_type]
blockable_attributes = block_params[:blockable_attributes] || {}
if target_type && @block.blockable_type != target_type
@block.change_blockable_type(target_type, blockable_attributes)
elsif blockable_attributes.present?
@block.blockable.update!(blockable_attributes)
end
end
private
def block_params
params.require(:block).permit(
:blockable_type,
blockable_attributes: [:id, :level, :content]
)
end
end
The controller checks if the incoming block type (target_type
) is different from the current block's type (@block.blockable_type
), and if so, calls change_blockable_type
to transform the block into a new type while preserving/updating its attributes.
Then add the change_blockable_type
method to Block:
# app/models/block.rb
class Block < ApplicationRecord
# β¦
def change_blockable_type(target_type, attributes = {})
return false if blockable_type == target_type || target_type.blank?
target_class = target_type.constantize
current_blockable = blockable
transferable_columns = target_class.column_names - ["id", "created_at", "updated_at"]
transferable_attributes = current_blockable.attributes.slice(*transferable_columns)
transferable_attributes.merge!(attributes) if attributes.present?
transaction do
replacement = target_class.create!(transferable_attributes)
update!(blockable: replacement)
current_blockable.destroy!
end
true
end
end
- create a new block of the target type while copying over all relevant attributes from the old block (excluding Rails' standard columns);
- updating the delegated type association to point to the new block;
- deleting the old block in a database transaction.
Yep, as said: it is quite involved. π¬
But now you can update the block's type by selecting any of the buttons. Cool, right?!
Again the GIF of the end result:
Next steps
Well high-five to you for reading all the way to here. π You just read the longest article I published here. That was quite a lot, wasn't it?! While this is a good foundation for a Notion-like editor, there are still things to add next. π
- keep track of block position, so you can re-order and add new blocks in between blocks;
- improve arrow-navigation;
- arrow up/down navigation for the types-dropdown;
- inline formatting options;
- more block types.
If thereβs enough interest, I might publish one more follow up with these next steps. π€
And there you have it. A basic Notion-like editor using Rails, Tailwind CSS and Hotwire. Is there anything you are struggling with? Let me know below. π
Top comments (0)