DEV Community

Rails Designer
Rails Designer

Posted on • Originally published at railsdesigner.com

Build a Notion-like editor with Rails

This article was originally published on Rails Designer


Notion had for a long-time a neat block-based editor. It allows you to type away with a paragraph-element by default, but allows you to choose other block elements, like h1, ul and so on. This allows you to style the elements inline as well, keeping things super clear.

This article shows you the basic data modeling and logic to set up. Enhancing it with JavaScript (Stimulus) and making things pretty will happen in a following article.

Data model

I am keeping this set up basic, with just a page model to hold the blocks, but in a real application a page might belong to something like a Collection. I am also not adding all possible blocks, but feel free to reach out if you need specific guidance.

First the Page model:

rails g model Page
Enter fullscreen mode Exit fullscreen mode
# Let's add the association already into app/models/page.rb
class Page < ApplicationRecord
  has_many :blocks, dependent: :destroy
end
Enter fullscreen mode Exit fullscreen mode

Simple enough. For the Blocks I will be using DelegatedType. This allows you to have shared attribute in one Block model, while having specific Block attributes in their own. It works perfect for the editor.

rails g model Block page:belongs_to blockable:belongs_to{polymorphic}
Enter fullscreen mode Exit fullscreen mode

Then the different Blocks:

rails generate model Block::Text content:text
rails generate model Block::Heading level:integer content:string
Enter fullscreen mode Exit fullscreen mode

Let's run rails db:migrate and make some changes to the the model files:

# app/models/block.rb
class Block < ApplicationRecord
  belongs_to :page

  delegated_type :blockable, types: %w[Block::Text Block::Heading]
end

# app/models/block/text.rb
class Block::Text < ApplicationRecord
  has_one :block, as: :blockable, dependent: :destroy
end

# app/models/block/heading.rb
class Block::Heading < ApplicationRecord
  has_one :block, as: :blockable, dependent: :destroy

  validates :level, inclusion: {in: 1..6}
end
Enter fullscreen mode Exit fullscreen mode

Let's also seed the database so we have some semi-real data to look at:

# db/seeds.rb
page = Page.create

blocks = [
  { type: "Block::Heading", attributes: { level: 1, content: "Welcome to Rails Wonderland" } },
  { type: "Block::Text", attributes: { content: "Once upon a time, in a land full of gems, there was a brave developer named Ruby." } },
  { type: "Block::Heading", attributes: { level: 2, content: "The Quest for the Perfect Gem" } },
  { type: "Block::Text", attributes: { content: "Ruby embarked on a quest to find the perfect gem, one that would solve all N+1 queries." } },
  { type: "Block::Heading", attributes: { level: 3, content: "Enter the Realm of Active Record" } },
  { type: "Block::Text", attributes: { content: "In the mystical realm of Active Record, Ruby learned the ancient art of associations." } },
  { type: "Block::Heading", attributes: { level: 3, content: "The Trials of Migration" } },
  { type: "Block::Text", attributes: { content: "With every migration, Ruby grew stronger, mastering the power of schema changes." } },
  { type: "Block::Text", attributes: { content: "And thus, the legend of Ruby and the Rails was born, inspiring developers across the world." } }
]

blocks.each do |block_data|
  blockable = block_data[:type].constantize.create(block_data[:attributes])

  Block.create(page: page, blockable: blockable)
end
Enter fullscreen mode Exit fullscreen mode

And run it: rails db:seed.

And finally a basic route, controller + view and partials:

# config/routes.rb
Rails.application.routes.draw do
  resources :pages, only: %w[show]
end

# app/controllers/pages_controller.rb
class PagesController < ApplicationController
  def show
    @blocks = Page.find(params[:id]).blocks
  end
end
Enter fullscreen mode Exit fullscreen mode
# app/views/pages/show.html.erb
<%= render @blocks %>

# app/views/blocks/_block.html.erb
<%= render "blocks/blockable/#{block.blockable_name}", block: block %>

