前言:本篇文章继续来讲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 | 在文档顶部设置样式,例如突出显示错误 |