ProseMirror实战:打造AI超级输入框教程
开发知识库问答输入框的 @文档 能力时,初期判断是轻量交互。深入后发现,编辑器的稳定性才是核心挑战。
本文按实际开发路径展开:分析最直接的 DOM 方案及其缺陷,解释为何最终切换至 ProseMirror,最后给出 @文档 的完整实现。快速浏览图示即可掌握核心要点。
对比图1和图2,哪张图示更直观地呈现了整体流程?
01 背景:编辑器稳定性
知识库问答输入框内,@文档 是关键能力之一。它允许用户与 AI 协作时自由组织意图与上下文,提升内容编排的灵活性。
初期需求分析时,采用 contenteditable 容器监听输入,识别光标前的 @query,弹出候选并插入不可编辑引用节点。当时未选择 ProseMirror,认为这是轻量交互,但随后发现处理“文本 + 原子节点”混排时,复杂度迅速上升。
该路径虽能快速产出可用版本,但进入深水区后暴露出关键问题:光标在嵌套节点中难以稳定恢复;输入法组合输入时修改 DOM 易导致候选错位;innerHTML 修复结构会污染撤销重做栈;临时交互态(如高亮、弹窗锚点)混入文档后难以维护。正是这些痛点,促使架构切换至 ProseMirror,并深刻理解了它的价值。
02 ProseMirror 架构与扩展点
切换到 ProseMirror 后,它成为更稳固的底座。ProseMirror 基于不可变文档 doc 与事务 Transaction 构建,融合编辑器状态 EditorState 与视图层 EditorView,并通过 Schema、Plugin、NodeView、Decoration 等扩展点协同工作,处理跨浏览器的 contenteditable、选区、IME 输入与光标映射。
简要了解整体架构后,我们聚焦于 @文档 能力中使用的 ProseMirror 特性。
先从 Schema 切入。Schema 定义编辑器的组成元素,以及 ProseMirror Node 与真实 DOM 间的转换规则。输入框内需要三种元素:基础 textNode 文本、docrefNode(@到的文档)以及常被忽略的 hardBreakNode(换行能力)。输入框应由这三部分组成。
Schema 核心设计如下:
import { Schema, NodeSpec } from 'prosemirror-model'
// 原子行内引用节点(docref)
const docrefNode: NodeSpec = {
inline: true,
group: 'inline',
atom: true,
selectable: true,
attrs: {
id: { default: '' },
label: { default: '' },
mtype: { default: 'doc' },
},
toDOM(node) {
const { id, label, mtype } = node.attrs
const attrs: Record = { type: mtype }
if (id) attrs.id = id
return ['mention', attrs, label]
},
parseDOM: [{
tag: 'mention',
getAttrs(dom) {
const el = dom as HTMLElement
const type = (el.getAttribute('type') || '').toLowerCase()
const id = el.getAttribute('id') || ''
const label = el.textContent || ''
if (type === 'no-access') {
return { id: '', label, mtype: 'no-access' }
}
return { id, label, mtype: 'doc' }
},
}],
}
// hardBreakNode 实现略
docrefNode 的 attrs 定义三个字段:id 标识 @文档的唯一标识符;label 为展示文本(通常是文档标题);mtype 用于向后兼容,未来支持更丰富的类型(如 VAPD、任务等)。toDOM 定义节点到 HTML 的转换,parseDOM 则定义如何从 HTML 片段解析为该节点。
再看 hardBreakNode,本质上是一个 br 标签:
const hardBreakNode: NodeSpec = {
inline: true,
group: 'inline',
selectable: false,
parseDOM: [{ tag: 'br' }],
toDOM: () => ['br'],
}
03 交互逻辑
借助 Schema 定义了三类节点:text、docref、hard_break。输入阶段还会出现“活跃查询”态(@ 后的即时查询与高亮),但它不属于文档结构,应作为渲染层的临时状态处理。
流程清晰:触发(行首或空格后输入 @,进入活跃态,计算匹配范围与查询字符串)、显示(为匹配范围添加查询高亮块,用于占位与高亮,同时精准定位弹窗以便选择)、确认(选择候选后,通过一次事务插入 docref 节点,并将光标移至其后)。
核心能力由 Suggestion(ProseMirror 的 Plugin 实现)提供匹配/装饰,createMentionPlugin 作为组合层对接弹窗渲染(SuggestRenderer),@ 后弹窗的样式与交互也集中在渲染层完成。
export const createMentionPlugin = (opts = {}) =>
Suggestion({
pluginKey: DocMentionPluginKey,
char: '@',
allowedPrefixes: [' '], // 行首或空格后触发(传 null 则放开前缀限制)
allowSpaces: false,
allowToIncludeChar: false,
decorationClass: 'pm-mention-query',
decorationContent: '输入文档名称',
render: () => createDocSuggestRenderer({ getItems: opts.getItems, onSelect: opts.onSelect })(),
})
核心逻辑封装在 Suggestion 和 createDocSuggestRenderer 中。Suggestion 完全基于 ProseMirror 实现。深入 Suggestion 之前,先明确查询高亮块属于临时状态,最终会被选中的 mention 替换,因此不写入文档 Schema 更合理。编辑器支持撤销/重做,不应将搜索中间态压入历史栈。ProSeMirror 的 Decoration 专为此场景设计,仅在渲染层出现,用于显示与定位,不影响文档结构。
return DecorationSet.create(state.doc, [
Decoration.inline(range.from, range.to, {
nodeName: 'span',
class: isEmpty ? 'pm-mention-query is-empty' : 'pm-mention-query',
// 以 data-decoration-id 将装饰节点与插件状态绑定,便于精准定位弹窗
'data-decoration-id': decorationId,
'data-decoration-content': '输入文档名称',
}),
])
Suggestion 的实现
按“触发 → 显示 → 确认”流程说明:
- 触发(监听):每个事务在 state.apply 中调用 findSuggestionMatch,根据 char/allowedPrefixes/allowSpaces 在光标前匹配触发串,得到 range/query/text。
- 显示(弹窗):弹窗渲染由 createDocSuggestRenderer 负责,返回 { onBeforeStart, onStart, onBeforeUpdate, onUpdate, onExit, onKeyDown }。onStart/onUpdate 接收的 props.clientRect() 用于定位。
- 确认(插入):弹窗内部点击候选触发 select(item),经由 createDocSuggestRenderer 的 onSelect(item) 抛出;外层 createMentionPlugin 的 onSelect 接住并调用 insertDocRef(attrs),最终将 docref 原子节点插入文档并将光标移至其后。
从弹窗回写 docref 的路径:渲染器内部点击候选时触发 select(item),外部通过 onSelect(item) 接住并插入。本文实现中,createMentionPlugin 传入的 onSelect 会调用组件的 insertDocRef({ id, label, subtitle }):
createMentionPlugin({
getItems: async q => /* 拉取 { id, label, subtitle }[] */,
onSelect: item => {
handleSelectSuggestion({ id: String(item.id), label: item.label, subtitle: item.subtitle })
}
})
function handleSelectSuggestion(attrs: DocRefAttrs) {
insertDocRef(attrs) // 创建 docref 原子节点 + Hair Space,并将光标置于其后
}
至此,核心流程完成。
04 结语:稳定性与结构化治理
回溯全文,核心目标是确保 @文档 在真实输入场景下稳定可用:光标不跳转、输入法不中断、撤销重做可预期、异步检索不串结果。
本次实现的完成度可概括为“主链路闭环 + 关键稳定性收敛”:触发、匹配、渲染、选择、插入流程完整;文档内容与临时状态分层隔离;核心交互在事务边界内可控。
它并非一次性解决所有问题,但将复杂度从经验修补推进到结构化治理。如果你正处理 mention、标签引用、变量插入等富文本能力,这种理清问题、拆解边界的思路值得借鉴。




