Home · About · Services · Industries · Projects · Products · Process · Blog · FAQ · Contact
Writing a rich-text editor without dependencies What we learned building esc-editor, a dependency-free React editor: contentEditable, selections, execCommand, safe paste, tables and undo.
Our first rich-text editor, pp-editor, wraps Quill for Angular apps, and it has served us well. When we needed an editor for React projects, including our own CMS platform, we wanted something different: no runtime dependencies, a small bundle, and nothing that would fight with whichever React version a project happened to use. That became esc-editor.
Writing an editor from scratch is famously a bad idea, and parts of it were as painful as people warn. But the scope we needed was modest: headings, inline formatting, lists, links, tables, a source view and reliable undo. Here is what we learned getting there.
contentEditable is the easy part Setting contentEditable on a div gives you a working editor in one line. Typing, deleting, moving the caret and native spellcheck all come for free. The difficulty is everything you then want to do to that content, because the browser owns the DOM inside it and will change it in ways you did not ask for.
That has a direct consequence in React. If you render the editor as a controlled component and write innerHTML on every change, the caret jumps to the start after each keystroke. We treat the incoming value as the source only when it differs from the HTML we last emitted. Otherwise React leaves the DOM alone and the browser keeps the caret where the user put it.
Selection and Range are the real API Almost every toolbar action starts with the question: where is the selection? The Selection API gives you the current selection, and each selection holds Range objects with start and end containers and offsets. Ranges let you wrap, extract, delete and insert nodes precisely.
The first bug everyone hits is that clicking a toolbar button moves focus out of the editor, and the selection goes with it. Calling preventDefault in the button's mousedown handler stops the focus change. For things like a link dialog, where focus genuinely has to leave, we save the range and restore it afterwards.
let saved: Range | null = null;
export function saveSelection(root: HTMLElement): void {
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0) return;
const range = sel.getRangeAt(0);
if (root.contains(range.commonAncestorContainer)) {
saved = range.cloneRange();
}
}
export function restoreSelection(root: HTMLElement): void {
if (!saved) return;
root.focus();
const sel = window.getSelection();
sel?.removeAllRanges();
sel?.addRange(saved);
}The root.contains check matters on pages with more than one editor, or any other text on the page. Without it, a toolbar can happily format text the user selected somewhere else entirely.
execCommand is deprecated, and we still use it document.execCommand is marked as deprecated, and its behaviour differs between browsers. It might produce b in one place and strong in another, or wrap text in a span with inline styles. So why use it at all?
Because there is still no standard replacement that does the same job. The newer Input Events give you a beforeinput event that tells you what the user is trying to do, but they do not apply formatting for you. execCommand does, and the edits it makes go into the browser's native undo history. Writing bold, italic and list toggling by hand with Ranges means dealing with partial selections across several paragraphs, nested inline elements and lists inside table cells. That is a lot of code to replace something that works.
type InlineCommand =
| "bold"
| "italic"
| "underline"
| "strikeThrough"
| "insertOrderedList"
| "insertUnorderedList";
export function runCommand(root: HTMLElement, cmd: InlineCommand): void {
restoreSelection(root);
// Prefer tags over inline style spans where the browser supports it
document.execCommand("styleWithCSS", false, "false");
document.execCommand(cmd, false);
}Our compromise is to use execCommand for the inline formatting and list commands, isolated behind one function so it can be replaced later, and to do structural operations such as tables directly on the DOM. We also normalise the output on save, so the stored HTML is consistent whichever browser produced it.
Sanitising pasted HTML Paste is where most editors get messy. Content copied from word processors, web pages and email clients arrives with inline styles, class names, conditional comments, fonts and sometimes scripts. We intercept the paste event, read the text/html data from the clipboard, clean it against an allowlist, and insert the result.
const ALLOWED: Record<string, string[]> = {
P: [], BR: [], STRONG: [], B: [], EM: [], I: [], U: [], S: [],
H2: [], H3: [], UL: [], OL: [], LI: [], BLOCKQUOTE: [], PRE: [], CODE: [],
A: ["href"], TABLE: [], THEAD: [], TBODY: [], TR: [],
TH: ["colspan", "rowspan"], TD: ["colspan", "rowspan"],
};
const DROP = new Set(["SCRIPT", "STYLE", "IFRAME", "OBJECT", "EMBED", "META", "LINK"]);
export function sanitise(html: string): string {
const doc = new DOMParser().parseFromString(html, "text/html");
clean(doc.body);
return doc.body.innerHTML;
}
function clean(parent: Element): void {
for (const node of Array.from(parent.childNodes)) {
if (node.nodeType === Node.COMMENT_NODE) { node.remove(); continue; }
if (node.nodeType !== Node.ELEMENT_NODE) continue;
const el = node as Element;
if (DROP.has(el.tagName)) { el.remove(); continue; }
clean(el);
const allowedAttrs = ALLOWED[el.tagName];
if (!allowedAttrs) { el.replaceWith(...Array.from(el.childNodes)); continue; }
for (const attr of Array.from(el.attributes)) {
if (!allowedAttrs.includes(attr.name)) el.removeAttribute(attr.name);
}
const href = el.getAttribute("href");
if (href !== null && !/^(https?:|mailto:|\/|#)/i.test(href.trim())) {
el.removeAttribute("href");
}
}
}A few choices are deliberate. DOMParser builds an inert document, so scripts do not run and images do not load while we inspect it. Unknown elements are unwrapped rather than deleted, so the text inside a Word span survives while the span goes. Links keep only safe schemes, which removes javascript: URLs. When there is no HTML on the clipboard we fall back to plain text.
One warning we repeat to every team using the editor: client-side sanitising improves the editing experience, but it is not a security boundary. Anyone can post HTML straight to your API. The server must sanitise again before storing or rendering it.
Tables, source view and an undo stack of our own execCommand has no portable table commands, so tables are plain DOM work. HTMLTableElement.insertRow and HTMLTableRowElement.insertCell cover adding rows and columns, and Tab moves between cells. The source view is a textarea holding the raw HTML. When the user switches back, the HTML goes through the same sanitiser before it is written into the editor.
Those direct DOM changes break native undo. The browser's history only knows about edits it made itself, so after a table operation Ctrl+Z either does nothing or undoes the wrong thing. We ended up owning undo completely: snapshot the HTML after each change (debounced while typing), intercept undo and redo shortcuts, and restore from our own stack.
export class History {
private stack: string[] = [];
private index = -1;
constructor(private readonly limit = 100) {}
push(html: string): void {
if (this.stack[this.index] === html) return;
this.stack = this.stack.slice(0, this.index + 1);
this.stack.push(html);
if (this.stack.length > this.limit) this.stack.shift();
this.index = this.stack.length - 1;
}
undo(): string | undefined {
if (this.index <= 0) return undefined;
this.index -= 1;
return this.stack[this.index];
}
redo(): string | undefined {
if (this.index >= this.stack.length - 1) return undefined;
this.index += 1;
return this.stack[this.index];
}
}Restoring innerHTML loses the caret, so each snapshot also stores the selection as character offsets from the start of the editor, which we map back to a Range after restoring. Without that, undo technically works but feels broken.
Typing the public API Writing the editor in TypeScript paid off mostly at the edges. Toolbar items are a string union rather than free text, so a typo in a project's config is a compile error. Callbacks are typed with the HTML string they receive, and the sanitiser is an optional prop with a typed signature, so projects with stricter rules can supply their own. Internally, being forced to handle a possibly null Selection caught several real bugs where the editor had lost focus.
What we'd do again Keep the scope small and say no to features that need a document model, such as collaborative editing. Wrap execCommand behind one function and treat it as replaceable. Own the undo stack from the start, not after the first structural feature breaks native undo. Sanitise against an allowlist on paste and on source view, and again on the server. Test paste with real content from word processors, browsers and email clients, not hand-written HTML. Check every selection belongs to the editor before acting on it. A dependency-free editor is not the right choice for every project. If you need collaboration, comments or a structured document model, a mature framework will save you months. For a focused editor inside a CMS or admin panel, it is very doable, and you end up understanding exactly what your editor does to your content.
Infigini IT Services · 11 Kanwali Road, Dehradun 248001, India · info@infigini.com · +91 95684 87789