Rich-text editors

Keep documents, chips, and undo inside the editor.

Markdown Source

Mention owns suggestions. Your editor owns the document and every edit to it. This includes mention nodes, selection, serialization, clipboard handling, and history.

The ProseMirror example below uses real mention nodes and editor transactions. The Lexical integration provides a second executable example with its own nodes, selection mapping, clipboard serialization, and history. Its browser tests cover inserting mentions across paragraphs, preserving formatting during paste, undo, redo, and node deletion.

Try ProseMirror

Type @Alice in the rich message field, press Enter to insert, then undo. Add another paragraph and mention Bob. The first mention remains a document node.

Connect an editor

Inside Mention.Root, call useMentionContext<Person>(). Register an EditorAdapter<Person> with setEditor(adapter), and clear it with setEditor(null) when the editor is destroyed.

The adapter has three operations:

  1. read() returns a text region and a collapsed caret, or null when editing is unavailable. A paragraph is a useful region; scan boundaries must not join adjacent blocks. Include a key when two regions could have identical text.
  2. getCaretRect() returns viewport coordinates from the editor's selection API.
  3. replace(edit, item, meta) applies one range replacement through an editor transaction. Insert a text node or a mention node according to your schema.

After every document or selection transaction, call refresh(). Forward keyboard events to handleKeyDown(event) before your editor's other key bindings. Keep the editable host a multiline textbox (role="textbox", aria-multiline="true") with an accessible name. Apply getEditorProps() to update its suggestion relationships as the state changes; the getter does not assign the host's role. During composition, read() should return null and the editor must preserve its own IME handling.

Position mapping

Offsets in EditorSnapshot and MentionEdit use UTF-16 and must agree. The ProseMirror example scans the current paragraph, represents each inline atom with one placeholder character, and translates range offsets back into document positions. It never flattens the document or replaces its DOM.

The repository example at packages/react/examples/ProseMirror.tsx also demonstrates schema serialization and history grouping.

Other editors can use this interface, but their adapters need independent validation against their selection and transaction models. The ProseMirror and Lexical examples each have their own browser tests; they do not establish compatibility with Tiptap, Slate, or other editors.

Copy the full implementation

Install the example's editor dependencies alongside Mention:

Install ProseMirror
npm install prosemirror-model prosemirror-state prosemirror-view prosemirror-history prosemirror-keymap prosemirror-commands

Import @danielivanov/mention/styles.css for the default suggestion styles. The complete file below is the source used by the live example and its browser tests. It includes the schema, adapter, transactions, attribute updates, and cleanup; no separate adapter snippet is maintained.

ProseMirror.tsx
"use client";

import {
  type EditorAdapter,
  Mention,
  useMentionContext,
} from "@danielivanov/mention";
import { baseKeymap, toggleMark } from "prosemirror-commands";
import { closeHistory, history, redo, undo } from "prosemirror-history";
import { keymap } from "prosemirror-keymap";
import { Schema } from "prosemirror-model";
import { EditorState, TextSelection } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import { useLayoutEffect, useRef, useState } from "react";

type Person = { id: string; name: string };
const people: Person[] = [
  { id: "alice", name: "Alice" },
  { id: "bob", name: "Bob" },
];
const schema = new Schema({
  nodes: {
    doc: { content: "paragraph+" },
    paragraph: {
      content: "inline*",
      group: "block",
      toDOM: () => ["p", 0],
      parseDOM: [{ tag: "p" }],
    },
    text: { group: "inline" },
    mention: {
      group: "inline",
      inline: true,
      atom: true,
      attrs: { id: {}, label: {} },
      toDOM: (node) => [
        "span",
        {
          "data-mention-id": node.attrs.id,
          "data-mention-label": node.attrs.label,
          contenteditable: "false",
          style:
            "background:#e0e7ff;color:#312e81;border-radius:4px;padding:0 3px",
        },
        `@${node.attrs.label}`,
      ],
      parseDOM: [
        {
          tag: "span[data-mention-id]",
          getAttrs: (el) => ({
            id: el.dataset.mentionId,
            label: el.dataset.mentionLabel,
          }),
        },
      ],
      leafText: (node) => `@${node.attrs.label}`,
    },
  },
  marks: {
    strong: {
      toDOM: () => ["strong", 0],
      parseDOM: [{ tag: "strong" }, { tag: "b" }],
    },
  },
});

