DEV Community

Rails Designer
Rails Designer

Posted on • Originally published at railsdesigner.com

Build a Notion-like editor with Rails, part 2

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.

Image description

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. %>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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 }) %>
Enter fullscreen mode Exit fullscreen mode

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") %>
Enter fullscreen mode Exit fullscreen mode

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`
  }
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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") %>
Enter fullscreen mode Exit fullscreen mode

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 }) %>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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)
  }
}
Enter fullscreen mode Exit fullscreen mode

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. %>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
<%= 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 %>
Enter fullscreen mode Exit fullscreen mode

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()
    })
  }
}
Enter fullscreen mode Exit fullscreen mode

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. %>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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)
  }
}
Enter fullscreen mode Exit fullscreen mode

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 of event.target;
  • ?? is a nullish coalescing operator; use position if not null or undefined; 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>
Enter fullscreen mode Exit fullscreen mode

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()
  }

  // …
Enter fullscreen mode Exit fullscreen mode

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's li 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

This is adding quite a few things to the partial:

  • data-controller, id and class 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")
  }
}
Enter fullscreen mode Exit fullscreen mode

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: "/"
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
  1. create a new block of the target type while copying over all relevant attributes from the old block (excluding Rails' standard columns);
  2. updating the delegated type association to point to the new block;
  3. 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:

Image description

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)