Frontend Development 12 min read

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.

Bilibili Tech
Bilibili Tech
Bilibili Tech
Understanding contenteditable and Modern Rich Text Editors with Slate.js

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.

frontendJavaScriptreactRich Text EditorContentEditableSlate.js
Bilibili Tech
Written by

Bilibili Tech

Provides introductions and tutorials on Bilibili-related technologies.

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.