Bootstrap

【element-tiptap】Tiptap编辑器核心概念----内容、扩展与词汇

前言:本篇文章继续来讲Tiptap编辑器的核心概念,主要是内容、扩展、词汇相关的概念

(一)内容

文档内容被存储在编辑器实例的 state 属性中。所有的修改都会以事务 transaction 的形式应用于 state。state 详细介绍了当前的内容、光标的位置和选区等内容。Tiptap 提供了很多可以挂载的事件,例如可以用于在应用事务之前改变事务。

可挂载的事件列表
事件名描述
beforeCreate编辑器视图创建之前
create编辑器初始化完成
update内容有修改
selectionUpdate编辑器的选区有修改
transaction创建和执行事务
focus监听编辑器聚焦
blur监听编辑器失焦
destroy监听编辑器实例销毁
onPaste监听粘贴事件
onDrop监听内容拖拽到编辑器中
contentError内容不符合 schema 制定的规则时触发
注册事件监听器

有三个方式注册事件监听器
① 通过配置项的方式
新创建的编辑器可以使用配置项的方式增加监听函数

const editor = new Editor({
  onBeforeCreate({ editor }) {
    // Before the view is created.
  },
  onCreate({ editor }) {
    // The editor is ready.
  },
})

② 通过绑定的方式
正在运行的编辑器可以通过 on() 方法监听

editor.on('beforeCreate', ({ editor }) => {
  // Before the view is created.
})

editor.on('create', ({ editor }) => {
  // The editor is ready.
})

editor.on('update', ({ editor }) => {
  // The content has changed.
})

如果后续要解绑的话,需要使用命名函数

const onUpdate = () => {
  // The content has changed.
}

// Bind …
editor.on('update', onUpdate)

// … and unbind.
editor.off('update', onUpdate)

③ 给扩展增加监听器

import { Extension } from '@tiptap/core'

const CustomExtension = Extension.create({
  onBeforeCreate({ editor }) {
    // Before the view is created.
  },
  onCreate({ editor }) {
    // The editor is ready.
  },
})

(二)扩展

扩展向编辑器中添加节点标记功能等。
扩展里面的内容有一丢丢多哇,等我后面专门写几篇文章介绍吧👻👻👻
这里先介绍一下创建扩展的方法

1、扩展现有的 extension

每一个 extension 都有一个 extends 方法,这个方法接收一个配置对象,可以向其中设置你想修改或者新增的功能。
下面的例子,重写了切换列表的快捷键

// 1. Import the extension
import BulletList from '@tiptap/extension-bullet-list'

// 2. Overwrite the keyboard shortcuts
const CustomBulletList = BulletList.extend({
  addKeyboardShortcuts() {
    return {
      'Mod-l': () => this.editor.commands.toggleBulletList(),
    }
  },
})

// 3. Add the custom extension to your editor
new Editor({
  extensions: [
    CustomBulletList(),
    // …
  ],
})

用这种方法,你可以修改现有的扩展的除了 name 以外的所有属性。下面我们来挨个看看扩展都有哪些属性

Name

扩展的名字是它的唯一标识符,一般不会修改它。在文档数据源JSON中也会存储扩展的名字。如果想修改它只能创建一个新的扩展。

Priority

这个属性定义扩展被注册的顺序。默认的 priority 是 100,大部分扩展都使用的是默认值。如果设置的大一些的话,可以早一些加载扩展。

import Link from '@tiptap/extension-link'

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

扩展的加载顺序影响两个事情:

  • 插件的顺序
    扩展的ProseMirror插件会优先运行
  • Schema 顺序
    在上面的例子中,提升了 Link 的顺序,那么渲染的时候,Link 标记就会先渲染,意味着一个链接之前可能是 <strong><a href="…">Example</a></strong>,但是提升优先级之后Link的层级也会提升,会变成 <strong><a href="…">Example</a></strong>
Settings

所有设置都可以通过扩展来配置,但是如果你想要修改默认设置,比如为其他开发者提供一个基于 Tiptap 的库,你可以这样做:

import Heading from '@tiptap/extension-heading'

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

Storage

在某些情况下你可能想在 extension 实例中存储一些可变数据,此时就可以使用 storage

import { Extension } from '@tiptap/core'

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

  addStorage() {
    return {
      awesomeness: 100,
    }
  },

  onUpdate() {
    this.storage.awesomeness += 1
  },
})

