tiptap 自定义扩展

我们在使用web编辑器时往往需要extensions自定义扩展,因为如何一款web编辑器自带的扩展都不可能满足场景的需求,tiptap可以这种个性化的需求有两种方法,一是按照官方方法实现扩展,一种是自己写vue组件调用tiptap命令,这样也可以非常方便的集成到tiptap。

扩展已有的扩展

tiptap提供了非常多的扩展,当这些扩展无法满足你的需求时,你可以对它进行再次扩展,每个扩展都有一个extend()方法,该方法接受一个object,其中object包含您想要更改或添加的所有内容。下面代码演示了扩展BulletList列扩展,实现按下Ctrl+L快捷键把普通文本转成列

// 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(),
    // …
  ],
})

name 扩展名

扩展名在很多地方被使用,使用不太容易改变名称,如果要更改现有扩展的名称,可以复制整个扩展并在所有情况下更改名称。扩展名也是JSON的一部分,如果你将编辑器内容存储为JSON,则也需要更改它的名称。

priority 优先级

priority 定义了注册扩展的顺序,扩展的默认优先级是100,这是大多数扩展的优先级,值越大越先注册

import Link from '@tiptap/extension-link'

const CustomLink = Link.extend({
  priority: 1000,
})

扩展的注册顺序会影响,Plugin orderSchema order
 Link扩展拥有更高的优先级,将把<strong><a href="…">Example</a></strong>  呈现为<a href="…"><strong>Example</strong></a>

settings 配置

对已有扩展进行再次封装时,你可以重新进行重写配置项,实现代码如下

import Heading from '@tiptap/extension-heading'

const CustomHeading = Heading.extend({
  addOptions() {
    return {
      ...this.parent?.(),
      levels: [1, 2, 3],
    }
  },
})

storage 存储

有些情况下你可能希望在扩展中存储一下数据,你可以通过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扩展可以包裹其他节点,比如标题,如果你想只允许段落,相应地设置content属性:

// Blockquotes must only include paragraphs
import Blockquote from '@tiptap/extension-blockquote'

const CustomBlockquote = Blockquote.extend({
  content: 'paragraph*',
})

Schema 允许你的节点是可拖拽的,配置draggable选项的即可,默认为false,但你可以重写它。

// Draggable paragraphs
import Paragraph from '@tiptap/extension-paragraph'

const CustomParagraph = Paragraph.extend({
  draggable: true,
})

attributes 属性

你可以使用attributes属性在内容中存储其他信息,比如你想扩展Paragraph节点,让它有不同的颜色:

const CustomParagraph = Paragraph.extend({
  addAttributes() {
    // Return an object with attribute configuration
    return {
      color: {
        default: 'pink',
      },
    },
  },
})

// 输出结果:
// <p color="pink">Example Text</p>

我们也可以使用renderHTML实现内联样式属性。

const CustomParagraph = Paragraph.extend({
  addAttributes() {
    return {
      color: {
        default: null,
        // Take the attribute values
        renderHTML: attributes => {
          // … and return an object with HTML attributes.
          return {
            style: `color: ${attributes.color}`,
          }
        },
      },
    }
  },
})

// 输出结果:
// <p style="color: pink">Example Text</p>

您还可以控制从HTML中解析属性的方式,你可以将颜色存储在一个名为data-color的属性中(而不仅仅是color)。

const CustomParagraph = Paragraph.extend({
  addAttributes() {
    return {
      color: {
        default: null,
        // Customize the HTML parsing (for example, to load the initial content)
        parseHTML: element => element.getAttribute('data-color'),
        // … and customize the HTML rendering.
        renderHTML: attributes => {
          return {
            'data-color': attributes.color,
            style: `color: ${attributes.color}`,
          }
        },
      },
    }
  },
})

// Result:
// <p data-color="pink" style="color: pink">Example Text</p>

你可以使用render: false完全禁用属性的呈现,如果你想添加一个属性到一个扩展,并保留现有的属性,你可以通过this.parent()访问它们。

