Understanding contenteditable and Modern Rich Text Editors with Slate.js
The article explains how the HTML contenteditable attribute enables in‑place editing, why legacy execCommand‑based editors are inconsistent, and how modern frameworks such as Slate.js—built on React and Immutable.js—provide a structured, location‑based data model with Transform and Node APIs for reliable, portable rich‑text editing.
The contenteditable attribute is an enumerated HTML attribute that indicates whether an element can be edited by the user. When set to true or an empty string, the element becomes editable; when set to false , it is not editable. If the attribute is omitted, its value is inherited from the parent element.
Example of making any element behave like a textarea:
<div contenteditable="true">
This text can be edited by the user.
</div>Compatibility tables show that most modern browsers support contenteditable .
Traditional rich‑text editors often rely on document.execCommand to apply formatting, insert elements, or handle copy‑paste. Example:
function formatDoc(sCmd, sValue) {
if (validateMode()) { document.execCommand(sCmd, false, sValue); oDoc.focus(); }
}Note: document.execCommand is deprecated and may be removed from future browsers.
Pain points of traditional editor solutions
Inconsistent behavior across browsers (e.g., handling of the Enter key).
Verbose and error‑prone DOM manipulation, especially after the rise of MVVM frameworks like Vue and React.
Difficulty persisting content: storing raw innerHTML leads to challenges with content moderation, algorithmic analysis, layout changes, and multi‑platform rendering.
Modern editors such as Quill.js and Draft.js address these issues by providing a structured, platform‑agnostic data model.
Slate.js Overview
Slate.js is a maintained, contenteditable -based editor framework that draws inspiration from Quill and Draft. It operates as a framework rather than a ready‑made application, built on top of React and Immutable.js.
Core APIs
Transform – document operations
Transforms.setNodes(editor, { type: 'heading-one' })To revert a heading back to plain text:
Transforms.unwrapNodes(editor, {
match: node => !Editor.isEditor(node) && node.children?.every(child => Editor.isBlock(editor, child)),
mode: 'all',
})Inserting text at a specific location:
Transforms.insertText(editor, 'some words', {
at: { path: [0, 0], offset: 3 },
})Node – element types
interface Editor {
// Current editor state
children: Node[]
selection: Range | null
operations: Operation[]
marks: Omit
| null
isInline: (element: Element) => boolean
isVoid: (element: Element) => boolean
normalizeNode: (entry: NodeEntry) => void
onChange: () => void
addMark: (key: string, value: any) => void
apply: (operation: Operation) => void
deleteBackward: (unit: 'character' | 'word' | 'line' | 'block') => void
deleteForward: (unit: 'character' | 'word' | 'line' | 'block') => void
deleteFragment: () => void
insertBreak: () => void
insertSoftBreak: () => void
insertFragment: (fragment: Node[]) => void
insertNode: (node: Node) => void
insertText: (text: string) => void
removeMark: (key: string) => void
}Elements (called Element ) can be block or inline; void elements are treated as black boxes. Leaf nodes are Text objects that store the actual string and formatting flags.
Locations – addressing nodes
Three main location types are used:
Path : an array of indices that uniquely identifies a node in the tree. Example:
const editor = {
children: [
{
type: 'paragraph',
children: [
{ text: 'A line of text!' }
]
}
]
}The leaf Text node has the path [0, 0] . The editor root has the special path [] .
Point : a Path plus an offset indicating the cursor position.
interface Point {
path: Path;
offset: number;
}Range : two points defining a selection.
interface Range {
anchor: Point;
focus: Point;
}Descendant – the editor’s data structure
The editor’s content can be retrieved as an array of Descendant objects (essentially Node or Text items). This structure can be stringified and stored on a server.
const initialValue: Descendant[] = [
{
type: 'paragraph',
children: [
{ text: 'This is editable ' },
{ text: 'rich', bold: true },
{ text: ' text, ' },
{ text: 'much', italic: true },
{ text: ' better than a ', },
{ text: '
', code: true },
{ text: '!' }
]
},
{
type: 'paragraph',
children: [
{ text: "Since it's rich text, you can do things like turn a selection of text " },
{ text: 'bold', bold: true },
{ text: ', or add a semantically rendered block quote in the middle of the page, like this:' }
]
},
{ type: 'block-quote', children: [{ text: 'A wise quote.' }] },
{ type: 'paragraph', align: 'center', children: [{ text: 'Try it out for yourself!' }] }
];The article concludes with a preview of building a modern rich‑text editor using Slate.js and provides reference links to MDN, Quill, Draft, and related resources.
Bilibili Tech
Provides introductions and tutorials on Bilibili-related technologies.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.