How to Build a Stable @Document Mention Input with ProseMirror

This article walks through implementing an @document mention feature for a knowledge‑base Q&A input box, comparing a naïve contenteditable approach with a ProseMirror‑based solution, detailing schema design, plugin and suggestion handling, and the stability challenges of cursor, IME, and undo/redo.

vivo Internet Technology
vivo Internet Technology
vivo Internet Technology
How to Build a Stable @Document Mention Input with ProseMirror

Background

In a knowledge‑base Q&A product we needed an @document capability: users type @ and select a document, similar to a seasoning tray for an AI agent. The initial idea was a simple contenteditable implementation, but mixing text nodes with atomic nodes quickly exposed stability problems.

Why Switch to ProseMirror

Using a plain contenteditable container worked at first, but we ran into issues such as:

Cursor position could not be reliably restored inside nested nodes.

Input Method Editors (IME) modify the DOM during composition, breaking candidate selection.

Correcting structure with innerHTML polluted the undo/redo stack.

Temporary UI states (highlights, pop‑up anchors) interfered with the document model.

These pain points led us to adopt ProseMirror, which offers an immutable document model, transactions, and a rich plugin system.

ProseMirror Architecture

ProseMirror revolves around doc (immutable document) and Transaction, with EditorState and EditorView. Extensions such as Schema, Plugin, NodeView, and Decoration handle cross‑browser contenteditable, selection, and IME input.

Schema Design

Our input box needs three node types: text – plain text. docref – an atomic inline node representing the referenced document. hard_break – a line‑break node.

The schema is defined as follows:

import { Schema, NodeSpec } from 'prosemirror-model'</code>
<code>// Atomic inline reference node (docref)</code>
<code>const docrefNode: NodeSpec = {</code>
<code>  inline: true,</code>
<code>  group: 'inline',</code>
<code>  atom: true,</code>
<code>  selectable: true,</code>
<code>  attrs: {</code>
<code>    id: { default: '' },</code>
<code>    label: { default: '' },</code>
<code>    mtype: { default: 'doc' }</code>
<code>  },</code>
<code>  toDOM(node) {</code>
<code>    const { id, label, mtype } = node.attrs</code>
<code>    const attrs = { type: mtype }</code>
<code>    if (id) attrs.id = id</code>
<code>    return ['mention', attrs, label]</code>
<code>  },</code>
<code>  parseDOM: [{</code>
<code>    tag: 'mention',</code>
<code>    getAttrs(dom) {</code>
<code>      const el = dom as HTMLElement</code>
<code>      const type = (el.getAttribute('type') || '').toLowerCase()</code>
<code>      const id = el.getAttribute('id') || ''</code>
<code>      const label = el.textContent || ''</code>
<code>      if (type === 'no-access') {</code>
<code>        return { id: '', label, mtype: 'no-access' }</code>
<code>      }</code>
<code>      return { id, label, mtype: 'doc' }</code>
<code>    }</code>
<code>  }],</code>
<code>}</code>
<code>// hardBreakNode (br) – omitted for brevity

The docref node stores the document’s unique id, display label, and a mtype for future extensions.

Interaction Logic

The editor must handle three phases: Trigger (detect @ at line start or after a space), Display (show a highlighted query block and a pop‑up list), and Confirm (insert the docref node in a single transaction and place the cursor after it).

Triggering is done by listening to each transaction’s apply and using a Suggestion plugin to find matches. The highlighted query block is rendered with a Decoration so it does not become part of the document model, preserving undo/redo semantics.

When the user selects a candidate, the plugin’s onSelect handler calls insertDocRef(attrs) to create the atomic node and move the cursor.

Plugin and Suggestion Implementation

The core plugin is created with createMentionPlugin:

export const createMentionPlugin = (opts = {}) =></code>
<code>  Suggestion({</code>
<code>    pluginKey: DocMentionPluginKey,</code>
<code>    char: '@',</code>
<code>    allowedPrefixes: [' '], // trigger after space or line start</code>
<code>    allowSpaces: false,</code>
<code>    allowToIncludeChar: false,</code>
<code>    decorationClass: 'pm-mention-query',</code>
<code>    decorationContent: '输入文档名称',</code>
<code>    render: () => createDocSuggestRenderer({</code>
<code>      getItems: opts.getItems,</code>
<code>      onSelect: opts.onSelect</code>
<code>    })()</code>
<code>  })

The Suggestion plugin handles the three phases internally:

Trigger : findSuggestionMatch examines the character before the cursor using allowedPrefixes and allowSpaces to produce a range and query string.

Display : The renderer returned by createDocSuggestRenderer receives clientRect() for precise pop‑up positioning.

Confirm : Clicking a candidate calls select(item), which propagates to the outer plugin’s onSelect, ultimately invoking insertDocRef(attrs).

Decorations are created like this:

return DecorationSet.create(state.doc, [</code>
<code>  Decoration.inline(range.from, range.to, {</code>
<code>    nodeName: 'span',</code>
<code>    class: isEmpty ? 'pm-mention-query is-empty' : 'pm-mention-query',</code>
<code>    'data-decoration-id': decorationId,</code>
<code>    'data-decoration-content': '输入文档名称'</code>
<code>  })</code>
<code>])

This temporary decoration does not affect the document schema, keeping undo/redo clean.

Conclusion

The final implementation closes the end‑to‑end flow: typing @ triggers a query, the suggestion list appears, the user selects a document, and a docref node is inserted while the cursor moves forward. By separating transient UI state (decorations) from the immutable document model, we achieve stable cursor behavior, reliable IME composition, and predictable undo/redo.

Although not every edge case is solved, the approach moves the problem from ad‑hoc patches to a structured, maintainable architecture. Developers building mentions, tag references, or variable insertion in rich‑text editors can adopt the schema, plugin, and decoration patterns demonstrated here.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

frontendPluginschemaProseMirrordecorationmentiondocrefsuggestion
vivo Internet Technology
Written by

vivo Internet Technology

Sharing practical vivo Internet technology insights and salon events, plus the latest industry news and hot conferences.

0 followers
Reader feedback

How this landed with the community

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.