# app/views/blocks/blockable/_block_heading.html.erb
<%= content_tag "h#{block.block_heading.level}", block.block_heading.content %>

# app/views/blocks/blockable/_block_text.html.erb
<%= content_tag :p, block.block_text.content %>
Enter fullscreen mode Exit fullscreen mode

Wow, that was a lot. But if you navigate to http://localhost:3000/pages/1 you should see the rendering of your first block-based page. Yay! πŸŽ‰

Basic editor

With the basic modeling in place and being able to render the page's blocks, let's make the page editable. I like to start with the most basic version and then enhance using JavaScript.

First the route, controller and view:

# config/routes.rb
Rails.application.routes.draw do
    resources :pages, only: %w[show edit] do
        resources :blocks, module: :pages, only: %w[create update]
    end
end

# app/controllers/pages_controller.rb
class PagesController < ApplicationController
  before_action :set_page, only: %w[show edit]

  def show
    @blocks = @page.blocks
  end

  def edit
  end

  private

  def set_page
    @page = Page.find(params[:id])
  end
end
Enter fullscreen mode Exit fullscreen mode
# app/views/pages/edit.html.erb
<ol id="blocks">
  <%= render partial: "pages/block", collection: @page.blocks %>
</ol>

# app/views/pages/_block.html.erb
<li>
  <%= form_with model: [block.page, block] do |form| %>
    <%= form.fields_for :blockable do |blockable_form| %>
      <%= render "blocks/editable/#{block.blockable_type.underscore}", form: blockable_form %>
    <% end %>

    <%= form.submit %>
  <% end %>
</li>
Enter fullscreen mode Exit fullscreen mode

Because each block has its own β€œblockable” which in turn can have different fields for each, let's create a different partial for each:

# app/views/blocks/editable/block/_heading.html.erb
<%= form.text_field :level %>
<br>
<%= form.text_area :content %>

# app/views/blocks/editable/block/_text.html.erb
<%= form.text_area :content %>
Enter fullscreen mode Exit fullscreen mode

Let's continue by also allowing to actually update Γ‘nd create new blocks as well:

# app/controllers/pages/blocks_controller.rb
class Pages::BlocksController < ApplicationController
  before_action :set_page, only: %w[create update]

  def create
    @page.blocks.create!(
      blockable: params[:blockable_type].constantize.new(new_block_params)
    )

    redirect_to edit_page_path(@page)
  end

  def update
    Block.find(params[:id]).update(existing_block_params)

    redirect_to edit_page_path(@page)
  end

  private

  def set_page
    @page = Page.find(params[:page_id])
  end

  def new_block_params
    params.permit(blockable_attributes: [:level])[:blockable_attributes].to_h.compact_blank
  end

  def existing_block_params
    params.require(:block).permit(:id, blockable_attributes: [:id, :level, :content])
  end
end
Enter fullscreen mode Exit fullscreen mode

Now let's add a few buttons to create a new blockr Update the pages#edit page:

<ol id="blocks">
  <%= render partial: "pages/block", collection: @page.blocks %>
</ol>

<%= button_to "Add paragraph", page_blocks_path(@page), params: {blockable_type: "Block::Text"} %>
<%= button_to "Add h1",
    page_blocks_path(@page),
    params: {
      blockable_type: "Block::Heading",
      blockable_attributes: {level: 1}
    }
%>
<%= button_to "Add h2",
    page_blocks_path(@page),
    params: {
      blockable_type: "Block::Heading",
      blockable_attributes: {level: 2}
    }
%>
<%# etc %>
Enter fullscreen mode Exit fullscreen mode

This is what you should have now:

Image description

What's next?

Now all the basics are in place. New blocks can be created and existing blocks can be updated. In an upcoming article I want to expand on this foundation and add a small Stimulus controller to improve the UX and also include Tailwind CSS to make things look a fair bit better.

Stay tuned! πŸ‘‚

Top comments (0)