DEV Community

Rails Designer
Rails Designer

Posted on • Originally published at railsdesigner.com

2

Create a Markdown-Powered Textarea with Stimulus

This article was originally published on Rails Designer


Today I want to explore how to recreate (most of) GitHub's markdown-powered textarea. It is a feature I want to add to Rails Designers (private community for Rails UI engineers) and thought it would be nice to share my first version with you.

I like this approach as the HTML's textarea is available in all browsers and can be used to write any comment, prose or whatever else needed with markdown. JavaScript is not needed, but if present it enhances the experience a fair bit.

What I intend to add today:

  • basic formatting options (bold, italic, etc.);
  • paste urls to markdown;
  • fetch page title from URL;
  • drag & drop images to upload with ActiveStorage.

See for the full implementation this repo (as I skip a few of the chunkier bits).

Let's start with the HTML.

<div data-controller="markdown">
  <div>
    <button data-action="markdown#h1">H1</button>

    <button data-action="markdown#bold">Bold</button>

    <button data-action="markdown#italic">Italic</button>

    <!-- add more formatting options -->
  </div>

  <textarea id="content" name="content" data-markdown-target="content">
  </textarea>
</div>
Enter fullscreen mode Exit fullscreen mode

Then the Stimulus controller:

// app/javascript/controllers/markdown_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["content"]

  h1() {
    this.formatText({ with: "# ", by: "prepending" })
  }

  bold() {
    this.formatText({ with: "**", by: "wrapping" })
  }

  italic() {
    this.formatText({ with: "_", by: "wrapping" })
  }

  // …add more formatting options…

  // private

  formatText({ with: marker, by = "wrapping" }) {
    const start = this.contentTarget.selectionStart
    const end = this.contentTarget.selectionEnd

    const text = this.contentTarget.value
    const selectedText = text.substring(start, end) || "text"

    const formatted = by === "wrapping" ? `${marker}${selectedText}${marker}` :
                      by === "prepending" ? `${marker}${selectedText}` :
                      by === "appending" ? `${selectedText}${marker}` : selectedText

    this.contentTarget.setRangeText(formatted, start, end, "select")
  }
}
Enter fullscreen mode Exit fullscreen mode

You can see the various formatting options have a really readable interface: this.formatText({ with: "**", by: "wrapping" }) (it is the kind of JavaScript code I teach in the book JavaScript for Rails Developers 💡).

While readable, it still is a lot of duplication when you imagine the many more formatting options you want to add. So let's do a quick refactor.

// app/javascript/controllers/markdown_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["content"]

-  h1() {
-    this.formatText({ with: "# ", by: "prepending" })
-  }

-  bold() {
-    this.formatText({ with: "**", by: "wrapping" })
-  }

-  italic() {
-    this.formatText({ with: "_", by: "wrapping" })
-  }

+  format({ params: { marker, position } }) {
+    this.#formatText({ with: marker, by: position })
+  }

  // private

  formatText({ with: marker, by = "wrapping" }) {
    const start = this.contentTarget.selectionStart
    const end = this.contentTarget.selectionEnd

    const text = this.contentTarget.value
    const selectedText = text.substring(start, end) || "text"

    const formatted = by === "wrapping" ? `${marker}${selectedText}${marker}` :
                      by === "prepending" ? `${marker}${selectedText}` :
                      by === "appending" ? `${selectedText}${marker}` : selectedText

    this.contentTarget.setRangeText(formatted, start, end, "select")
  }
}
Enter fullscreen mode Exit fullscreen mode

Then the HTML will hold the “configuration”:

<div data-controller="markdown">
  <div>
    <button data-action="markdown#format" data-markdown-marker-param="# " data-markdown-position-param="prepending">H1</button>

    <button data-action="markdown#format" data-markdown-marker-param="**" data-markdown-position-param="wrapping">bold</button>

    <button data-action="markdown#format" data-markdown-marker-param="_" data-markdown-position-param="wrapping">italic</button>
  </div>

  <textarea id="content" name="content" data-markdown-target="content">
  </textarea>
</div>
Enter fullscreen mode Exit fullscreen mode

This is using "action parameters" and I showed it in this article lesser known Stimulus features. Check it out as it is a really handy feature that is not used often.

Paste URLs

With the basic formatting options done, let's add some niceties. Pasting URL's that format to markdown-links (eg. [text](url)). As some logic will be reused, let's do another quick refactor:

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["content"]

  format({ params: { marker, position } }) {
    this.#formatText({ with: marker, by: position })
  }

  // private

   #formatText({ with: marker, by = "wrapping" }) {
