Skip to main content

A rich text editor

Status: implementedsrc/rich-text-editor/, the reference. This is the design record: what else was looked at, what the component is for, why the model is ProseMirror's and the view is this package's, and what a plugin written for a browser can and cannot expect of it.

What it is for

The use cases decided the shape, so they come first. Each needs one more rung of the API than the one above it.

Use caseWhat it needs
A notes app that keeps markdown filesMarkdown in and out, a toolbar, undo, the clipboard. Never ProseMirror.
A comment box, an issue tracker's fieldThe same, a placeholder, readOnly for display, a height that follows the text.
A chat composerEnter sends and Shift+Enter breaks the line; grows to a height, then scrolls; no toolbar.
A mail or CMS fieldHTML in and out, and a paste from a web page or a word processor that keeps its structure.
A knowledge base with blocks of its ownCustom node types drawn as components — a callout, an embed — and looks per mark.
An app with an editor platform of its ownProseMirror plugins, schemas and commands, as written for the browser.
Collaborative editing, history, versioningThe app owns the EditorState and sees every transaction.

Two things all of them share, which is why this is one component rather than several: a document with structure (lists, quotes, code, tables), and the editing conventions of the desktop it runs on.

Prior art

EditorModelViewWhat was taken, or why not
ProseMirrorSchema-checked tree; integer positions; invertible, mappable stepsEditorView over contenteditableThe model, wholesale. Everything but the view runs without a DOM, and its plugins are data and functions.
TipTapProseMirror, plus an extension layerProseMirror'sThe handle's vocabulary: isActive, can, insertContent, updateAttributes for node views. Its node names are read.
Remirror, Milkdown, BlockNoteProseMirror, wrappedProseMirror'sEvidence that ProseMirror is the model an ecosystem forms around. Milkdown's markdown-first stance matches the default here.
LexicalKeyed node classes; immutable editor stateIts own DOM reconcilerStable node keys — this editor's block keys are the same idea. Its plugins are React components over the DOM.
Slate (and Plate)Schema-less nested JSON; operationsslate-react over the DOMRendering as a function of node type (renderElement) — nodeViews here. No schema means no validated document.
QuillDelta, a flat op listParchment blots in the DOMThe toolbar as a declarative list of names. A flat model cannot say a list inside a quote.
Draft.jsBlocks and entitiesReact DOMArchived by its authors; nothing taken.
CKEditor 5Its own model, view and conversionIts own DOM layerThe bar for pasting from Word and Google Docs. GPL or commercial — not a dependency an MIT package can take.
Editor.jsA list of JSON blocks, each a toolOne DOM island per blockBlocks are not a document: no selection across them.
MDXEditorLexical, with mdast in and outLexical'sConfirms markdown as the value people want from a WYSIWYG field.
Typora, ObsidianMarkdown sourceSource with the markup hiddenThe markdown shortcuts. Editing the source itself was rejected: lists and tables are harder, not easier, that way.
Qt QTextDocumentBlocks, fragments, frames, lists, tablesQTextEditThe desktop's conventions, and the evidence: a native toolkit's editor reads and writes HTML and, since 5.14, markdown.
GTK GtkTextBufferText with tags, marks and iteratorsGtkTextViewMarks — positions that survive edits — are what the block keys are for blocks. No structure beyond styled runs.
AppKit NSTextViewAn attributed string, paragraph styles, text listsTextKitThe macOS conventions: Alt for words, Cmd for lines, one text for VoiceOver.

Why ProseMirror's model, and nothing else of it

<CodeEditor> wrote its own model, and for code that was right: code is lines of text. A rich document is a tree whose validity is a question — can a list hold a table, can a heading hold an image — and whose edits have to map positions (for undo, for collaboration, for a decoration that must follow text as it moves). ProseMirror is a decade of answers to exactly that: prosemirror-model validates against a schema, -transform makes every edit an invertible, mappable step, -state makes the editor's state one immutable value with plugin state inside it, and -commands, -history, -keymap, -inputrules and -schema-list are the editing behaviour people expect, already argued over. None of those modules touches a DOM.