const CustomTableCell = TableCell.extend({
  addAttributes() {
    return {
      ...this.parent?.(),
      myCustomAttribute: {
        // …
      },
    }
  },
})

global attributes

属性可以同时应用到多个扩展。这对于文本对齐、行高、颜色、字体系列和其他样式相关属性非常有用。

import { Extension } from '@tiptap/core'

const TextAlign = Extension.create({
  addGlobalAttributes() {
    return [
      {
        // Extend the following extensions
        types: [
          'heading',
          'paragraph',
        ],
        // … with those attributes
        attributes: {
          textAlign: {
            default: 'left',
            renderHTML: attributes => ({
              style: `text-align: ${attributes.textAlign}`,
            }),
            parseHTML: element => element.style.textAlign || 'left',
          },
        },
      },
    ]
  },
})

render HTML

使用renderHTML函数你可以将扩展渲染为HTML。我们将一个属性对象传递给它,其中包含所有本地属性、全局属性和配置的CSS类。下面是一个来自Bold扩展的例子:

renderHTML({ HTMLAttributes }) {
  return ['strong', HTMLAttributes, 0]
}

数组中的第一个值我HTML标签的名称。如果第2个元素是一个对象,它将被解释为一组属性,在此之后的任何元素都呈现为子元素。

数字0用来指示内容应该插入的位置,让我们看看带有两个嵌套标记的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]
}

parse HTML

使用parseHTML()函数尝试从编辑器加载html文档,下面是 Bold mark 的简化示例:

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"> and <span style="font-weight: 700">
    {
      style: 'font-weight',
      getAttrs: value => /^(bold(er)?|[5-9]\d{2,})$/.test(value as string) && null,
    },
  ]
}

这将检查<strong><b>标记。如你所见,你可以选择性地传递一个getAttrs回调,以添加更复杂的检查。

在这个例子中,你可能已经注意到getAttrs函数有两个目的:

  • 检查HTML属性以确定规则是否匹配

  • 获取DOM Element并使用HTML属性来设置标记或节点属性

parseHTML() {
  return [
    {
      tag: 'span',
      getAttrs: element => {
        // Check if the element has an attribute
        element.hasAttribute('style')
        // Get an inline style
        element.style.color
        // Get a specific attribute
        element.getAttribute('data-color')
      },
    },
  ]
}

你可以返回一个对象,其中属性作为键,解析值用于设置marknode属性。不过,我们建议在addAttributes()中使用parseHTML,更详细的信息请访问 https://prosemirror.net/docs/ref/#model.ParseRule

commands 命令

在扩展中使用命令或添加一个Commands命令:

import Paragraph from '@tiptap/extension-paragraph'

const CustomParagraph = Paragraph.extend({
  addCommands() {
    return {
      paragraph: () => ({ commands }) => {
        return commands.setNode('paragraph')
      },
    }
  },
})

shortcuts 快捷键

大多数核心的扩展都带有快捷键,你可以使用addKeyboardShortcuts()方法,覆盖预定义的快捷键:

// Change the bullet list keyboard shortcut
import BulletList from '@tiptap/extension-bullet-list'

const CustomBulletList = BulletList.extend({
  addKeyboardShortcuts() {
    return {
      'Mod-l': () => this.editor.commands.toggleBulletList(),
    }
  }
})

input rules 输入规则

使用输入规则Input rules,你可以定义正则表达式来侦听用户输入,比如将文本(c)转换为©字符。mark标记使用markInputRule函数,node节点使用nodeInputRule函数。

// Use the ~single tilde~ markdown shortcut
import Strike from '@tiptap/extension-strike'
import { markInputRule } from '@tiptap/core'

// Default:
// const inputRegex = /(?:^|\s)((?:~~)((?:[^~]+))(?:~~))$/

// New:
const inputRegex = /(?:^|\s)((?:~)((?:[^~]+))(?:~))$/

const CustomStrike = Strike.extend({
  addInputRules() {
    return [
      markInputRule({
        find: inputRegex,
        type: this.type,
      }),
    ]
  }
})

paste rules 粘贴规则