在扩展之外访问扩展中定义的 storage 的话,使用 editor.storage 访问,需要确保每一个扩展都有独一无二的 name。

const editor = new Editor({
  extensions: [CustomExtension],
})

const awesomeness = editor.storage.customExtension.awesomeness
Schema

Tiptap 需要定义非常严格的数据模式,来指定文档的结构和节点之间的嵌套方式。你可以通过下面的几个属性自定义extension 的数据模式。

  • content 指明该扩展可以允许的子节点的类型
  • draggable 该扩展是否可以拖拽
  • group 指定自身的类型是块级还是行内
  • inline 布尔值,指定是否行内显示
  • marks 标记
  • atom 设置为 true 表示不能有子节点,不能直接编辑内容
  • attrs 节点属性
  • selectable 节点能否选中
  • code⁠ 布尔值 表示该节点是否包含代码,如果包含代码的话某一些命令的表现可能会不一样
  • whitespace “pre” | “normal” 控制节点中空格的显示方式。默认值是 normal,会将空格折叠,并用空格代替换行符等;如果设置成 pre,不会折叠空格。如果设置成 true 的话,跟设置成 pre 是一样的效果。
  • definingAsContext⁠ 布尔值,在内容被替换的时候,比如粘贴操作,是否保留该节点作为新内容的父节点。
  • definingForContent⁠ 在插入内容时是否保留定义的父节点,一般用于特殊的块级元素,例如代码块、引用块等
  • defining⁠ 如果设置为true,上面两个属性都会设置为true
  • isolating 当启用时(默认为 false),这种类型节点的边界会被视为常规编辑操作(如退格或提升)不能跨越的边界。表格单元格就是一个可能需要启用此功能的节点示例
  • toDOM 方法 定义节点是如何渲染成DOM的。返回一个DOM节点或者一个描述节点结构的对象。
  • parseDOM⁠ TagParseRule [] 定义如何将 HTML 解析为编辑器的内部结构
// 1. 解析自定义格式
const CustomFormat = Mark.create({
  name: 'customFormat',
  parseDOM: [
    {
      // 样式匹配
      style: 'color',
      getAttrs: (value) => ({
        color: value
      })
    },
    {
      // 类名匹配
      tag: 'span.custom',
      getAttrs: (dom) => ({
        color: dom.style.color
      })
    }
  ]
})

// 2. 解析外部粘贴的内容
const ExternalContent = Node.create({
  name: 'external',
  parseDOM: [
    {
      tag: '[data-source="external"]',
      getAttrs: (dom) => ({
        source: dom.getAttribute('data-source'),
        id: dom.getAttribute('data-id')
      })
    }
  ]
})
  • toDebugString⁠ fn(node: Node) → string 定义该节点在调试的时候显示的信息
  • leafText⁠?: fn(node: Node) → string 定义将此类型的叶节点序列化为字符串的默认方式(如Node.textBetween和Node.textContent所使用的)
  • linebreakReplacement 布尔值 表示该节点是否能起到换行的作用,但不使用换行符
    实际应用:
// 1. 在富文本和纯文本间转换
const convertToPlainText = () => {
  // <br> 节点会被转换为 \n
  editor.commands.setBlockType('plain')
}

// 2. 在预格式化和普通文本间转换
const togglePreformatted = () => {
  // 自动处理换行符的转换
  editor.commands.toggleBlockType('preformatted')
}
Attributes

Attributes 可以用来存储内容的附加信息。例如下面,扩展段落增加不同的颜色,渲染段落的时候就会自动加上 color 属性

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

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

默认情况下,所有的属性都会在初始化节点的时候解析并且渲染成 HTML 属性。
不过要想给文字设置颜色,需要使用style属性,像下面的写法:

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

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

你也可以控制如何从HTML中转换成数据。例如下面的例子,如果你想将 color 的属性存储成 data-color,可以使用 psrseHTML 自定义转换规则

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>

可以使用 rendered: false 完全禁用属性的渲染
如果你想保持现有的属性,可以通过 this.parent() 继承

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

Global attributes

Attributes 还能一次性给多个扩展设置。例如文本对齐方式、行高、文字样式、或者其他的样式相关的属性就很适合一次性设置。例如 TextAlign 扩展

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的标签。如果第二个参数是一个对象,它就是属性的集合;如果是一个嵌套的数组,那么就是子元素。最后的数字表示元素要插入的位置。
下面是放子元素的示例:

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

