Tiptap 是一个基于 ProseMirror 构建的富文本编辑器,它是一个灵活、可扩展的富文本编辑器,同时适用于 Vue.js 和 React。所以,无论你的技术栈是Vue,还是React,使用Tiptap都不用太过于在选型上纠结。Tiptap 的核心思路是通过插件系统提供丰富的功能,使得开发者可以根据需求定制编辑器的功能和样式。
Tiptap 的主要有5大部分组成:
- Core:Tiptap 的核心模块,负责处理编辑器的基本功能,如文本输入、选择、撤销和重做等。
- Extensions:扩展模块,提供丰富的编辑功能,如加粗、斜体、列表、链接等。开发者可以根据需求选择需要的功能,并通过插件系统轻松地添加到编辑器中,下面我们会展开说说如何自定义一个插件,例如如何将AI能力加持到编辑器上来。
- Commands:命令模块,用于执行编辑操作,如插入、删除、修改等。开发者可以通过命令 API 对编辑器进行操作,实现自定义的功能。
- Schema:定义编辑器的文档结构,包括节点、标记和规则。通过自定义 Schema,可以实现特定的文档结构和约束。
- Vue/React components:Tiptap 提供了 Vue 和 React 的组件,使得编辑器可以轻松地集成到这两个框架中。
首先,为了了解这些模块之间的关联关系,我们可以看看下面这系统架构幅图。
Tiptap 作为主要的入口,连接了 Core、Extensions、Commands、Schema 和 Vue/React components。Extensions 又包括了多个功能模块,如 Bold、Italic、List 和 Link。这样的架构使得 Tiptap 可以根据需求灵活地扩展功能和样式。
Tiptap 的 Core模块原理简介
Tiptap 的 Core 模块是基于 ProseMirror 构建的,它负责处理编辑器的基本功能,如文本输入、选择、撤销和重做等。ProseMirror 是一个用于构建富文本编辑器的 JavaScript 库,提供了强大的文档模型和编辑功能,我们在上篇文章中有简单的介绍过,Tiptap实际上就是扩展了ProseMirror的 Nodes,Marks等等,所有的包装相关的源码,我们可以参考 https://github.com/ueberdosis/tiptap/tree/develop/packages/pm。正如这个库的readme文件所说,
那么,整个Core 实际上层对 ProseMirror 的更加方便的使用的封装。说是封装其实就是间接导出,并没有做什么实质性的导出,所以,完完全全可以理解为 TipTap的底层就是 ProseMirror,那么为何不直接依赖 ProseMirror呢?这里极有可能是为了后续更好做扩展或者解耦。
整个Tiptap的架构图,我们可以参考如下
- Document Model:ProseMirror 提供了一个灵活的文档模型,用于表示富文本编辑器中的内容。文档模型由节点(Node)和标记(Mark)组成,节点表示文档的结构元素,如段落、标题和列表等;标记表示文本的样式,如加粗、斜体和链接等。Tiptap 的 Core 模块使用 ProseMirror 的文档模型来表示和操作编辑器中的内容。
- Transactions:ProseMirror 中的所有编辑操作都是通过事务(Transaction)来完成的。事务是一系列对文档模型的修改操作,如插入、删除和修改等。Tiptap 的 Core 模块使用 ProseMirror 的事务系统来处理编辑操作,确保文档模型的一致性和可撤销性。
- View:ProseMirror 提供了一个视图系统,用于将文档模型渲染到 DOM 中,并处理用户的输入和交互。Tiptap 的 Core 模块使用 ProseMirror 的视图系统来实现编辑器的显示和交互功能。
- Plugins:ProseMirror 支持插件系统,允许开发者为编辑器添加自定义的功能和行为。Tiptap 的 Core 模块使用 ProseMirror 的插件系统来实现扩展功能,如撤销和重做、拖放和粘贴等。
基本上,可以理解为 是 ProseMirror的那套把戏。
我们如何在TipTap 上去实现一个扩展(Extension),以及扩展的实现原理
在 Tiptap 中,插件的各种能力(如快捷键、命令等)是通过扩展(Extension)的 API 实现的。当你将扩展添加到编辑器时,编辑器会自动加载和应用这些 API。以下是一些主要的 API 和它们的原理:
- 快捷键:在扩展中定义
inputRules
或keymap
属性,可以添加快捷键。inputRules
是一种基于输入模式的快捷键,例如在输入*
和空格时自动创建一个列表。keymap
是一种基于按键组合的快捷键,例如按Ctrl B
时切换加粗样式。当用户输入或按下快捷键时,编辑器会自动调用相应的命令。 - 命令:在扩展中定义
commands
方法,可以添加命令。命令是一个函数,接受一个参数params
,并返回一个处理函数。处理函数接受两个参数:state
和dispatch
。state
是当前的编辑器状态,dispatch
是一个用于分发事务的函数。你可以在处理函数中执行一些操作,如修改文档模型、更新视图和触发事件等。 - 菜单项:在扩展中定义
menuItems
属性,可以添加菜单项。菜单项是一个对象,包含一些属性,如command
、icon
和title
等。当用户点击菜单项时,编辑器会自动调用相应的命令。 - 插件:在扩展中定义
plugins
属性,可以添加 ProseMirror 插件。ProseMirror 插件是一个对象,通常包含一个或多个处理函数,如handleDOMEvents
、appendTransaction
和filterTransaction
等。这些处理函数用于处理编辑器的事件和事务。
以下是用户操作时,扩展Extension于编辑器Editor的交互序列图,当然隐藏了诸多细节,但是不妨碍我们理解一个扩展在整个编辑过程中扮演的角色。
一个简单的扩展的实现
在Tiptap上实现一个扩展最简单的办法莫过于基于他的模板了:npm init tiptap-extension@latest
为了极大的降低理解难度,我选择直接使用加粗扩展来了解下一个扩展的主要逻辑。
代码语言:typescript复制import {
Mark,
markInputRule,
markPasteRule,
mergeAttributes,
} from '@tiptap/core'
export interface BoldOptions {
HTMLAttributes: Record<string, any>,
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
bold: {
/**
* Set a bold mark
*/
setBold: () => ReturnType,
/**
* Toggle a bold mark
*/
toggleBold: () => ReturnType,
/**
* Unset a bold mark
*/
unsetBold: () => ReturnType,
}
}
}
export const starInputRegex = /(?:^|s)((?:**)((?:[^*] ))(?:**))$/
export const starPasteRegex = /(?:^|s)((?:**)((?:[^*] ))(?:**))/g
export const underscoreInputRegex = /(?:^|s)((?:__)((?:[^__] ))(?:__))$/
export const underscorePasteRegex = /(?:^|s)((?:__)((?:[^__] ))(?:__))/g
export const Bold = Mark.create<BoldOptions>({
name: 'bold',
addOptions() {
return {
HTMLAttributes: {},
}
},
parseHTML() {
return [
{
tag: 'strong',
},
{
tag: 'b',
getAttrs: node => (node as HTMLElement).style.fontWeight !== 'normal' && null,
},
{
style: 'font-weight',
getAttrs: value => /^(bold(er)?|[5-9]d{2,})$/.test(value as string) && null,
},
]
},
renderHTML({ HTMLAttributes }) {
return ['strong', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]
},
addCommands() {
return {
setBold: () => ({ commands }) => {
return commands.setMark(this.name)
},
toggleBold: () => ({ commands }) => {
return commands.toggleMark(this.name)
},
unsetBold: () => ({ commands }) => {
return commands.unsetMark(this.name)
},
}
},
addKeyboardShortcuts() {
return {
'Mod-b': () => this.editor.commands.toggleBold(),
'Mod-B': () => this.editor.commands.toggleBold(),
}
},
addInputRules() {
return [
markInputRule({
find: starInputRegex,
type: this.type,
}),
markInputRule({
find: underscoreInputRegex,
type: this.type,
}),
]
},
addPasteRules() {
return [
markPasteRule({
find: starPasteRegex,
type: this.type,
}),
markPasteRule({
find: underscorePasteRegex,
type: this.type,
}),
]
},
})
他的代码不多,也就100行左右,也就实现了加粗的功能,我们可以看到,这里面,定义了一些正则,还定义了一些命令,以及快捷键。
可以看到主要的逻辑是,当触发快捷键,时,会给选择的文本增加 ** **,再次触发,会去掉选中。实际上渲染的样式是会表现为 html结构插入到dom中,而renderHtml 就是干这个事情的,但是,加粗的方式不一,所以,多种形式都可以被解析为是 文本加粗。
那么,如何实现一个类似于NotionAI的方式的扩展插件呢?
想必,大家都基本上体验过Notion那种Ai赋能的写作之爽了吧,总之开始用的时候是惊艳到我了,那么,像NotionAI那种输入 / ,就呼出菜单的扩展,该如何实现呢?实际上,这种就就需要用到addProseMirrorPlugins的方式。
代码语言:javascript复制const SlashExtensions = Extension.create({
name: 'slash-command',
addOptions() {
return {
suggestion
};
},
addProseMirrorPlugins() {
return [
Suggestion({
editor: this.editor,
...this.options.suggestion
})
];
}
});
然后具体的逻辑无非就是通过editor读取上下文,然后调用 ChatGPT 接口,拿到响应,在通过editor的接口写到到文档即可。当然我有实现一个成品的NotionAi 的平替,在utools上搜索 aition即可体验。
我正在参与2023腾讯技术创作特训营第三期有奖征文,组队打卡瓜分大奖!