# Rich-text editors

Keep documents, chips, and undo inside the editor.

Canonical: https://reactmention.com/docs/rich-text



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](/docs/lexical) 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 [#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.

<div className="docs-example not-prose">
  <ProseMirrorDemo />
</div>

## Connect an editor [#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 [#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](/docs/lexical) examples each have their own browser tests; they do not establish compatibility with Tiptap, Slate, or other editors.

## Copy the full implementation [#copy-the-full-implementation]

Install the example's editor dependencies alongside Mention:

```sh title="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.

```tsx title="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](/docs/internals) explains that boundary in more detail.
