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()。 - 节点后没有文本块时,需要先插入一个段落。