Tiptap 自定义 NodeView 如何将光标移出节点
问题现象
使用 Tiptap 自定义 NodeView 时,光标可能被限制在节点内部,或无法自然移动到节点前后的文本块。常见场景包括:
- 点击按钮后,希望继续在节点后输入。
- 在节点内容的边界按方向键时,希望光标离开节点。
- 自定义节点位于文档末尾,节点后没有可放置光标的段落。
核心原理
通过 NodeView 提供的 getPos() 获取当前节点位置:
- 节点之前的位置:
getPos() - 节点之后的位置:
getPos() + node.nodeSize
然后更新 ProseMirror 的 Selection,并重新聚焦编辑器。
推荐方案:Selection.near()
相比直接设置 TextSelection,Selection.near() 会在目标位置附近寻找有效选区。即使目标位置刚好位于两个块节点之间,也更不容易出现非法文本选区警告。
import { Selection } from '@tiptap/pm/state'
function moveCursorOutside(direction: 'before' | 'after') {
const view = editor.view
const nodePos = getPos()
if (typeof nodePos !== 'number') return
const targetPos =
direction === 'after'
? nodePos + node.nodeSize
: nodePos
const selection = Selection.near(
view.state.doc.resolve(targetPos),
direction === 'after' ? 1 : -1,
)
view.dispatch(
view.state.tr
.setSelection(selection)
.scrollIntoView(),
)
view.focus()
}
其中 bias 参数用于指定搜索方向:
1:优先向后寻找有效选区。-1:优先向前寻找有效选区。
React NodeView 示例
import { NodeViewWrapper } from '@tiptap/react'
import { Selection } from '@tiptap/pm/state'
export function CustomNodeView({ editor, node, getPos }) {
const moveAfter = () => {
const view = editor.view
const nodePos = getPos()
if (typeof nodePos !== 'number') return
const targetPos = nodePos + node.nodeSize
const selection = Selection.near(
view.state.doc.resolve(targetPos),
1,
)
view.dispatch(
view.state.tr
.setSelection(selection)
.scrollIntoView(),
)
view.focus()
}
return (
<NodeViewWrapper>
<button contentEditable={false} onClick={moveAfter}>
跳出节点
</button>
</NodeViewWrapper>
)
}
NodeView 内不参与编辑的按钮或控件应设置 contentEditable={false},避免浏览器把光标放进控件内容。
使用方向键跳出
可以监听方向键,并在满足边界条件时调用移动函数:
<NodeViewWrapper
onKeyDown={event => {
if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {
event.preventDefault()
moveCursorOutside('after')
}
if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {
event.preventDefault()
moveCursorOutside('before')
}
}}
>
{/* NodeView 内容 */}
</NodeViewWrapper>
如果 NodeView 内包含可编辑的 NodeViewContent,不要无条件拦截方向键。应先判断当前选区是否位于内容开头或结尾,只在边界处跳出,否则会破坏节点内部正常的光标移动。
简化方案:setTextSelection()
如果确定目标位置位于可编辑文本块中,可以使用 Tiptap 命令:
const pos = getPos() + node.nodeSize
editor
.chain()
.focus()
.setTextSelection(pos)
.run()
如果 pos 位于两个块节点之间,这种方式可能出现以下警告:
TextSelection endpoint not pointing into a node with inline content
因此,通用实现优先使用 Selection.near()。
节点位于文档末尾
如果自定义节点是文档最后一个块,后面可能没有能够容纳文本光标的位置。此时可以先插入一个段落,再聚焦到段落中:
const pos = getPos() + node.nodeSize
editor
.chain()
.insertContentAt(pos, { type: 'paragraph' })
.focus(pos + 1)
.run()
插入前应根据文档结构判断是否已经存在后继文本块,避免重复创建空段落。
结论
- 通用场景优先使用
Selection.near()。 - 已确定目标是合法文本位置时,可以使用
setTextSelection()。 - 节点位于文档末尾且后面没有文本块时,先插入段落再移动光标。
- NodeView 内有可编辑内容时,只在光标到达边界后拦截方向键。