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>
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")
}
}
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")
}
}
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>
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
+ }
}
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
+ }
+ }
}
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>
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"
+ }
+ }
}
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
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
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/)
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>
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)
+ }
+
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
+ "
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")
+ }
+
}
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
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
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.
Top comments (1)
What features would you add to it?