function Editor({ onDocument }: { onDocument: (doc: string) => void }) {
  const mention = useMentionContext<Person>();
  const latest = useRef(mention);
  const host = useRef<HTMLDivElement>(null);
  const viewRef = useRef<EditorView | null>(null);
  useLayoutEffect(() => {
    latest.current = mention;
  });

  useLayoutEffect(() => {
    const view = new EditorView(host.current!, {
      state: EditorState.create({
        schema,
        plugins: [
          history(),
          keymap({
            "Mod-z": undo,
            "Mod-Shift-z": redo,
            "Mod-y": redo,
            "Mod-b": toggleMark(schema.marks.strong!),
          }),
          keymap(baseKeymap),
        ],
      }),
      attributes: {
        role: "textbox",
        "aria-label": "Rich message",
        "aria-multiline": "true",
        style:
          "min-height:100px;border:1px solid #999;padding:8px;white-space:pre-wrap",
      },
      handleKeyDown: (_view, event) => latest.current.handleKeyDown(event),
      handleDOMEvents: {
        compositionend: () => {
          queueMicrotask(() => latest.current.refresh());
          return false;
        },
      },
      dispatchTransaction(transaction) {
        view.updateState(view.state.apply(transaction));
        onDocument(JSON.stringify(view.state.doc.toJSON()));
        latest.current.refresh();
      },
    });
    viewRef.current = view;
    const adapter: EditorAdapter<Person> = {
      element: view.dom,
      read() {
        const { empty, $from } = view.state.selection;
        if (
          !empty ||
          !$from.parent.isTextblock ||
          view.composing ||
          !view.hasFocus()
        )
          return null;
        // One placeholder per atom preserves ProseMirror's position units.
        // Only the current paragraph is scanned, so mentions cannot cross blocks.
        return {
          text: $from.parent.textBetween(
            0,
            $from.parent.content.size,
            "",
            "\ufffc",
          ),
          caret: $from.parentOffset,
          key: $from.start(),
        };
      },
      getCaretRect() {
        const { left, top, bottom } = view.coordsAtPos(
          view.state.selection.from,
        );
        return new DOMRect(left, top, 0, bottom - top);
      },
      replace(edit, person) {
        const { $from } = view.state.selection;
        const marks = view.state.storedMarks ?? $from.marks();
        const from = $from.start() + edit.from;
        const to = $from.start() + edit.to;
        const node = schema.nodes.mention!.create(
          { id: person.id, label: person.name },
          null,
          marks,
        );
        const transaction = closeHistory(view.state.tr).replaceWith(from, to, [
          node,
          schema.text(" ", marks),
        ]);
        transaction.setSelection(
          TextSelection.create(transaction.doc, from + 2),
        );
        view.dispatch(transaction.scrollIntoView());
        // Subsequent typing gets its own undo group.
        view.dispatch(closeHistory(view.state.tr));
        view.focus();
      },
    };
    latest.current.setEditor(adapter);
    return () => {
      latest.current.setEditor(null);
      viewRef.current = null;
      view.destroy();
    };
  }, [onDocument]);

  useLayoutEffect(() => {
    const attributes: Record<string, string> = {
      role: "textbox",
      "aria-label": "Rich message",
      "aria-multiline": "true",
      style:
        "min-height:100px;border:1px solid #999;padding:8px;white-space:pre-wrap",
    };
    for (const [key, value] of Object.entries(mention.getEditorProps())) {
      if (value !== undefined) attributes[key] = String(value);
    }
    viewRef.current?.setProps({ attributes });
  });
  return <div ref={host} />;
}

export function ProseMirrorDemo() {
  const [document, setDocument] = useState("");
  return (
    <Mention.Root<Person>
      items={people}
      getKey={(p) => p.id}
      getLabel={(p) => p.name}
    >
      <Editor onDocument={setDocument} />
      <Mention.Popover container={null} aria-label="People">
        <Mention.List<Person>>
          {(person) => (
            <Mention.Item value={person}>{person.name}</Mention.Item>
          )}
        </Mention.List>
        <Mention.Empty>No people found</Mention.Empty>
      </Mention.Popover>
      <output data-testid="editor-document" style={{ display: "none" }}>
        {document}
      </output>
    </Mention.Root>
  );
}

Adapt the schema, labels, styling, and history behavior to your editor. Keep the snapshot and replacement offsets consistent, and verify insertion, formatting, undo/redo, and selection changes in your actual document model. Editor ownership explains that boundary in more detail.