Document cover
Rich Text Editors Are Mostly Edge Cases Wearing A ToolbarEvery developer hits it: you build a toolbar and think you're almost done. You're not. Paste normalization, cursor mapping, drag-and-drop, concurrent edits — that's the actual product. Why I built Seed's editor on ProseMirror, and why the document model matters more than the toolbar.

There's a moment every developer hits when building a rich text editor. It usually happens around week two: you look at your toolbar — bold, italic, headings, maybe an image button — and you think, "I'm almost done."

You are not almost done. You are 10% done. The other 90% is edge cases. And they're not hiding — they're the entire product.

The toolbar is the decoy

A toolbar is a set of commands that mutate a document. Bold wraps a selection in <strong>. A heading bumps a paragraph to <h2>. These are trivial. Every editor library ships them out of the box.

The real product lives in what happens between those toolbar clicks:

  • What if you press Enter inside a blockquote? Should it extend the quote or escape it?

  • What if you paste content from Google Docs? Word? Notion?

  • What if you delete the last character of a heading? Does it become a paragraph? What if it's the only block in the document?

  • What if you drag-and-drop an image between two paragraphs? Does the cursor position survive?

  • What if two people are typing in the same block? Who wins?

None of these are "edge cases" in the sense that they're rare. They're the normal operation of an editor. Users hit them constantly.

Why ProseMirror gets this right

I built Seed's editor on ProseMirror. Not because it's easy — it's famously not. But because its architecture acknowledges the truth: a rich text editor is a document system with a schema, transactions, selections, and identity, not a decorated <textarea>.

In ProseMirror:

  • The document is a tree with a schema. Every node has rules. You can't put a heading inside a list item unless you explicitly allow it.

  • Every change is a transaction. Transactions can be composed, inspected, and — crucially — rejected before they touch the DOM.

  • The selection is a first-class object with its own mapping logic. When the document changes around your cursor, the selection maps correctly. This alone eliminates an entire category of bugs.

Most "WYSIWYG editor" libraries hide all of this behind a friendly API. That's fine — until it isn't. Until you need to know why the cursor jumped to position zero after an async update. Until you need to intercept a paste event and normalize the HTML before it enters the document. Until you need collaborative editing and realize your document model has no concept of identity.

The real work

Shipping Seed's editor involved:

  • Comment anchors that survive text changes. When you add a line above a comment, the anchor has to move. ProseMirror's mapping handles this, but you still have to model it correctly — what happens when the commented text is deleted entirely?

  • Click-to-edit transitions. A document view and an editing view are different states. Switching between them means preserving cursor position, handling unsaved changes, and managing concurrent users.

  • Block-level drag and drop. This is a state machine: idle → dragging → hovering-over-target → dropping → reordering. Six states, at least three failure modes.

  • Autosave that doesn't fight the user. Debouncing keystrokes is the easy part. The hard part: what if the save fails? What if the user keeps typing while the save is in flight? What if two saves overlap?

The takeaway

If you're evaluating a rich text editor library, stop looking at the toolbar. Look at the document model. Look at how it handles transactions and selections. Look at whether it has a concept of identity for collaboration.

The toolbar is the easy part. The edge cases are the product. Pick a library that knows it.

Photo by Syawish Rehman on Unsplash

Do you like what you are reading? Subscribe to receive updates.

Unsubscribe anytime