Fancy editors have become all the rage in web apps over the last couple decades. My own headcanon is this. I blame the world's reliance on Microsoft Office. Microsoft Word became the universal way to format text documents, and everyone wanted all that (mostly unecessary) functionality in their browser. Wordpress, which still runs the majority of the internet, furthered the spread of "WYSIWYG" editors. From there, comprehensive styling of text became the expectation to website users.
Updating super large WYSIWG content naively can slow down as you increase the size of your abitrarily large editor. Intelligent diffing can make this a bit better, but throwing concurrency on top of all that...you basically have to reinvent all the innovations of Google docs (not easy). A recent innovation made this a bit easier: the Notion block-style WYSIWYG editor. The block editor is nice because every update is sent per block. Users can work on different blocks at the same time.
These block editors though are not super easy to just whip up on your frontend. A new company that wants to offer multi-line text editing to users now has some serious decision-making to make in terms of how to implement text editors. Can you get away with just simple <textarea> form elements? Is there a plugin for it? Which React library do you use? Is there a vanilla JS version available? Are these customizable? Reliable? Sustainable? Well maintained?
I've watched a company I worked at struggle to implement, maintain, and reliably deliver a good WYSIWYG block-based experience to customers via a React library. We even, at times, contracted out to the creator of the library to help alleviate our woes with some success. The bugs still keep coming in. I've often wondered: how hard would it be to create a dead simple version without a library, React, all the dependencies of the "modern" web era. So the goal for today is simple: create a functional wysiwgy block-based editor in the simplest way possible—avoid complex implementations as much as possible. Show that you can still do some cool stuff, and do it in as little time and effort as possible.
I'd never heard of the contenteditable=true but it's pretty freaking cool. I stumbled across it while strategizing on how a block-based "modern" editor could be made. Here's an extremely plain example of it's beauty on display:
<div
style="border: 1px solid black; padding: 5px; margin-bottom: 5px; box-shadow: 2px 2px;"
contenteditable="true"
>Here's some placeholder text. Type whatever you want here!</div>Try even ctl/cmd+B or I. That's right. Some styling comes out of the box! Even more, I asked chatgpt how I might make other styles work like colors for example. It gave me this sweet little snippet:
<div
style="border: 1px solid black; padding: 5px; margin-bottom: 5px; box-shadow: 2px 2px;"
contenteditable="true"
>Adding color to different <font color="#ff0000">parts</font> of <font color="#008000">text</font> is not so hard!</div>
<button
style="background-color: red; border: 1px black solid; border-radius: 1px; padding: 10px; color: white; cursor: pointer;"
onclick="setColor('red')"
>Style Red</button>
<button
style="background-color: blue; border: 1px black solid; border-radius: 1px; padding: 10px; color: white; cursor: pointer;"
onclick="setColor('blue')"
>Style Blue</button>
<button
style="background-color: green; border: 1px black solid; border-radius: 1px; padding: 10px; color: white; cursor: pointer;"
onclick="setColor('green')"
>Style Green</button>
<script>
function setColor(color) {
document.execCommand("foreColor", false, color);
}
</script>
Now highlight some text and click one of the buttons and notice t it only works on the contenteditable text. HTML really does come out with a lot out of the box (Random aside: shoutout to one of my favorite blog posts ever https://justfuckingusehtml.com/)!
Originally, I was thinking I'd have to style a bunch of input or textarea blocks to be just right or build a div with a bunch of custom javascript to make this idea purr, but this here makes things way easier. Textarea blocks have little handles to make them bigger or smaller, and they honestly kind of look like crap. This was a gamechanger. I really didn't know where I was going to have to go. I hope at this point you can see the vision.
Ok I must add that with a little dev inspecting od my own, and I did find that Notion is in fact using contenteditable itself. I'm sure it's taking a custom json format they've made and syncing it to these contenteditable blocks, but certainly not claiming to be a revolutionary here. I followed in the footsteps of the editor I was trying to emulate. Perfect!
<div class="border-1 border-slate-700 rounded-sm p-5">
<div
class="p-2 hover:bg-slate-100"
contenteditable="true"
>
This is a Regular content block
</div>
<div
class="p-2 hover:bg-slate-100 border-l-4 border-black"
contenteditable="true"
>
This is a Callout content block
</div>
<div
class="p-2 hover:bg-slate-100 text-3xl font-bold"
contenteditable="plaintext-only"
>
This is a Heading 1 content block
</div>
<div
class="p-2 hover:bg-slate-100 text-2xl font-bold"
contenteditable="plaintext-only"
>
This is a Heading 2 content block
</div>
<div
class="p-2 hover:bg-slate-100 text-xl font-bold"
contenteditable="plaintext-only"
>
This is a Heading 3 content block
</div>
<div>
Use <code>contenteditable="plaintext-only"</code>
instead of <code>contenteditable="true"</code>
<span>{"for"} blocks you don't want styling on.</span>
Notice can't style the heading blocks
</div>
<small>
the article tempted to make every list a series of sequential contenteditable blocks
but I'm really not sure.
</small>
<ul
contenteditable="true"
class="pl-4 space-y-1"
>
<li>
<span contenteditable="false"></span>
Type text here. Try deleting this list item.
</li>
<li>editable!</li>
</ul>
<ol
contenteditable="true"
class="pl-4 space-y-1"
>
<li>
<span contenteditable="false"></span>
Type text here. Try deleting this list item.
</li>
<li>editable!</li>
</ol>
</div>
contenteditable="plaintext-only" instead of contenteditable="true" for blocks you don't want styling on. Notice can't style the heading blocksOk one thing so far that I've found extremely annoying: I can't tab. It just takes me to the next content-editable block. In English, we start every paragraph with an indent. I want to keep things simple, but I think at the very least we unforunately need to cover this.
At this point, I was very tempted to turn to Alpine JS. It makes keybindings extremely easy, but I've also written vanilla JS before for keybindings and at this point, I thought I remembered it not being so bad. I'll return to this paragraph with a verdict. I'm trying to keep things simple and avoid dependencies—except tailwind I guess b/c, well, it's pretty good and already hooked up into our site.
And the verdict was: vanilla JS was really not so bad!
<div id="key-binding-example">
<div class="border-1 border-slate-700 rounded-sm p-5">
<div
class="p-2 hover:bg-slate-100"
contenteditable="true"
>
This is a Regular content block
</div>
<div
class="p-2 hover:bg-slate-100 border-l-4 border-black"
contenteditable="true"
>
This is a Callout content block
</div>
<div
class="p-2 hover:bg-slate-100 text-3xl font-bold"
contenteditable="plaintext-only"
>
This is a Heading 1 content block
</div>
<div
class="p-2 hover:bg-slate-100 text-2xl font-bold"
contenteditable="plaintext-only"
>
This is a Heading 2 content block
</div>
<div
class="p-2 hover:bg-slate-100 text-xl font-bold"
contenteditable="plaintext-only"
>
This is a Heading 3 content block
</div>
<ul
contenteditable="true"
class="pl-4 space-y-1"
>
<li>
<span contenteditable="false"></span>
Type text here. Try deleting this list item.
</li>
<li>editable!</li>
</ul>
<ol
contenteditable="true"
class="pl-4 space-y-1"
>
<li>
<span contenteditable="false"></span>
Type text here. Try deleting this list item.
</li>
<li>editable!</li>
</ol>
</div>
</div>
<script>
const keyBoundBlocks = document.querySelectorAll("#key-binding-example [contenteditable]:not([contenteditable=false])")
for (let i=0; i<keyBoundBlocks.length; i++) {
let block = keyBoundBlocks[i]
block.className += " whitespace-pre-wrap" // class is essential to show for \t in contenteditable=true blcosk
block.addEventListener('keydown', (e) => {
const shouldPreventDefault =
false
|| (e.code==="Tab")
const isBlockDownEvent =
false
|| (e.shiftKey && e.ctrlKey && e.code==="KeyJ") // "vim"-ish (not really lol)
|| (e.shiftKey && e.metaKey && e.code==="KeyJ") // "vim"ish (not really lol)
|| (e.shiftKey && e.ctrlKey && e.code==="ArrowDown")
|| (e.shiftKey && e.metaKey && e.code==="ArrowDown")
const isBlockUpEvent =
false
|| (e.shiftKey && e.ctrlKey && e.code==="KeyK") // "vim"-ish (not really lol)
|| (e.shiftKey && e.metaKey && e.code==="KeyK") // "vim"-ish (not really lol)
|| (e.shiftKey && e.ctrlKey && e.code==="ArrowUp")
|| (e.shiftKey && e.metaKey && e.code==="ArrowUp")
if (shouldPreventDefault) e.preventDefault()
if (isBlockDownEvent) {
const next = keyBoundBlocks[i + 1];
if (next) next.focus();
}
if (isBlockUpEvent) {
const prev = keyBoundBlocks[i - 1];
if (prev) prev.focus();
}
if (e.code==="Tab" && !e.shiftKey) {
e.preventDefault();
const selection = window.getSelection();
const range = selection.getRangeAt(0);
const tab = document.createTextNode('\t');
range.insertNode(tab);
range.setStartAfter(tab); // Move cursor to after tab
range.setEndAfter(tab);
selection.removeAllRanges();
selection.addRange(range);
}
// shift+tab maybe we'll make it worth with bulleted lists, but no need {"for"}
// it here
});
}
</script>
Ok in the editor above, we can finally Tab to indent. It works! With Shift+Tab disabled now too, how would user navigate from block to block. With not too much JS I was able to get the job done. The key bindings are admittedly not so ideal and a bit strange because so many keybindings are already consumed by the browser. But try out shift+ctrl+j/k or shift+cmd+j/k (for my not really vim at all homage) or shift+cmd+upArrow/downArrow or shift+ctrl+upArrow/downArrow, hopefully one of those options works for whatever operation system you might be using.
Alright, a key thing I'm certainly missing here is adding and removing blocks. So let's get to it. This will be my final goal here for the course of this post. I might come back to the editor concept though. We'll see! I told Orion that I'd get to one of these a week, and I'm already behind!
<div id="add-remove-example-base-blocks" class="hidden">
<div class="border-1 border-slate-700 rounded-sm p-5">
<div class="flex" id="base-block">
<div class="flex-none mr-4 font-medium">
<button class="trash-block button border-gray-300 bg-slate-100 border-1 rounded-sm p-2 hover:cursor-pointer">🗑️</button>
</div>
<div class="flex-auto">
<div
class="p-2 hover:bg-slate-100 whitespace-pre-wrap"
contenteditable="true"
>
A regular content block
</div>
</div>
</div>
<div class="flex" id="base-callout">
<div class="flex-none mr-4 font-medium">
<button class="trash-block button border-gray-300 bg-slate-100 border-1 rounded-sm p-2 hover:cursor-pointer">🗑️</button>
</div>
<div class="flex-auto">
<div
class="p-2 hover:bg-slate-100 border-l-4 border-black whitespace-pre-wrap"
contenteditable="true"
>
This is a Callout content block
</div>
</div>
</div>
<div class="flex" id="base-h1">
<div class="flex-none mr-4 font-medium">
<button class="trash-block button border-gray-300 bg-slate-100 border-1 rounded-sm p-2 hover:cursor-pointer">🗑️</button>
</div>
<div class="flex-auto">
<div
id="base-h1"
class="p-2 hover:bg-slate-100 text-3xl font-bold whitespace-pre-wrap"
contenteditable="plaintext-only"
>
This is a Heading 1 content block
</div>
</div>
</div>
<div class="flex" id="base-h2">
<div class="flex-none mr-4 font-medium">
<button class="trash-block button border-gray-300 bg-slate-100 border-1 rounded-sm p-2 hover:cursor-pointer">🗑️</button>
</div>
<div class="flex-auto">
<div
class="p-2 hover:bg-slate-100 text-3xl font-bold whitespace-pre-wrap"
contenteditable="plaintext-only"
>
This is a Heading 2 content block
</div>
</div>
</div>
<div class="flex" id="base-h3">
<div class="flex-none mr-4 font-medium">
<button class="trash-block button border-gray-300 bg-slate-100 border-1 rounded-sm p-2 hover:cursor-pointer">🗑️</button>
</div>
<div class="flex-auto">
<div
class="p-2 hover:bg-slate-100 text-3xl font-bold whitespace-pre-wrap"
contenteditable="plaintext-only"
>
This is a Heading 3 content block
</div>
</div>
</div>
<div class="flex" id="base-ul">
<div class="flex-none mr-4 font-medium">
<button class="trash-block button border-gray-300 bg-slate-100 border-1 rounded-sm p-2 hover:cursor-pointer">🗑️</button>
</div>
<div class="flex-auto">
<ul
contenteditable="true"
class="pl-4 space-y-1 whitespace-pre-wrap"
>
<li>
<span contenteditable="false"></span>
Type text here. Try deleting this list item.
</li>
<li>editable!</li>
</ul>
</div>
</div>
<div class="flex" id="base-ol">
<div class="flex-none mr-4 font-medium">
<button class="trash-block button border-gray-300 bg-slate-100 border-1 rounded-sm p-2 hover:cursor-pointer">🗑️</button>
</div>
<div class="flex-auto">
<ol
contenteditable="true"
class="pl-4 space-y-1 whitespace-pre-wrap"
>
<li>
<span contenteditable="false"></span>
Type text here. Try deleting this list item.
</li>
<li>editable!</li>
</ol>
</div>
</div>
</div>
</div>
<div id="add-remove-example-body">
<div class="flex">
<div class="flex-none mr-4 font-medium">
<button class="trash-block button border-gray-300 bg-slate-100 border-1 rounded-sm p-2 hover:cursor-pointer">🗑️</button>
</div>
<div class="flex-auto">
<div
class="p-2 hover:bg-slate-100 whitespace-pre-wrap"
contenteditable="true"
>
A starter block
</div>
</div>
</div>
</div>
<div class="p-3">
<select name="block-type" id="block-type" class="cursor-pointer">
<option value="block">Content Block</option>
<option value="callout">Callout Block</option>
<option value="h1">Heading 1 Block</option>
<option value="h2">Heading 2 Block</option>
<option value="h3">Heading 3 Block</option>
<option value="ul">Bulleted List</option>
<option value="ol">Numbered List</option>
</select>
<button class="border-black p-2 rounded-sm border-1 hover:cursor-pointer" id="add-block">Add</button>
</div>
<script>
function trashBlock(e) {
e.preventDefault()
const trashBtn = e.target
const parentContainer = trashBtn.parentElement.parentElement
parentContainer.remove()
}
const blockContainer = document.querySelector("#add-remove-example-body")
const addBlockBtn = document.querySelector("#add-block")
addBlockBtn.addEventListener("click", (e) => {
const blockTypeVal = document.querySelector("#block-type").value
const rootBlock = document.querySelector("#base-" + blockTypeVal)
const blockCopy = rootBlock.cloneNode(true)
blockCopy.removeAttribute("id")
trashBlockBtnCopy = blockCopy.querySelector(".trash-block")
trashBlockBtnCopy.addEventListener("click", trashBlock)
blockContainer.appendChild(blockCopy)
})
const trashBlockBtns = document.querySelectorAll(".trash-block")
for (let i=0; i<trashBlockBtns.length; i++) {
trashBlockBtns[i].addEventListener("click", trashBlock)
}
// Since now blocks dynamically grow and shrink a different approach is needed
// (should have anticipated this, I already wrote the outline but alas...)
const addRemoveExample = document.querySelector("#add-remove-example-body")
addRemoveExample.addEventListener("keydown", (e) => {
const target = e.target.closest('[contenteditable]:not([contenteditable="false"])'); // walks up the tree
console.log("YO", target)
if (!target || !addRemoveExample.contains(target)) return;
const shouldPreventDefault =
false
|| (e.code==="Tab")
const isBlockDownEvent =
false
|| (e.shiftKey && e.ctrlKey && e.code==="KeyJ") // "vim"-ish (not really lol)
|| (e.shiftKey && e.metaKey && e.code==="KeyJ") // "vim"ish (not really lol)
|| (e.shiftKey && e.ctrlKey && e.code==="ArrowDown")
|| (e.shiftKey && e.metaKey && e.code==="ArrowDown")
const isBlockUpEvent =
false
|| (e.shiftKey && e.ctrlKey && e.code==="KeyK") // "vim"-ish (not really lol)
|| (e.shiftKey && e.metaKey && e.code==="KeyK") // "vim"-ish (not really lol)
|| (e.shiftKey && e.ctrlKey && e.code==="ArrowUp")
|| (e.shiftKey && e.metaKey && e.code==="ArrowUp")
if (shouldPreventDefault) e.preventDefault()
const currentKeyBoundBlocks = Array.from(document.querySelectorAll("#add-remove-example-body [contenteditable]:not([contenteditable=false])"))
i = currentKeyBoundBlocks.indexOf(target)
if (isBlockDownEvent) {
const next = currentKeyBoundBlocks[i + 1];
if (next) next.focus();
}
if (isBlockUpEvent) {
const prev = currentKeyBoundBlocks[i - 1];
if (prev) prev.focus();
}
if (e.code==="Tab" && !e.shiftKey) {
e.preventDefault();
const selection = window.getSelection();
const range = selection.getRangeAt(0);
const tab = document.createTextNode('\t');
range.insertNode(tab);
range.setStartAfter(tab); // Move cursor to after tab
range.setEndAfter(tab);
selection.removeAllRanges();
selection.addRange(range);
}
})
</script>It's really cool that contenteditable comes out of the box with so much functionality. I really appreciate all the little things it can do, but it definitely has its limitations. Just tabbing by itself was not hard, but not exciting to have to try and write some bootleg-feeling Tab yourself. For lists, I've always wanted tabbing and shift+tabbing are essential too. It appears this was really as far as I could get in a few hundred lines of code.
On this road, I did do some googling and found some similar articles along the way, so I did want to acknowledge those that came before me. I also learned about 37signals project Trix, which uses contenteditable as an I/O device for edits to an internal document model. Maybe something to look at for next time 👀.
contenteditable <table>. It's just a bit strange to work with as you can see in the code example, so I skipped it in that section. You can't modify the number of columns and rows anyways, so I didn't think it was worth using in that section: <table contenteditable="true" class="table border-black border-4">
<tr class="p-3">
<th>School</th>
<th>First</th>
<th>Last</th>
</tr>
<tr class="p-3">
<td>Notre Dame</td>
<td>Whitley</td>
<td>James</td>
</tr>
<tr class="p-3">
<td>SDSU</td>
<td>Peter</td>
<td>Forrester</td>
</tr>
</table>| School | First | Last |
|---|---|---|
| Notre Dame | Whitley | James |
| SDSU | Peter | Forrester |