粘贴规则的工作原理与输入规则类似(见上文),应用于粘贴的内容。 正则表达式中有一个微小的区别。输入规则通常以$ 符号结束(这意味着“断言在一行末尾的位置”),粘贴规则通常会查看所有内容,并且没有$ 符号,如下面的示例所示。

// Check pasted content for the ~single tilde~ markdown syntax
import Strike from '@tiptap/extension-strike'
import { markPasteRule } from '@tiptap/core'

// Default:
// const pasteRegex = /(?:^|\s)((?:~~)((?:[^~]+))(?:~~))/g

// New:
const pasteRegex = /(?:^|\s)((?:~)((?:[^~]+))(?:~))/g

const CustomStrike = Strike.extend({
  addPasteRules() {
    return [
      markPasteRule({
        find: pasteRegex,
        type: this.type,
      }),
    ]
  }
})

events 事件

你可以将事件监听器移动到单独的扩展中,下面代码是一个所有事件监听器的例子:

import { Extension } from '@tiptap/core'

const CustomExtension = Extension.create({
  onCreate() {
    // 编辑器初始化完成
  },
  onUpdate() {
    // 文档内容更新事件
  },
  onSelectionUpdate({ editor }) {
    // 选中内容改变
  },
  onTransaction({ transaction }) {
    // 编辑器状态更改事件 The editor state has changed.
  },
  onFocus({ event }) {
    // 编辑器获得焦点事件
  },
  onBlur({ event }) {
    // 编辑器失去焦点事件
  },
  onDestroy() {
    // 编辑器销毁事件
  },
})

this 对象

下面代码是 this 对象的属性列表。

// 扩展名称 'bulletList'
this.name

// 编辑器实例
this.editor

// ProseMirror type
this.type

// 所有的设置
this.options

// Everything that’s in the extended extension
this.parent

ProseMirror 插件

因为tiptap建立在ProseMirror之上所以我们可以用addProseMirrorPlugins去封装ProseMirror插件。

Existing plugins 封装已存在的ProseMirror插件

import { history } from '@tiptap/pm/history'

const History = Extension.create({
  addProseMirrorPlugins() {
    return [
      history(),
      // …
    ]
  },
})

访问ProseMirror API

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) { /* … */ },
          // … and many, many more.
          // Here is the full list: https://prosemirror.net/docs/ref/#view.EditorProps
        },
      }),
    ]
  },
})

Node views 节点视图

对于高级用例,你可能需要在节点内执行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('clicked on the container')
      })

      const content = document.createElement('div')
      container.append(content)


      return {
        dom: container,
        contentDOM: content,
      }
    }
  },
})

创建一个新扩展

当现有扩展无法满足你的需求时,你可以创建自己的扩展,它与上面描述的扩展现有的扩展的语法相同。

import Image from '@tiptap/extension-image'

const CustomImage = Image.extend({
  addNodeView() {
    return () => {
      const container = document.createElement('div')

      container.addEventListener('click', event => {
        alert('clicked on the container')
      })

      const content = document.createElement('div')
      container.append(content)


      return {
        dom: container,
        contentDOM: content,
      }
    }
  },
})

Create a node 创建节点

如果将文档看作树,那么node节点只是树中的一种内容类型。可以学习 ParagraphHeadingCodeBlock扩展

import { Node } from '@tiptap/core'

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

  // Your code goes here.
})

Create a mark 创建标记

你可以对Node节点添加一个或多个标记Mark,例如添加内联样式Style,你可以学习例子 Bold、Italic 或 Highlight。

import { Mark } from '@tiptap/core'

const CustomMark = Mark.create({
  name: 'customMark',

  // Your code goes here.
})

Create an extension 创建新扩展

你可以通过TextAlign扩展学习如何创建一个新扩展。

import { Extension } from '@tiptap/core'

const CustomExtension = Extension.create({
  name: 'customExtension',

  // Your code goes here.
})

下载教程 Demo

本教程Demo的源码点击这里下载,Tiptap Demo,下载完成后运行npm i初始化依赖包。