-    const start = this.contentTarget.selectionStart
-    const end = this.contentTarget.selectionEnd

-    const formattedText = by === "wrapping" ? `${marker}${selectedText}${marker}` :
-                          by === "prepending" ? `${marker}${selectedText}` :
-                          by === "appending" ? `${selectedText}${marker}` : selectedText
+    const text = by === "wrapping" ? `${marker}${this.#selectedText}${marker}` :
+                          by === "prepending" ? `${marker}${this.#selectedText}` :
+                          by === "appending" ? `${this.#selectedText}${marker}` : this.#selectedText
+
+    this.contentTarget.setRangeText(text, this.#start, this.#end, "select")
   }

+ get #selectedText() {
+   return this.contentTarget.value.substring(this.contentTarget.selectionStart, this.contentTarget.selectionEnd) || "text"
+ }

+ get #start() {
+   return this.contentTarget.selectionStart
+ }

+ get #end() {
+   return this.contentTarget.selectionEnd
+ }
}
Enter fullscreen mode Exit fullscreen mode

This will make it easier to add this pasting URL's feature.

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["content"]

  format({ params: { marker, position } }) {
    this.#formatText({ with: marker, by: position })
  }

+  pasteUrl(event) {
+    const text = event.clipboardData.getData("text")
+
+    if (this.#isUrl(text)) {
+      event.preventDefault()
+
+      this.contentTarget.setRangeText(
+        `[${this.#selectedText}](${text})`,
+        this.#start,
+        this.#end
+      )
+    }
+  }

  // private

+  #isUrl(text) {
+    try {
+      new URL(text)
+
+      return true
+    } catch {
+      return false
+    }
+  }
}
Enter fullscreen mode Exit fullscreen mode

Then in the HTML:

-  <textarea id="content" name="content" data-markdown-target="content">
-  </textarea>
+  <textarea
+    id="content"
+    name="content"
+    data-markdown-target="content"
+    data-action="paste->markdown#pasteUrl"
+  ></textarea>
Enter fullscreen mode Exit fullscreen mode

I just moved each attribute on its own line to keep things readable and added the new data-action. And with that when you paste an URL it will be transformed to a markdown link either using text as the “anchor” or the selected text.

Fetch title from URL

It would be cool to fetch the title from the URL and use that instead. For that a little bit of back-end code is needed too, as CORS usually prevents you from fetching the URL through JS. Let's look at the changes need to the Stimulus controller first:

