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

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

(一)内容

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

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

有三个方式注册事件监听器

① 通过配置项的方式

新创建的编辑器可以使用配置项的方式增加监听函数

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

② 通过绑定的方式

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

js 复制代码
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.
})

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

javascript 复制代码
const onUpdate = () => {
  // The content has changed.
}

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

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

③ 给扩展增加监听器

javascript 复制代码
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 方法,这个方法接收一个配置对象,可以向其中设置你想修改或者新增的功能。

下面的例子,重写了切换列表的快捷键

javascript 复制代码
// 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,大部分扩展都使用的是默认值。如果设置的大一些的话,可以早一些加载扩展。

javascript 复制代码
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 的库,你可以这样做:

javascript 复制代码
import Heading from '@tiptap/extension-heading'

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

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

javascript 复制代码
import { Extension } from '@tiptap/core'

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

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

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

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

javascript 复制代码
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 解析为编辑器的内部结构
javascript 复制代码
// 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 布尔值 表示该节点是否能起到换行的作用,但不使用换行符
    实际应用:
javascript 复制代码
// 1. 在富文本和纯文本间转换
const convertToPlainText = () => {
  // <br> 节点会被转换为 \n
  editor.commands.setBlockType('plain')
}

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

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

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

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

默认情况下,所有的属性都会在初始化节点的时候解析并且渲染成 HTML 属性。

不过要想给文字设置颜色,需要使用style属性,像下面的写法:

ts 复制代码
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 自定义转换规则

ts 复制代码
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() 继承

javascript 复制代码
const CustomTableCell = TableCell.extend({
  addAttributes() {
    return {
      ...this.parent?.(),
      myCustomAttribute: {
        // ...
      },
    }
  },
})
Global attributes

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

javascript 复制代码
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 扩展:

javascript 复制代码
renderHTML({ HTMLAttributes }) {
  return ['strong', HTMLAttributes, 0]
},

返回的数组中的第一个元素是HTML的标签。如果第二个参数是一个对象,它就是属性的集合;如果是一个嵌套的数组,那么就是子元素。最后的数字表示元素要插入的位置。

下面是放子元素的示例:

javascript 复制代码
renderHTML({ HTMLAttributes }) {
  return ['pre', ['code', HTMLAttributes, 0]]
},

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

javascript 复制代码
import { mergeAttributes } from '@tiptap/core'

// ...

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

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

下面是一个简单的 Bold 标记的例子:

javascript 复制代码
parseHTML() {
  return [
    {
      tag: 'strong',
    },
  ]
},

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

javascript 复制代码
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

给扩展增加命令,

javascript 复制代码
import Paragraph from '@tiptap/extension-paragraph'

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

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

Keyboard shortcuts

大多数核心的扩展都带有默认的快捷键,你可以使用 addKeyboardShortcuts() 方法对其进行修改

javascript 复制代码
// 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 输入法。

例如下面的例子,输入 ~文字~ 的时候,会转换成 " 文字 "

javascript 复制代码
// 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",因此正则表达式会更灵活

javascript 复制代码
// 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

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

javascript 复制代码
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 来访问

javascript 复制代码
// extension 的名字,例如 'bulletList'
this.name

// Editor 实例
this.editor

// ProseMirror 类型
this.type

// 配置项
this.options

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

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

  • 添加现成的插件
javascript 复制代码
import { history } from '@tiptap/pm/history'

const History = Extension.create({
  addProseMirrorPlugins() {
    return [
      history(),
      // ...
    ]
  },
})
  • 使用插件 API 创建新的插件,例如下面代码创建一个事件处理的插件
javascript 复制代码
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 方法。这个方法需要返回父节点和当前节点

javascript 复制代码
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 描述编辑器文档内容和选区的所有的东西
Transaction state的修改
Extension 注册新功能
Node 内容的类型,例如段落、标题
Mark 可以应用于节点,例如用于内联格式设置
Command 在编辑器中执行一个动作,以某种方式改变state
Decoration 在文档顶部设置样式,例如突出显示错误
相关推荐
code_shenbing1 小时前
跨平台WPF框架Avalonia教程 三
前端·microsoft·ui·c#·wpf·跨平台·界面设计
白臻1 小时前
使用element-plus el-table中使用el-image层级冲突table表格会覆盖预览的图片等问题
前端·vue.js·elementui
北极糊的狐1 小时前
vue使用List.forEach遍历集合元素
前端·javascript·vue.js
晓看天色*1 小时前
[JAVA]MyBatis框架—获取SqlSession对象
java·开发语言·前端
ZVAyIVqt0UFji2 小时前
Reactflow图形库结合Dagre算法实现函数资源关系图
开发语言·前端·javascript·ecmascript
luckilyil2 小时前
前端—Cursor编辑器
前端·编辑器
cooldream20092 小时前
快速上手 Vue 3 的高效组件库Element Plus
前端·javascript·vue.js·element plus
我是苏苏3 小时前
Web开发:ORM框架之使用Freesql的DbFrist封装常见功能
java·前端·jvm
疯狂的沙粒3 小时前
Vue项目开发 vue实例挂载的过程?
前端·javascript·vue.js
吃葡萄不吐葡萄皮嘻嘻3 小时前
el-table实现最后一行合计功能并合并指定单元格
前端·vue.js·elementui