The one that does is prosemirror-view, and it is the part this package would have to replace under any library: a view over contenteditable is a view over a browser. So the design is to keep everything else and write the view — shaped like EditorView, so the ecosystem's code, which is written against that shape, has somewhere to land.

The alternatives fail on that same question. Lexical's and Slate's models can run headless, but their plugins are React components rendering DOM, so their ecosystems do not come along. Quill's modules are DOM modules. CKEditor's licence rules it out before its architecture does.

The ladder

One element, and each rung is an opt-in on top of the one below — never a second API that has to be migrated to (the continuity rule <Table>'s PRD set):

// 1. markdown in and out
<RichTextEditor value={body} onChange={(ev) => setBody(ev.value)} />

// 2. the behaviours an app would otherwise wire by hand
<RichTextEditor toolbar placeholder="Reply…" submitOnEnter onSubmit={send} suggestions={[mentions]} />

// 3. what the document is written in, and how its parts look
<RichTextEditor format="html" markStyles={{ code: { bg: '#eef' } }} nodeViews={{ image: Figure }} />

// 4. ProseMirror's own seams
<RichTextEditor plugins={[mentions, spellcheck]} editorProps={{ handlePaste }} schema={mySchema} />

// 5. the app owns the EditorState
<RichTextEditor state={state} dispatchTransaction={(tr) => setState((s) => s.apply(tr))} />

The contract that makes it a ladder: what a rung adds, the rung above it keeps. A state-owning app still gets the toolbar, the looks and the node views; a plugin passed at rung 4 still sees the value flow of rung 1; the handle (ref) is the same object at every rung.

How it is built

The view (view.ts) is RichEditorView. It holds the EditorState, dispatches, runs someProp in ProseMirror's order, keeps plugin views, and answers the geometry questions (posAtCoords, coordsAtPos, endOfTextblock) from the laid-out text. It turns react-x11's keys, presses, composition and focus into what a browser's contenteditable would have done — after offering each to the plugins' props first.

Rendering (render.ts) is composition, by the rule AGENTS.md sets: the viewport scrolls rather than transforms, and a document's block flow is what flexbox already lays out, the way <Markdown> draws the same content. A block is a memoized component keyed by node identity; ProseMirror shares every node an edit did not touch, so typing re-renders one paragraph and the containers above it.

Each textblock is a retained element, <richeditortext>: <richtext> plus a selection band and a caret that the view sets directly. A caret that blinks twice a second and a drag that extends sixty times a second never re-render anything.

Block keys (keys.ts) are what a block is known by. Positions move on every keystroke before them, so a block never receives one: its key is mapped through each transaction's steps, and it asks the view for its position when it needs it — which is what makes getPos() a function in a node view.

InlineMap (inline.ts) is the bridge between ProseMirror's positions (UTF-16 units, an inline leaf counting one) and what <richtext> draws (code points, an image as its alt text, a hard break as a line feed, widgets with no document width, the space an empty block is drawn with). It is total and monotonic in both directions and pure — the same role <CodeEditor>'s tab map plays, and the part to get right once.

The clipboard (clipboard.ts) is prosemirror-view's own copy and paste, ported: data-pm-slice on the way out, the context-aware parse on the way in, and every clipboard prop in the same order. HTML is read through a DOM shim over htmlparser2 and css-select (html.ts) — enough for DOMParser.fromSchema and a schema's parseDOM rules, so a paste from a web page is read by the schema's own rules.

Markdown is <Markdown>'s parser, moved to src/internal/markdown/ so both components share it, and a serializer written for it (stringify.ts). A round trip is asserted over a generated corpus: no document may lose text or change on a second trip. Building it found four parser bugs <Markdown> had been rendering all along — a blank line inside a fence in a list item ended the item, a fence opened on a task item's line was missed, an escaped entity was decoded anyway, and emphasis beside an emoji read half of it as punctuation — and they are fixed for both. The AST also keeps two things it used to drop, so a document survives a save: a fence's whole info string and a link's title.

Keys are named the way prosemirror-keymap expects. toDomKeyEvent builds a DOM-shaped event from the keysym, with the Latin key a chord was pressed on — Ctrl+B under a Russian layout is still Mod-b — and swaps Ctrl and Meta when the backend's primary modifier and the host's disagree: prosemirror-keymap reads navigator.platform, which under Node on a Mac says Mac even when the app is drawing to an X server.

Suggestions (suggest.ts) are a plugin whose state is the open list — the trigger, the word typed after it, the rows and the highlighted one — and whose handleKeyDown takes the list's keys while it has rows. Its plugin view asks for the rows as the query changes and drops an answer to a query already typed past, <CodeEditor>'s completion rule. The component draws a <popup> from nothing but that state, hung off the textblock the trigger is in (anchorAt), so an app that owns the state gets the list by putting the plugin in it. A list opens as its trigger's word is typed, never as the caret moves into one: the value is markdown, so a mention stays text, and a document soon holds many words that start with @.

Tables (tables.ts) are prosemirror-tables' commands, wrapped so a table in markdown's shape — one header row, first, alignment by column — stays in it; TableView sizes each column to its widest cell the way <Markdown> does, measuring a cell again only when its node changed; and the table's own buttons are in the toolbar only with the caret in a table.

Collaborators' carets (collab.ts) are y-prosemirror's cursor widgets made drawable. Its cursor plugin builds each with a cursorBuilder, which by default makes a DOM element; remoteCaret makes a description instead, and the view, asking a text-less widget's toDOM once, draws what comes back as a bar beside the editor's own caret. The plugin views that need view.dom — y-prosemirror's listens on it for focus — are made once the root element exists, not in the view's constructor.

Drag and drop (drag.ts) is core's gesture over the root element, which is a drag source and a drop target. A press on the selection is the one press the view leaves unclaimed, so that core can arm a drag; a drag carries a copy's HTML and text and, for a drop in the app, the slice itself. A drop is read like a paste and put in where dropPoint says it fits — prosemirror-view's own drop, step for step.

Long documents (virtual.ts) draw a window of the top-level blocks, over the machinery <Tree> and <Table> share (src/internal/): a height index keyed by block key, the window, and a reveal that is a debt rather than a one-shot. Each drawn block is in a box measured after layout. The view's geometry is laid-out text, so it asks the window whether the block a position is in is drawn; when it is not, scrollToSelection and the motions that need a line have the block revealed first, and finish once it is laid out.

What a plugin can count on

The ledger, so "does my plugin work?" has an answer short of trying it.

EditorView memberHere
state, dispatch, props, someProp, setProps, update, updateStateYes. someProp in ProseMirror's order.
editable, composing, hasFocus(), focus(), isDestroyed, destroy()Yes.
posAtCoords, coordsAtPos, endOfTextblockYes, in logical window coordinates — for a position in a drawn block; in a long document's undrawn one, coordsAtPos answers the editor's corner.
pasteText, pasteHTMLYes, through the same props a real paste goes through.
domThe root element — a react-x11 node, not an HTMLElement — with addEventListener for the focus events; docView is truthy while it is mounted.
draggingThe slice a drag out of this editor carries, and whether a drop back in here moves it.
root, domAtPos, nodeDOM, posAtDOM, domSelection()No. There is no DOM to answer with.
View propHere
handleKeyDown, handleKeyPress, handleTextInputYes. The event is a DomKeyEvent; composition commits go through handleTextInput.
handleClickOn, handleClick, and the double and triple formsYes. The event has clientX/clientY (logical), button, detail, the modifiers.
handlePaste, transformPasted, transformPastedHTML, transformPastedText, clipboardParser, clipboardTextParser, domParserYes.
transformCopied, clipboardSerializer, clipboardTextSerializerYes.
decorationsInline and node decorations; widgets whose spec carries text.
editable, handleScrollToSelection, dispatchTransactionYes.
nodeViewsNot as DOM constructors: the component's nodeViews take React components.
handleDropYes. The event is DOM-shaped: dataTransfer.getData answers what was read, and its files are { name, path, uri }.
handleDOMEvents, markViews, attributes, createSelectionBetweenNo.

What that means for the plugins people reach for:

  • prosemirror-history, -keymap, -inputrules, -commands, -schema-list run in every editor — they are the default plugins, and the tests drive them through real key events.
  • A decoration plugin — highlighting, search results, spellcheck underlines, a collaborator's selection — is drawn, as long as a widget says what it draws with text. The example's TODO highlighter is one, unchanged from what a browser would run.
  • prosemirror-tables' commands run here: they are the editor's own table commands (tables.ts). Its tableEditing() plugin's cell selection listens to DOM mouse events and does not arm, so there is no selecting a block of cells yet.
  • y-prosemirror is verified (test/rich-text-editor-collab.test.ts): its sync, undo and cursor plugins run unchanged over two relayed editors, and a collaborator's caret is drawn when the cursor plugin is handed remoteCaret for its cursor builder — the default one makes a DOM element. prosemirror-collab is state and steps by construction.

Decisions

  • Markdown is the default value. It is what the apps in the first table store, and this package already reads it: what an editor wrote is what a <Markdown> beside it shows, by the same parser.
  • A reset is not undoable. value changing and setValue replace the document outside the history — a form reset, not an edit — so Undo never brings back the draft an app just cleared.
  • The toolbar is a caller of the API, not a second one. Its buttons run the exported commands and ask the state whether they are on, as an app's own toolbar would; they are not focusable, so the caret stays where it was.
  • The editor opens nothing. Links go to onLink, images to renderImage: a document is not a licence to make requests.
  • Tab belongs to lists, code and tables, and Escape frees it. Anywhere else Tab moves focus on, as it does from a browser's contenteditable; after Escape it leaves from anywhere.
  • The copy is kept in process. react-x11's clipboard is text-only on macOS for now, so a copy also keeps its slice, and a paste of exactly that text takes the slice back — or copy and paste within one editor would flatten every list on a Mac.
  • Subpath only. ProseMirror's declarations name DOM globals; exported from the barrel, they would be every app's problem, including apps that want a calendar.
  • Virtualized by top-level block. Past 200 top-level blocks only the ones near the viewport are mounted — <Tree>'s and <Table>'s window and height index, keyed by block key. A block's children are drawn with it, so one enormous list or table is drawn whole: windowing inside a block is not worth its cost until a document shows it is.
  • A suggestion opens as its word is typed, never as the caret moves. The value is markdown and a mention stays text, so a document is soon full of words that start with @, and a click into one places a caret. GitHub's comment box and <CodeEditor>'s completion keep the same rule.
  • A mention is text unless a row says otherwise. Markdown has no mention syntax — GitHub stores @ada as text too. A row's insert can be a node: text with a link mark, or a mention node of an app's own schema. A chip waits on inline widgets.
  • An Escape a plugin claims is the plugin's. Escape arms one Tab that leaves the editor unless a plugin's handleKeyDown took it — a suggestion list closing. The next Escape arms the Tab: while a list is open the way out is one key longer, never gone.

Follow-ups

  • IME tiers (react-x11#272) — the preedit is drawn and committed; the candidate window's placement and the preedit's own cursor and segments are core's to deliver.
  • Inline widgets as components — the same gate as MDX's inline half: a <richtext> run that reserves advance width for an element. A mention drawn as a chip, avatar and all, waits on the same run; until then a mention is text, or text with a mark.
  • Images in textrenderImage draws an image alone in its paragraph; one inside a line needs the same inline-widget run.