core-concepts
前言:这篇文章来介绍一下 Tiptap 编辑器的一些核心概念
(一)结构
1、 Schemas
定义文档组成方式。一个文档就是标题、段落以及其他的节点组成的一棵树。
每一个 ProseMirror 的文档都有一个与之相关联的 schema,它规定了可以出现在文档中的节点的类型,以及它们的嵌套方式。例如,它可能规定,顶层节点可以包含一个或多个块(block),段落节点可以包括任意数量的拥有各种标记的行内节点。ProseMirror 给出了基本的 schema
,并且允许自定义 schema
。
一个简单的 schema
示例:
// 底层 ProseMirror schema
{
nodes: {
doc: {
content: 'block+',
},
paragraph: {
content: 'inline*',
group: 'block',
parseDOM: [{ tag: 'p' }],
toDOM: () => ['p', 0],
},
text: {
group: 'inline',
},
},
}
在上面的 schema
的定义中,我们定义了三个节点
doc
,它是文档的根节点,它的内容由content
属性定义,可以是一个或多个块级元素,block+
是正则表达式的写法,小加号表示一个或多个。paragraph
节点,它自身是块级元素,由group
属性指定,由于定义的节点只有它是块级元素,所以根节点的子节点只允许出现paragraph
节点。它的内容inline*
指的是0个或多个行内节点,所以它只能包含text
节点。parseDOM
定义了如何从粘贴的 HTML 中解析节点。toDOM
定义了paragraph
节点如何在 DOM 中渲染。text
节点,这个节点就很简单了,行内,纯文本
在 Tiptap 中,节点、标记和扩展都在各自的文件中独立定义,便于拆分逻辑,最终引擎会将所有的定义合并在一起。
schema 是严格的,文档中不能出现任何 schema 不允许的节点。
例如,如果你想编辑器中粘贴了 This is <strong>important</strong>
,但是没有扩展可以解析 strong
标签,那么文档中会直接显示 This is important
可以通过监听器可以监听解析失败的事件:
contentError
在这个事件中还会接收到一个参数 disableCollaboration
,它是一个函数,调用这个函数可以重新初始化编辑器,并且不会将出错的内容同步给其他用户。不过协同编辑的功能是收费的,大概率还是需要自己的团队重新开发。
这个方法的调用有以下两种:
- 直接在创建编辑器的时候设置监听器
new Editor({
enableContentCheck: true,
content: invalidContent,
onContentError({ editor, error, disableCollaboration }) {
// your handler here
},
...options,
})
- 通过
on()
方法监听
const editor = new Editor({
enableContentCheck: true,
content: invalidContent,
...options,
})
editor.on('contentError', ({ editor, error, disableCollaboration }) => {
// your handler here
})
2、Marks
标记可以附加到每一个节点上,用来给节点的某些特殊的部分增加样式或者注释文本。
在 schema 中,必须指定可以允许的 marks 的类型,和上面的 schema 中指定 nodes 的写法类似。
默认情况下,带有行内内容的节点会允许所有的在 schema 中定义的 marks 应用于它们的子节点,但是可以在节点的 marks 属性中进行自定义。
例如下面这个简单的 schema,允许 strong 和 em 标记应用于 paragraphs 中的文本,但是不允许应用于 headings 中的文本:
const markSchema = new Schema({
nodes: {
doc: {content: "block+"},
paragraph: {group: "block", content: "text*", marks: "_"},
heading: {group: "block", content: "text*", marks: ""},
text: {inline: true}
},
marks: {
strong: {},
em: {}
}
})
标记集合会被解析成标记的所有名称以空格分隔的字符串的形式,_
会作为一个通配符,匹配所有的标记;而空字符串表示不允许任何的标记。
Tiptap 中提供了一系列 marks 扩展
3、commands
Commands
是以编程的方式改变编辑器内容。编辑器提供了很多很多的命令,以编程的方式添加、改变编辑器内容或者更改选区。
① 执行 command
通过编辑器实例上的 commands 属性,调用 command
editor.commands.setBold()
像酱紫执行命令就可以让文本加粗
② 链式执行 command
大多数的命令都可以合并到一次调用中,这会比单独调用函数更加高效。下面的例子是让选中的文本加粗
editor.chain().focus().toggleBold().run()
chain()
用来开启新的执行链,run()
方法用来实际执行所有的命令。这些命令很可能都是通过点击按钮触发的,但是按钮通常不在编辑器内部,所有可能会需要先执行 focus
聚焦于编辑器,然后再执行 toggleBold
方法加粗选中的文本。所以大部分命令在执行前都会先链式调用 focus()
,这样用户就可以继续进行编辑操作。
执行链上的方法是排着队执行的。一个执行链上的方法会被合并到一个 transaction 中,一个执行链只会触发一次更新监听器。
默认情况下,ProseMirror 是不支持链式操作的,我们需要通过 Transaction mapping 在命令执行链中更新位置。
下面是一个例子,链式执行删除和插入命令:
// 添加两个自定义命令演示两个 transaction 步骤之间的映射
addCommands() {
return {
delete: () => ({ tr }) => {
const { $from, $to } = tr.selection
// 使用 tr.mapping.map 在 transaction 步骤之间映射位置
const from = tr.mapping.map($from.pos)
const to = tr.mapping.map($to.pos)
tr.delete(from, to)
return true
},
insert: (content: string) => ({ tr }) => {
const { $from } = tr.selection
// 使用 tr.mapping.map 在 transaction 步骤之间映射位置
const pos = tr.mapping.map($from.pos)
tr.insertText(content, pos)
return true
},
}
}
现在就可以执行下面的操作,确保插入内容时位置不会错误
editor.chain().delete().insert('foo').run()
③ 自定义命令中的链
当链接到一个命令的时候,事务会处于保留状态。如果你想链式调用自定义命令,你需要使用当前的事务,并且将你的自定义命令添加到当前的调用链上,如以下代码:
addCommands() {
return {
customCommand: attributes => ({ chain }) => {
// Doesn’t work:
// return editor.chain() …
// Does work:
return chain()
.insertContent('foo!')
.insertContent('bar!')
.run()
},
}
}
④ 行内命令
如果命令中执行的代码比较简单,可以直接写成行内命令的形式:
editor
.chain()
.focus()
.command(({ tr }) => {
// manipulate the transaction
tr.insertText('hey, that’s cool!')
return true
})
.run()
⑤ 空运行命令
在执行某些命令之前,可以使用 can()
方法,来判断这个命令能不能执行,例如在菜单中的按钮能不能显示等。这个方法不会执行任何修改而只是会判断后面跟的命令是否可以执行。
editor.can().toggleBold()
can()
方法也可以和 chain()
一起使用,来判断是否执行链上所有的方法都可以执行
editor.can().chain().toggleBold().toggleItalic().run()
如果链式操作中的所有命令都能执行,can() 方法才会返回 true。如果其中有自定义命令,切记要返回布尔值。
就是说上面一连串的方法,最后返回的是个布尔值,不会有任何的修改。
⑥ 尝试命令
如果有一连串的命令,运行一个命令成功后,就不再往后执行,就可以使用 first
命令。好像有 if~else~
的作用,或者是替代 can()
判断的作用。
例如下面的例子,backspace 键会首先去尝试撤销一个输入规则;如果成功的话就执行这个操作,如果失败的话就执行下一个命令,删除选区内容
editor.first(({ commands }) => [
() => commands.undoInputRule(),
() => commands.deleteSelection(),
// …
])
下面的写法作用相同:
export default () =>
({ commands }) => {
return commands.first([
() => commands.undoInputRule(),
() => commands.deleteSelection(),
// …
])
}
就是说如果当前焦点是一个列表,点击删除键,会列表输入规则删除,变成普通的文本输入
⑦ 关键命令列表
内容
命令 | 描述 |
---|---|
clearContent() | 删除整个文档 |
insertContent() | 在当前位置插入一个节点或者HTML字符串 |
insertContentAt() | 在指定位置插入一个节点或者HTML字符串 |
setContent() | 用新内容替代整篇文档的内容 |
节点&标记
命令 | 描述 |
---|---|
clearNodes() | 将节点变成简单的段落 |
createParagraphNear() | 在当前位置的附近创建一个段落 |
deleteNode() | 删除节点 |
extendMarkRange() | 将文本选择范围扩展到当前标记。 |
exitCode() | 停止代码编辑 |
joinBackward() | 和后一个节点合并 |
joinForward() | 和前一个节点合并 |
lift() | 提升当前的选区到上一个层级,例如将二层的列表项变成一层的列表项 |
liftEmptyBlock() | 提升空块的层,例如空的列表项点回车时会将当前列表项提升为单独的一行;空引用点击回车会退出引用 |
newlineInCode() | 在代码中添加换行符 |
resetAttributes() | 将一些节点或标记的属性重置为默认值 |
setMark() | 给标记添加一个新属性 |
setNode() | 将一个指定范围的内容替换为新节点 |
splitBlock() | 在光标处分割当前元素,派生一个新节点 |
toggleMark() | 切换标记 |
toggleWrap() | 切换指定的包裹标签,例如 toggleWrap(‘bulletList’) 切换当前元素是否放在列表中 |
undoInputRule() | 撤销输入规则,即变成普通文本 |
unsetAllMarks() | 删除当前选区的所有标记 |
unsetMark() | 删除当前选区的指定的标记 |
updateAttributes() | 更新节点或标记的属性 |
列表
命令 | 描述 |
---|---|
liftListItem() | 提升列表项等级 |
sinkListItem() | 降低列表项等级 |
splitListItem() | 将一个列表项拆分为两个列表项。 |
toggleList() | 切换列表类型;切换普通文本和列表 |
wrapInList() | 将一个节点包装在一个列表中 |
选区
命令 | 描述 |
---|---|
blur() | 从编辑器中移除焦点 |
deleteRange() | 删除指定 range |
deleteSelection() | 删除 selection |
enter() | 触发回车行为 例如分割p标签、创建新的一行等 |
focus() | 聚焦编辑器到指定的位置 |
keyboardShortcut() | 触发指定的键盘快捷键 |
scrollIntoView() | 滚动视图到选区位置 |
selectAll() | 选中整个文档 |
selectNodeBackward() | 向后选中一个节点 |
selectNodeForward() | 向前选中一个节点 |
selectParentNode() | 选中父节点 |
setNodeSelection() | 创建一个 NodeSelection |
setTextSelection() | 创建一个 TextSelection |