跳到主要内容

Tiptap 自定义 NodeView 如何移入和移出光标

先说结论

在 Tiptap 里,自定义 NodeView 可以理解成编辑器中的一个“特殊内容块”,例如卡片、流程图、公式或提示框。

移动光标时,我们通常有 4 个目标:

  • 移到节点前面。
  • 移到节点后面。
  • 移到节点内部的开头。
  • 移到节点内部的末尾。

关键是先拿到这个节点的位置:

const start = getPos()
const end = start + node.nodeSize

可以把它想象成:

普通文字 | NodeView 内部内容 | 普通文字
↑ ↑
start end

因此:

  • start:节点前面。
  • start + 1:节点内部开头。
  • end - 1:节点内部末尾。
  • end:节点后面。

为什么不能直接设置光标位置

有些位置只是两个节点之间的边界,不能真正放置文本光标。如果强行使用 TextSelection,可能出现警告:

TextSelection endpoint not pointing into a node with inline content

更稳妥的做法是使用 Selection.near()。它的含义很简单:

从指定位置出发,在附近寻找一个真正可以放置光标的位置。

最简单的完整示例

下面的 NodeView 中有 3 个按钮,分别把光标移到节点前、节点内部和节点后。

import {
NodeViewContent,
NodeViewWrapper,
} from '@tiptap/react'
import { Selection } from '@tiptap/pm/state'

export function CustomNodeView({ editor, node, getPos }) {
const moveCursor = (
target: 'before' | 'inside' | 'after',
) => {
// 当前 NodeView 的起始位置
const start = getPos()

if (typeof start !== 'number') return

// 当前 NodeView 结束后的位置
const end = start + node.nodeSize

let position: number
let direction: 1 | -1

if (target === 'before') {
position = start
direction = -1
} else if (target === 'inside') {
position = start + 1
direction = 1
} else {
position = end
direction = 1
}

const view = editor.view

const selection = Selection.near(
view.state.doc.resolve(position),
direction,
)

view.dispatch(
view.state.tr
.setSelection(selection)
.scrollIntoView(),
)

view.focus()
}

return (
<NodeViewWrapper>
<button
contentEditable={false}
onMouseDown={event => {
event.preventDefault()
moveCursor('before')
}}
>
移到节点前
</button>

<button
contentEditable={false}
onMouseDown={event => {
event.preventDefault()
moveCursor('inside')
}}
>
移入节点
</button>

<NodeViewContent />

<button
contentEditable={false}
onMouseDown={event => {
event.preventDefault()
moveCursor('after')
}}
>
移到节点后
</button>
</NodeViewWrapper>
)
}

代码逐步解释

1. getPos() 是什么

getPos() 是 Tiptap 提供给 NodeView 的函数,用来获取当前节点在整篇文档中的位置。

const start = getPos()

它返回的是节点开始前的位置,而不是节点内部的位置。

2. node.nodeSize 是什么

node.nodeSize 表示当前节点在文档中占用了多少位置。

const end = start + node.nodeSize

把开始位置和节点大小相加,就能得到节点结束后的位置。

3. 为什么移入节点要加 1

start 位于节点外面。要进入节点,需要跨过节点的开始边界:

const insideStart = start + 1

如果想移动到节点内部末尾,可以使用:

const insideEnd = end - 1

4. direction 有什么用

Selection.near() 的第二个参数表示优先搜索的方向:

Selection.near(resolvedPosition, direction)
  • 1:优先向后寻找。
  • -1:优先向前寻找。

因此:

  • 移到节点前时使用 -1
  • 移入节点或移到节点后时使用 1

5. 为什么按钮使用 onMouseDown

如果使用普通的 onClick,浏览器可能先把焦点交给按钮,导致编辑器失去焦点。

所以这里使用:

onMouseDown={event => {
event.preventDefault()
moveCursor('after')
}}

preventDefault() 会阻止按钮抢走焦点。

按钮还要设置:

contentEditable={false}

这表示按钮只是 NodeView 中的控件,不属于可编辑文本。

移到节点内部末尾

如果还需要移动到 NodeView 内部的最后,可以增加一个目标:

const insideEnd = start + node.nodeSize - 1

const selection = Selection.near(
editor.state.doc.resolve(insideEnd),
-1,
)

editor.view.dispatch(
editor.state.tr.setSelection(selection),
)
editor.view.focus()

这里使用 -1,表示从节点内部末尾开始,优先向前寻找合法的光标位置。

为什么有时无法移入 NodeView

并不是所有 NodeView 都能放入文本光标。

只有 NodeView 包含可编辑内容时,才能使用前面的方式移入。例如 React NodeView 中包含:

<NodeViewContent />

对应的 Tiptap 节点还需要声明可以包含哪些内容,例如:

content: 'block+'

如果节点没有 content,或者被配置成:

atom: true

它就是一个不可拆分的整体。此时不能把 ProseMirror 文本光标放到节点内部,只能选中整个节点。

选中不可编辑的 NodeView

对于卡片、图片等原子节点,可以使用 NodeSelection

import { NodeSelection } from '@tiptap/pm/state'

const position = getPos()
const selection = NodeSelection.create(
editor.state.doc,
position,
)

editor.view.dispatch(
editor.state.tr.setSelection(selection),
)
editor.view.focus()

这不是把文本光标放入节点,而是选中整个节点。

NodeView 中有输入框怎么办

如果 NodeView 内部是普通的 <input><textarea>,应该直接聚焦这个 DOM 元素:

const inputRef = useRef<HTMLInputElement>(null)

inputRef.current?.focus()

这是因为输入框自己的光标和 Tiptap 文档中的光标是两套不同的东西。

简单来说:

  • NodeViewContent 中的光标由 Tiptap 管理。
  • <input><textarea> 中的光标由浏览器管理。

节点后面没有段落怎么办

如果 NodeView 是文档中的最后一个节点,后面可能没有能够放置文本光标的段落。

这时需要先插入一个段落:

const end = getPos() + node.nodeSize

editor
.chain()
.insertContentAt(end, { type: 'paragraph' })
.focus(end + 1)
.run()

插入后,光标会进入新段落,可以继续输入。

实际使用时,应该先判断后面是否已经有文本块,避免重复插入空段落。

总结

记住下面 4 个位置就够了:

const start = getPos()
const end = start + node.nodeSize

const before = start
const insideStart = start + 1
const insideEnd = end - 1
const after = end

然后使用 Selection.near() 在目标附近寻找合法的光标位置。

另外需要注意:

  • 包含 NodeViewContent 的节点,光标可以移入。
  • 原子节点不能放入文本光标,只能用 NodeSelection 选中。
  • 普通输入框应直接调用 DOM 的 focus()
  • 节点后没有文本块时,需要先插入一个段落。