如果还想在这里添加具体的属性,可以使用 mergeAttributes

import { mergeAttributes } from '@tiptap/core'

// ...

renderHTML({ HTMLAttributes }) {
  return ['a', mergeAttributes(HTMLAttributes, { rel: this.options.rel }), 0]
},

Parse HTML

parseHTML() 方法定义从 HTML 转换为编辑器文档的方式。这个方法可以获取 HTML DOM 元素,返回一个包含属性、标签等信息的对象。
下面是一个简单的 Bold 标记的例子:

parseHTML() {
  return [
    {
      tag: 'strong',
    },
  ]
},

我们定义了一个规则,将所有的 strong 标签转为 Bold 标记。下面是一个更复杂的转换规则,将所有的 strong 标签和 b 标签,以及行内设置 font-weight 为bold或者700的标签都识别成 Bold 标记。getAttrs 方法用来匹配更复杂的规则,如果检查成功需要返回 null,所以这个方法的最后是 && null

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,
    },
  ]
},

这个属性是用于向后端发送数据的时候,或者是在控制台打印 editor.editor.getHTML() 的时候,当前节点怎么展现。因为比如说像 latex 公式,在网页中它需要很复杂的结构来展示成 MathML,但是存储文档的话存一个 <latex>2^1=2</latex> 类似的latex公式就可以了。

Commands

给扩展增加命令,

import Paragraph from '@tiptap/extension-paragraph'

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

增加后就可以通过editor.commands.paragraph(); 访问

Keyboard 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

输入规则是用定义用正则表达式监听用户输入的规则,常用于匹配 markdown 输入法。
例如下面的例子,输入 ~文字~ 的时候,会转换成 “ 文字

// 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

粘贴规则类似于上面的输入规则,监听用户的粘贴的内容,如果有匹配上的字符串就进行转换。
不过在写正则表达式的时候有些不一样,输入规则通常要以指定的符号为开头和结尾,分别使用 ^$ 表示。但是粘贴只需要找字符串中成对出现的所有符号,不用考虑是否在一定要以某符号开头和结尾,例如 文本~~删除线1~~文本~~删除线2~~ 这种形式也可以转换成 “文本删除线1文本删除线2”,因此正则表达式会更灵活

// 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 = /(~~([^~]+)~~)/g;

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

Events

编辑器的生命周期函数以及监听器可以放在扩展中

import { Extension } from '@tiptap/core'

const CustomExtension = Extension.create({
  onCreate() {
    // The editor is ready.
  },
  onUpdate() {
    // The content has changed.
  },
  onSelectionUpdate({ editor }) {
    // The selection has changed.
  },
  onTransaction({ transaction }) {
    // The editor state has changed.
  },
  onFocus({ event }) {
    // The editor is focused.
  },
  onBlur({ event }) {
    // The editor isn’t focused anymore.
  },
  onDestroy() {
    // The editor is being destroyed.
  },
})
可以通过this访问的属性

在扩展中,有几个属性可以通过 this 来访问

// extension 的名字,例如 'bulletList'
this.name

// Editor 实例
this.editor

// ProseMirror 类型
this.type

// 配置项
this.options

// 被继承的 extension 的所有信息
this.parent
ProseMirror Plugins

Tiptap 是在 ProseMirror 的基础上开发的,ProseMirror提供了强大的插件 API。使用 addProseMirrorPlugins() 向扩展中添加插件。

  • 添加现成的插件
import { history } from '@tiptap/pm/history'

const History = Extension.create({
  addProseMirrorPlugins() {
    return [
      history(),
      // …
    ]
  },
})
  • 使用插件 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 来创建节点,例如给图片渲染一个外框框,此时就需要使用 addNodeView 方法。这个方法需要返回父节点和当前节点

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

(三)词汇

下面是 ProseMirror 中常见的词汇的描述

词汇描述
Schema配置内容可以具有的结构
Document编辑器中实际的内容
State描述编辑器文档内容和选区的所有的东西
Transactionstate的修改
Extension注册新功能
Node内容的类型,例如段落、标题
Mark可以应用于节点,例如用于内联格式设置
Command在编辑器中执行一个动作,以某种方式改变state
Decoration在文档顶部设置样式,例如突出显示错误
;