tiptap 自定义扩展
引言
Tiptap 的一个优势在于其可扩展性。你可以根据需要扩展编辑器,而不仅仅是依赖提供的扩展。
通过自定义扩展,你可以添加新的内容类型和功能,无论是对已有的扩展进行扩展,还是从头开始构建。接下来,我们将通过一些常见示例学习如何扩展现有的节点、标记和扩展。
在最后,我们会介绍如何从头开始创建新扩展,但在此之前,你需要了解扩展现有和创建新扩展所需的知识。
扩展现有扩展
每个扩展都有一个 extend()
方法,它接受一个对象,用于更改或添加扩展的各个方面。
假设你想改变项目符号列表的快捷键。首先,查看扩展的源代码,例如 项目符号列表节点。为了覆盖快捷键,你的代码可能如下所示:
// 1. 导入扩展
import BulletList from "@tiptap/extension-bullet-list";
// 2. 覆盖快捷键
const CustomBulletList = BulletList.extend({
addKeyboardShortcuts() {
return {
"Mod-l": () => this.editor.commands.toggleBulletList(),
};
},
});
// 3. 将自定义扩展添加到编辑器
new Editor({
extensions: [
CustomBulletList(),
// …
],
});
对于扩展的其他方面,如名称、优先级、设置、存储、schema、属性等,处理方式类似。下面是一些示例,说明如何通过 extend()
方法更改这些方面:
名称
扩展名称在许多地方使用,更名并不容易。如果你想更改现有扩展的名称,可以复制整个扩展并替换所有出现的地方。
扩展名称也包含在 JSON 中。如果你 将内容存储为 JSON,也需要在该处更改名称。
优先级
优先级定义了扩展注册的顺序。默认优先级为 100
,大多数扩展都采用这个值。优先级更高的扩展会先加载。
import Link from "@tiptap/extension-link";
const CustomLink = Link.extend({
priority: 1000,
});
扩展加载顺序影响两个方面:
- 插件顺序 优先级高的扩展对应的 ProseMirror 插件会先运行。
- schema 顺序
比如
Link
标记,默认优先级更高,这意味着它会被渲染为[<strong>Example</strong></a>
,而不是<strong><a href="…">Example](/docs/zh-Hans/tiptap-docs/master/guide/…)</strong>
。
设置
虽然可以通过扩展本身配置所有设置,但如果想更改默认设置(比如提供一个 Tiptap 上的库供其他开发者使用),可以这样做:
import Heading from "@tiptap/extension-heading";
const CustomHeading = Heading.extend({
addOptions() {
return {
...this.parent?.(), // 父扩展的设置
levels: [1, 2, 3],
};
},
});
存储
在某些时候,你可能希望在扩展实例中保存一些数据。这些数据是可变的。在扩展内部,你可以通过 this.storage
访问它。
import { Extension } from "@tiptap/core";
const CustomExtension = Extension.create({
name: "customExtension", // 建议为每个扩展分配唯一名称
addStorage() {
return {
awesomeness: 100,
};
},
onUpdate() {
this.storage.awesomeness += 1;
},
});
在外部,你可以通过 editor.storage
访问。确保每个扩展的名称都是唯一的。
const editor = new Editor({
extensions: [CustomExtension],
});
const awesomeness = editor.storage.customExtension.awesomeness;
schema
Tiptap 使用严格的 schema 来配置内容的结构、嵌套行为等。你可以 针对现有扩展更改 schema 的各个方面。让我们逐个了解一些常见用例。
默认的 Blockquote
扩展允许包裹其他节点,如标题。如果你想让 blockquote 只包含段落,可以这样设置 content
属性:
// blockquote 只能包含段落
import Blockquote from "@tiptap/extension-blockquote";
const CustomBlockquote = Blockquote.extend({
content: "paragraph*",
});
schema 还允许使节点拖拽,这就是 draggable
选项的作用。默认为 false
,但你可以覆盖它。
// 可拖拽的段落
import Paragraph from "@tiptap/extension-paragraph";
const CustomParagraph = Paragraph.extend({
draggable: true,
});
这只是两个小例子,底层的 ProseMirror schema 实际上非常强大。
属性
你可以使用属性在内容中存储额外信息。比如,如果你想扩展默认的 Paragraph
节点,使其有不同的颜色:
const CustomParagraph = Paragraph.extend({
addAttributes() {
// 返回属性配置的对象
return {
color: {
default: 'pink',
},
},
},
})
// 结果:
// <p color="pink">Example Text</p>
这已经足以告诉 Tiptap 关于新属性的信息,并设置 'pink'
为默认值。所有属性默认作为 HTML 属性渲染,并在初始化时从内容中解析。
继续颜色的例子,假设你想在实际文本上应用内联样式。使用 renderHTML
函数,你可以返回 HTML 属性,它们将在输出中渲染。
以下示例根据 color
的值添加内联样式 HTML 属性:
const CustomParagraph = Paragraph.extend({
addAttributes() {
return {
color: {
default: null,
// 获取属性值
renderHTML: (attributes) => ({
style: `color: ${attributes.color}`,
}),
// …并定制 HTML 解析。
parseHTML: (element) => element.style.color || "pink", // 或者根据需求解析初始内容
},
};
},
});
// 结果:
// <p style="color: pink">Example Text</p>
你也可以控制如何从 HTML 解析属性。例如,你可能想要将颜色存储在名为 data-color
(而非 color
)的属性中,那么做法如下:
const CustomParagraph = Paragraph.extend({
addAttributes() {
return {
color: {
default: null,
// 自定义 HTML 解析(例如,加载初始内容)
parseHTML: (element) => element.getAttribute("data-color"),
// …并定制 HTML 渲染。
renderHTML: (attributes) => ({
"data-color": attributes.color,
style: `color: ${attributes.color}`,
}),
},
};
},
});
// 结果:
// <p data-color="pink" style="color: pink">Example Text</p>
你可以完全禁用属性的渲染,通过 rendered: false
。
扩展现有属性
如果你想在一个扩展中添加属性,并保留现有属性,可以通过 this.parent()
访问它们。
在某些情况下,它可能是 undefined,因此确保检查这种情况,或者使用可选链 this.parent?.()
。
const CustomTableCell = TableCell.extend({
addAttributes() {
return {
...this.parent?.(), // 添加到父扩展的属性
myCustomAttribute: {
// …
},
};
},
});
全局属性
属性可以同时应用于多个扩展。这对于文本对齐、行高、颜色、字体家族等与样式相关的属性很有用。
深入了解一下 文本对齐扩展的完整源代码 以了解更复杂的例子。简而言之,实现方式如下:
import { Extension } from "@tiptap/core";
const TextAlign = Extension.create({
addGlobalAttributes() {
return [
{
// 扩展以下扩展
types: ["heading", "paragraph"],
// …并添加这些属性
attributes: {
textAlign: {
default: "left",
renderHTML: (attributes) => ({
style: `text-align: ${attributes.textAlign}`,
}),
parseHTML: (element) => element.style.textAlign || "left",
},
},
},
];
},
});
渲染 HTML
通过 renderHTML
函数,你可以控制扩展如何转换为 HTML。我们传入一个包含本地属性、全局属性和配置好的 CSS 类的对象。这里有一个来自 Bold
扩展的示例:
renderHTML({ HTMLAttributes }) {
return ['strong', HTMLAttributes, 0]
},
数组的第一个元素应为 HTML 标签的名称。如果第二个元素是对象,则表示一组属性。后面的元素作为子节点渲染。
在代码中,数字零(表示空位)用于指示内容插入的位置。让我们看看 CodeBlock
扩展中嵌套标签的渲染:
renderHTML({ HTMLAttributes }) {
return ['pre', ['code', HTMLAttributes, 0]]
},
如果你想在那里添加特定属性,可以导入 @tiptap/core
库中的 mergeAttributes
辅助函数:
import { mergeAttributes } from '@tiptap/core'
// ...
renderHTML({ HTMLAttributes }) {
return ['a', mergeAttributes(HTMLAttributes, { rel: this.options.rel }), 0]
},
解析 HTML
parseHTML()
函数尝试从 HTML 加载编辑器文档。这个函数接收一个 HTML DOM 元素作为参数,并期望返回一个包含属性及其值的对象。这是 Bold
标记的一个简化示例:
parseHTML() {
return [
{
tag: 'strong',
},
]
},
这定义了一个规则,将所有 <strong>
标签转换为 Bold
标记。你可以根据需要进行更复杂的设置,这里是扩展中的完整示例:
parseHTML() {
return [
// <strong>
{
tag: 'strong',
},
// <b>
{
tag: 'b',
getAttrs: node => node.style.fontWeight !== 'normal' && null,
},
// <span style="font-weight: bold">和<span style="font-weight: 700">
{
style: 'font-weight',
getAttrs: value => /^(bold(er)?|[5-9]\d{2,})$/.test(value as string) && null,
},
]
},
这检查 <strong>
、<b>
标签,以及任何具有内联样式设置 font-weight
为粗体的 HTML 标签。
请注意,你可以选择性地传递一个 getAttrs
回调,来进行更复杂的检查,例如特定 HTML 属性。回调会接收到 HTML DOM 节点,但在检查 style
属性时,它会接收到值。
你可能注意到的 && null
是什么?ProseMirror 期望当检查成功时返回null
或undefined
。
为规则指定priority
以解决与其他扩展的冲突,例如如果你正在构建一个自定义扩展来查找带有类属性的段落,但已经使用了默认段落扩展。
使用 getAttrs
示例中的 getAttrs
函数有两个用途:
- 检查 HTML 属性以决定规则是否匹配(并根据 HTML 创建标记或节点)。如果函数返回
false
,则不匹配。 - 获取 DOM 元素并使用 HTML 属性相应地设置你的标记或节点属性:
parseHTML() {
return [
{
tag: 'span',
getAttrs: element => {
// 检查元素是否有属性
element.hasAttribute('style')
// 获取内联样式
element.style.color
// 获取特定属性
element.getAttribute('data-color')
},
},
]
},
你可以返回一个对象,其中属性作为键,解析后的值用于设置标记或节点属性。我们建议你在 addAttributes()
中使用 parseHTML
,这样代码会更整洁。
addAttributes() {
return {
color: {
// 根据`data-color`属性的值设置颜色属性
parseHTML: element => element.getAttribute('data-color'),
}
}
},
有关 getAttrs
和其他 ParseRule
属性的更多信息,请参阅 ProseMirror 参考文档。
命令
import Paragraph from "@tiptap/extension-paragraph";
const CustomParagraph = Paragraph.extend({
addCommands() {
return {
paragraph:
() =>
({ commands }) => {
return commands.setNode("paragraph");
},
};
},
});
警告 在
addCommands
内部使用commands
参数
要在addCommands
中访问其他命令,请使用传递给它的commands
参数。
键盘快捷键
大多数核心扩展都提供了合理的键盘快捷键。不过,根据你想要构建的功能,你可能希望更改它们。通过 addKeyboardShortcuts()
方法,你可以覆盖预定义的快捷键映射:
// 更改项目符号列表的键盘快捷键
import BulletList from "@tiptap/extension-bullet-list";
const CustomBulletList = BulletList.extend({
addKeyboardShortcuts() {
return {
"Mod-l": () => this.editor.commands.toggleBulletList(),
};
},
});
输入规则
输入规则允许你定义正则表达式来监听用户输入。它们用于 Markdown 快捷方式,例如在 Typography
扩展中将文本如 (c)
转换为 ©
(等等)。使用 markInputRule
辅助函数处理标记,使用 nodeInputRule
处理节点。
默认情况下,两边有两个波浪线的文本会被转换为删除线文本。如果你想认为每边一个波浪线就足够了,你可以像下面这样重写输入规则:
// 使用单个波浪线的Markdown快捷方式
import Strike from "@tiptap/extension-strike";
import { markInputRule } from "@tiptap/core";
// 默认:
// const inputRegex = /(?:^|\s)((?:~~)((?:[^~]+))(?:~~))$/
// 新:
const inputRegex = /(?:^|\s)((?:~)((?:[^~]+))(?:~))$/;
const CustomStrike = Strike.extend({
addInputRules() {
return [
markInputRule({
find: inputRegex,
type: this.type,
}),
];
},
});
粘贴规则
粘贴规则的工作原理类似于输入规则(参见上文)。但它们不是监听用户输入,而是应用到粘贴的内容上。
正则表达式有一点小差别。输入规则通常以 $
美元符号结束(表示“在行尾断言”),而粘贴规则通常会遍历所有内容,不包含这个 $
美元符号。
以上面的示例为例,并将其应用于粘贴规则,如下所示。
// 检查粘贴内容是否包含单个波浪线的Markdown语法
import Strike from "@tiptap/extension-strike";
import { markPasteRule } from "@tiptap/core";
// 默认:
// const pasteRegex = /(?:^|\s)((?:~~)((?:[^~]+))(?:~~))/g
// 新:
const pasteRegex = /(?:^|\s)((?:~)((?:[^~]+))(?:~))/g;
const CustomStrike = Strike.extend({
addPasteRules() {
return [
markPasteRule({
find: pasteRegex,
type: this.type,
}),
];
},
});
事件
甚至可以将你的 事件监听器 移到单独的扩展中。以下是监听所有事件的示例:
import { Extension } from "@tiptap/core";
const CustomExtension = Extension.create({
onCreate() {
// 编辑器已准备就绪。
},
onUpdate() {
// 内容已更改。
},
onSelectionUpdate({ editor }) {
// 选择已更改。
},
onTransaction({ transaction }) {
// 编辑器状态已更改。
},
onFocus({ event }) {
// 编辑器获得焦点。
},
onBlur({ event }) {
// 编辑器不再聚焦。
},
onDestroy() {
// 编辑器正在销毁。
},
});
这里有什么?
这些扩展不是类,但你在扩展中的 this
上下文中仍有一些重要的东西可用。
// 扩展名称,例如'bulletList'
this.name;
// 编辑器实例
this.editor;
// ProseMirror类型
this.type;
// 所有设置的对象
this.options;
// 在扩展中扩展的所有内容
this.parent;
ProseMirror 插件(高级)
最后,Tiptap 是基于 ProseMirror 构建的,而 ProseMirror 也有一个非常强大的插件 API。要直接访问它,请使用 addProseMirrorPlugins()
。
已存在的插件
你可以像下面的例子一样将现有的 ProseMirror 插件包裹在 Tiptap 扩展中。
import { history } from "@tiptap/pm/history";
const History = Extension.create({
addProseMirrorPlugins() {
return [
history(),
// …
];
},
});
访问 ProseMirror API
要监听事件,例如点击、双击或粘贴内容,可以在 editorProps
的 event handler 中传递这些事件处理器。
或者,你可以像下面的例子那样,将它们添加到 Tiptap 扩展中。
import { Extension } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
export const EventHandler = Extension.create({
name: "eventHandler",
addProseMirrorPlugins() {
return [
new Plugin({
key: new PluginKey("eventHandler"),
props: {
handleClick(view, pos, event) {
/* … */
},
handleDoubleClick(view, pos, event) {
/* … */
},
handlePaste(view, event, slice) {
/* … */
},
// … 还有很多,非常多。完整的列表可以在这里找到:https://prosemirror.net/docs/ref/#view.EditorProps
},
}),
];
},
});
节点视图(高级)
对于需要在节点内部执行 JavaScript 的高级用例,例如为图像渲染复杂的界面,你需要了解节点视图。
它们非常强大,但也很复杂。简单来说,你需要返回一个父 DOM 元素,以及内容应该渲染的 DOM 元素。看下面这个简化示例:
import Image from "@tiptap/extension-image";
const CustomImage = Image.extend({
addNodeView() {
return () => {
const container = document.createElement("div");
container.addEventListener("click", (event) => {
alert("点击了容器");
});
const content = document.createElement("div");
container.append(content);
return {
dom: container,
contentDOM: content,
};
};
},
});
关于节点视图有更多内容需要学习,可以参考我们指南中的 专门介绍节点视图的部分获取更多信息。如果你正在寻找一个实际案例,可以看看 TaskItem
节点的源代码。它使用节点视图来渲染复选框。
创建新的扩展
你可以从头开始构建自己的扩展,你知道吗?它的语法与上面描述的扩展继承是相同的。
创建节点
如果你将文档视为一棵树,那么 节点就是这棵树中的一种内容类型。可以从学习诸如 Paragraph、Heading 或CodeBlock 这样的例子开始。
import { Node } from "@tiptap/core";
const CustomNode = Node.create({
name: "customNode",
// 你的代码将放在这里。
});
节点不必是块级元素,也可以作为文本的内联渲染,例如用于 @提及。
创建标记
一个或多个标记可以应用于 节点,例如添加内联格式。可以从学习 Bold、Italic 和 Highlight 这样的例子开始。
import { Mark } from "@tiptap/core";
const CustomMark = Mark.create({
name: "customMark",
// 你的代码将放在这里。
});
创建扩展
扩展为 Tiptap 添加新功能,你可能会经常看到“扩展”这个词,即使对于节点和标记也是如此。但也有真正的扩展。它们不能像节点和标记那样添加到模式(schema),但可以添加功能或改变编辑器的行为。
一个很好的学习例子可能是 TextAlign 。
import { Extension } from "@tiptap/core";
const CustomExtension = Extension.create({
name: "customExtension",
// 你的代码将放在这里。
});
创建并发布独立扩展
如果你想为 Tiptap 创建并发布自己的扩展,可以使用我们的 CLI 工具来初始化项目。
只需运行 npm init tiptap-extension
并按照指示操作。CLI 会为你创建一个新的文件夹,其中包含预配置的项目,包括使用 Rollup 的构建脚本。
如果你想在本地测试扩展,可以在项目文件夹中运行 npm link
,然后在你的项目(例如 Vite 应用)中运行 npm link YOUR_EXTENSION
。
分享
当一切都正常工作后,别忘了 与社区分享,或者在我们的 awesome-tiptap 仓库中分享。