跳到主要内容

Tiptap 自定义 NodeView 如何将光标移出节点

问题现象

使用 Tiptap 自定义 NodeView 时,光标可能被限制在节点内部,或无法自然移动到节点前后的文本块。常见场景包括:

  • 点击按钮后,希望继续在节点后输入。
  • 在节点内容的边界按方向键时,希望光标离开节点。
  • 自定义节点位于文档末尾,节点后没有可放置光标的段落。

核心原理

通过 NodeView 提供的 getPos() 获取当前节点位置:

  • 节点之前的位置:getPos()
  • 节点之后的位置:getPos() + node.nodeSize

然后更新 ProseMirror 的 Selection,并重新聚焦编辑器。

推荐方案:Selection.near()

相比直接设置 TextSelectionSelection.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 内有可编辑内容时,只在光标到达边界后拦截方向键。