项目

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,
});

扩展加载顺序影响两个方面:

  1. 插件顺序 优先级高的扩展对应的 ProseMirror 插件会先运行。
  2. 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 期望当检查成功时返回nullundefined

为规则指定priority 以解决与其他扩展的冲突,例如如果你正在构建一个自定义扩展来查找带有类属性的段落,但已经使用了默认段落扩展。

使用 getAttrs

示例中的 getAttrs 函数有两个用途:

  1. 检查 HTML 属性以决定规则是否匹配(并根据 HTML 创建标记或节点)。如果函数返回 false,则不匹配。
  2. 获取 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

要监听事件,例如点击、双击或粘贴内容,可以在 editorPropsevent 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 节点的源代码。它使用节点视图来渲染复选框。

创建新的扩展

你可以从头开始构建自己的扩展,你知道吗?它的语法与上面描述的扩展继承是相同的。

创建节点

如果你将文档视为一棵树,那么 节点就是这棵树中的一种内容类型。可以从学习诸如 ParagraphHeadingCodeBlock 这样的例子开始。

import { Node } from "@tiptap/core";

const CustomNode = Node.create({
  name: "customNode",

  // 你的代码将放在这里。
});

节点不必是块级元素,也可以作为文本的内联渲染,例如用于 @提及

创建标记

一个或多个标记可以应用于 节点,例如添加内联格式。可以从学习 BoldItalicHighlight 这样的例子开始。

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 仓库中分享。

在本文档中