// app/javascript/controllers/markdown_controller.js
export default class extends Controller {
-  pasteUrl(event) {
+  async pasteUrl(event) {
     const text = event.clipboardData.getData("text")

     if (this.#isUrl(text)) {
       event.preventDefault()

+      const title = await this.#fetchUrlTitle({from: text})
+
       this.contentTarget.setRangeText(
-        `[${this.#selectedText}](${text})`,
+        `[${title}](${text})`,
         this.#start,
         this.#end
       )

+  async #fetchUrlTitle({from: url}) {
+    try {
+      const response = await fetch("/fetch", {
+        method: "POST",
+        headers: {
+          "Content-Type": "application/json",
+          "X-CSRF-Token": document.querySelector("[name='csrf-token']").content
+        },
+        body: JSON.stringify({ url })
+      })
+
+      const data = await response.json()
+
+      return data.title || this.#selectedText || "Untitled"
+    } catch (error) {
+      console.warn("Failed to fetch URL title:", error)
+
+      return this.#selectedText || "Untitled"
+    }
+  }
}
Enter fullscreen mode Exit fullscreen mode

I've written this article on making requests from Stimulus controllers. Check it out to understand the above logic better. Also: concepts like await/async are covered in my recently released book JavaScript for Rails Developers (what a coincidence! 🤓).

Now let's create some super basic logic at the /fetch endpoint. First the route:

Rails.application.routes.draw do
  root to: "pages#show"
+
+   resource :fetch, only: %w[create]
end
Enter fullscreen mode Exit fullscreen mode

And then the Rails controller that actually fetches the URL and parses it to get the page's title:

class FetchesController < ApplicationController
  def create
    render json: {title: title(from: params[:url])}
  rescue StandardError => error
    render json: {error: error.message}, status: :unprocessable_entity
  end

  private

  def title(from: url) = Nokogiri::HTML(URI.open(from)).title || "Untitled"
end
Enter fullscreen mode Exit fullscreen mode

Super simpler! But it does what it needs to do. 💪

When you now paste https://railsdesigner.com/markdown-textarea/ it will transform it into:

[Create a Markdown-Powered Textarea with Stimulus | Rails Designer](https://railsdesigner.com/markdown-textarea/)
Enter fullscreen mode Exit fullscreen mode

This shows the basic implementation for this feature, and should be more than enough to get started! 🚀

Continuous bullet- and numbered lists

A feature you see often in markdown editors is bullet- or numbered lists that continue with their prefix (eg. - or 2.) after a return (new line). While simple on the surface, it is a surprisingly tricky feature to implement. Let's start with the basics:

<div data-controller="markdown">
  id="content"
    name="content"
    data-markdown-target="content"
-    data-action="paste->markdown#pasteUrl"
+    data-action="paste->markdown#pasteUrl keydown->markdown#continueList"
  ></textarea>
Enter fullscreen mode Exit fullscreen mode

Looks simple enough, let's add that method to the controller:

// app/javascript/controllers/markdown_controller.js
import { Controller } from "@hotwired/stimulus"
+ import { List } from "controllers/markdown/list"

 export default class extends Controller {

+  continueList(event) {
+    new List(this.contentTarget).continue(event)
+  }
+
Enter fullscreen mode Exit fullscreen mode

Oh! All the logic is neatly tucked in another class as it is quite a lot! 🧹 Refer to the repo for the full class. But in short: the List class works by detecting if the current line is a bullet (-) or numbered (1.) list item when Enter is pressed. It then either continues the list with a new item or removes the list marker if the current item is empty. It uses regex patterns to detect list types.

Drag & drop images (using ActiveStorage)

Another common feature is to drag & drop images (or videos) into your markdown. I'd like to show how the basics can be done.

Make sure you have ActiveStorage installed and ready to go.

As often, first the HTML:

<textarea
-    data-action="paste->markdown#pasteUrl keydown->markdown#continueList"
+    data-action="
+      paste->markdown#pasteUrl
+      keydown->markdown#continueList
+
+      dragover->markdown#dragover:stop:prevent
+      dragleave->markdown#dragleave:stop:prevent
+      drop->markdown#drop:prevent:stop
+    "
Enter fullscreen mode Exit fullscreen mode

Then the same technique to store the actual logic in its own class:

// app/javascript/controllers/markdown_controller.js
import { Controller } from "@hotwired/stimulus"
import { List } from "controllers/markdown/list"
+ import { Upload } from "controllers/markdown/upload"

  export default class extends Controller {
+  dragover() {}
+  dragleave() {}
+
+  async drop(event) {
+    const uploader = await new Upload().process(event)
+
+    if (uploader) this.contentTarget.setRangeText(uploader.markup, this.#start, this.#end, "select")
+  }
+
 }
Enter fullscreen mode Exit fullscreen mode

See those empty dragover and dragleave methods? It is needed to adhere to Drag and Drop API. I could've just pointed them both to a void() method and it would still work, but I like this explicitness a bit better. And how about the custom actions stop and prevent are used in the HTML? Check out this article about Stimulus' action options to learn more about them.

Before moving on let's add the route and the controller that interacts with Active Storage.

Rails.application.routes.draw do
  root to: "pages#show"

  resource :fetch, only: %w[create]
+
+  resources :images, only: %w[create]
 end
Enter fullscreen mode Exit fullscreen mode

In most real apps you would attach this to a user, workspace or some other record.

class ImagesController < ApplicationController
  def create
    urls = params[:files].map do |file|
      blob = ActiveStorage::Blob.create_and_upload!(
        io: file,
        filename: file.original_filename,
        content_type: file.content_type
      )

      url_for(blob)
    end

    render json: {urls: urls}
  end
end
Enter fullscreen mode Exit fullscreen mode

The Upload class is still fairly compact, but to quickly go over it: it processes drag events by first checking for files (returning their uploaded URLs as markdown). Otherwise it falls back to plain text, (always returning an object structure with a markup property).

See for the full implementation of the upload class the repo on GitHub.

And there you have it. A minimal, but markdown enhanced textarea. The three files created can be copied over into any Rails app and you have a functioning markdown-powered textarea. ✨

Of course there are still various more features you could add, but with the basics outlined here that shouldn't be be too much of an issue.

Heroku

Amplify your impact where it matters most — building exceptional apps.

Leave the infrastructure headaches to us, while you focus on pushing boundaries, realizing your vision, and making a lasting impression on your users.

Get Started

Top comments (1)

Collapse
 
railsdesigner profile image
Rails Designer

What features would you add to it?

SurveyJS custom survey software

JavaScript Form Builder UI Component

Generate dynamic JSON-driven forms directly in your JavaScript app (Angular, React, Vue.js, jQuery) with a fully customizable drag-and-drop form builder. Easily integrate with any backend system and retain full ownership over your data, with no user or form submission limits.

Learn more