探索Prosemirror的插件系统:解锁编辑器的无限可能性

1. Prosemirror 的中枢:插件系统

在学习 Prosemirror 时,除了它的 MVC 模式,还有它的插件系统也是不可忽视的。在 Prosemirror 的四个核心基础包中,并没有为我们实现多么丰富的功能,而是重点实现了一套 MVC 的模式。然而,Prosemirror 之所以能在富文本编辑器领域占据一席之地,除了这套成熟的 MVC 模块设计,还有一套成熟的插件系统,以及丰富的插件生态。

Prosemirror 官方文档明确表示,它的初衷并不是提供一套开箱即用的解决方案,而是一套类似乐高积木的系统。我们需要手动拼装才能正常使用,这也是为什么我们之前花了四篇文章来讲解 Prosemirror 中的各种概念的原因,都是在熟悉它的不同模块。它具有很高的抽象程度,快速精通是不太可能的,所以你感觉学起来费劲就对了。在这套乐高系统中,插件系统扮演了举足轻重的角色。作为连接各种扩展功能的中枢,它通过合理地调度,将一个原本只有基础输入能力的输入框变为功能强大的编辑器。我们可以根据需要按需组合定制自己的产品,大小任意,完全自主把握。

当然,Prosemirror 也希望有人能够基于这套乐高系统提供开箱即用的解决方案。目前已经有一些产品如 Tiptap、Remirror 在 Prosemirror 的基础上构建了更简单易用的编辑器解决方案,也有很多公司内部创造了自己的基于 Prosemirror 的开箱即用方案。但无论使用哪个上层框架,最终你会发现,还是要回到 Prosemirror 这个原点,就像你学习任何前端框架,最终还是要回归到 JavaScript。

本文将从命令 Command 开始逐步认识和使用 Prosemirror 中的插件,并跟随线索深入了解之前讲到的 ResolvedPos。对于一些未使用过的 API,在后续的高级篇中,将会伴随着一些实战案例的出现。如果你对此感兴趣,可以关注本系列。

2. 探究 Command 的底层实现

2.1 Command 是什么

在 Prosemirror 中,有一个概念叫做 Command(命令),它表示在按下某些按键后执行的一些特殊操作。然而,在 Prosemirror 的核心包中,并没有一个核心机制来实现命令,实际上它只是一个类型定义:

ts 复制代码
/// Commands are functions that take a state and a an optional
/// transaction dispatch function and...
///
///  - determine whether they apply to this state
///  - if not, return false
///  - if `dispatch` was passed, perform their effect, possibly by
///    passing a transaction to `dispatch`
///  - return true
///
/// In some cases, the editor view is passed as a third argument.
export type Command = (state: EditorState, dispatch?: (tr: Transaction) => void, view?: EditorView) => boolean

​ 这就是核心包中 Command 的全部内容。Command 实际上就是一些函数,它接受一个 EditorState 参数,即编辑器的状态数据,并且可以选择性地接受一个 dispatch 函数,用于派发一个 transaction 以更新编辑器的状态。通过这两个参数,通常可以满足大多数的操作需求。但如果这些参数不能满足需求,Prosemirror 也提供了一个可选的第三个参数 view,它表示编辑器的视图。最后,Command 函数返回一个布尔值,如果返回 true,则表示触发了当前的命令操作,后续的命令将不会触发;如果返回 false,则表示当前命令没有被触发,如果还有后续命令,则会继续执行。

​ 上面的返回值可能让人感到奇怪,什么是后续的命令?在编辑器中,例如按下回车键,通常会插入一个新的段落,但如果在有序或无序列表中呢?根据一些良好的用户体验,回车键应该直接插入下一个列表项,在空的列表项按下回车键,则将当前项转为普通段落。如果这三个场景对应三条不同的命令:insertParagraphinsertListItemtransformListItemToParagraph,并且将这三个命令都注册为回车键的处理函数,如果满足插入段落的条件,则第一个命令会返回 true,后续的两个命令就不会执行;如果不满足条件,则第一个命令会返回 false,后续的命令将会继续执行,根据对应的场景进行操作。

​ 我们在之前的示例中也使用过 Command:

ts 复制代码
import { keymap } from 'prosemirror-keymap'
import { baseKeymap } from 'prosemirror-commands'
import { history, undo, redo } from 'prosemirror-history'

  // 根据 schema 定义,创建 editorState 数据实例
  const editorState = EditorState.create({
    schema,
    plugins: [
      // baseKeymap 是一些例如回车之类的基础按键,在 prosemirror-commands 包中
      keymap(baseKeymap),
      // 接入 history 插件,提供输入历史栈功能
      history(),
      // 将组合按键 ctrl/cmd + z, ctrl/cmd + y 分别绑定到 undo, redo 命令上
      keymap({"Mod-z": undo, "Mod-y": redo}),
    ]
  })

​ 通过 Command,我们可以使用快捷键来操作编辑器,例如加粗、斜体等功能,通常也会将它们绑定到相应的快捷键上,并实现对应的命令。

2.2 Command 的实现原理

​ 为什么上面的 Command 只是一个 TypeScript 的类型定义,而在 keymap 中却可以运行命令呢?实际上,在 Prosemirror 中,将 Command 定义为一种类型是为了给大家提供一个命令的标准或协议,即一种规范,但并没有实际实现它。Command 的实现是在 prosemirror-keymap 包中完成的,而 keymap 则是一个插件。使用起来非常简单,通过 {key: command} 的键值对将快捷键与命令绑定,按下对应的按键时,就会执行相应的 Command。

​ 实际上,这个实现原理非常简单,就是拦截按键操作。让我们来看一下源码:

ts 复制代码
// bindings 就是我们传入的 按键 -> 命令 的映射对象
export function keymap(bindings: {[key: string]: Command}): Plugin {
  // 这里创建了一个插件,给 props 中的 handleKeydown 绑定了一个 keydownHandler 的操作,即拦截键盘按下事件!!!对,没错,就是这么简单
  return new Plugin({props: { handleKeyDown: keydownHandler(bindings) }})
}
// 回想我们在开发普通项目时监听 addEventListener('keydown', (event) => {}), 这里的 handleKeyDown 对应的就是我们见到的普通 dom 的 keydown 事件,只不过是 prosemirror 拦截了键盘按下的事件,这与 React 进行事件代理是一个道理。
// handleKeyDown 的类型就是: (view: EditorView, event: KeyboardEvent) => boolean
// 可以看到 keydownHandler 返回的类型就是上面 handleKeyDown 需要的类型
function keydownHandler(bindings: {[key: string]: Command}): (view: EditorView, event: KeyboardEvent) => boolean

​ 高端的操作,往往就是这么朴实无华,忙碌了半小时,王师傅发现,实际上它就是普通的 DOM 事件拦截。然而,还有一件事情需要注意,恰好 handleKeyDown 的返回值是布尔类型,上面的 Command 返回值也是布尔类型,他们的行为就可以对应上了。实际上,Prosemirror 不仅仅拦截了 keydown 事件,对于 DOM 触发的大多数事件也进行了拦截,例如 handleKeyPresshandleTextInputhandleClickOnhandleClick 等等,甚至还有一个 handleDOMEvents,可以在里面直接拦截各种 dom 事件。它们的共同点都是返回布尔值,如果返回 true,则表示执行了你的操作,阻止了编辑器默认操作,例如 keydown 事件,返回 true 相当于执行完你的代码后,后续编辑器默认的 keydown 事件将不会被执行,因此在浏览器中无法输入任何内容。当然,你也可以返回 false,在执行你的操作的同时,也会执行编辑器后续的默认操作。

EditorProps 是 Plugin 对应 props 的类型,在文档中可以查看所有可绑定的事件。实际上,这个类型是在创建 EditorView 时第二个参数的类型 DirectEditorProps 也是这个类型,只是额外增加了 state、plugins 和 dispatchTransaction 等属性,因此在创建 EditorView 时也可以添加这些参数,但通常还是通过插件的方式增加。这是因为插件系统的关键在就于这些参数,如果是在创建 EditorView 时将所有功能通过第二个参数传入,那就无法很好地根据功能提炼代码,入口也会变得很冗长。而如果插件也能定义这些功能,就能很好地抽象不同的功能,并将其拆分为独立的包进行实现,最后通过创建 EditorView 时,以 plugins 将它们聚合在一起。这也是这个乐高系统实现的根本原因之一。

​ 到此,我们已经知道了 Command 是在 Prosemirror 代理 DOM 的 keydown 事件时抽象出来的概念,用于拦截用户按键并执行一些操作。由于 keydownHandler 等事件的第一个参数是 EditorView,我们可以轻松地从中提取 state 和 dispatch,并将它们传递给 Command,为了以防万一,Prosemirror 还将 view 作为第三个参数直接传递给 Command。在之前处理 mark 的示例中,为了偷懒,我们将整个 editorView 传递给了 setMark、toggleMark 等函数,然后根据需要从中提取所需属性。现在其实就是更加精细化地处理,将我们常用的 state 与 dispatch 作为前两个参数传进来了,通常我们需要的 schema、selection、tr 都可以从 state 中获取,dispatch 则是用来提交 tr 用来触发编辑器状态变更的,在 80% 的场景下,前两个参数已经够我们使用了,但如果需要使用 view.focus() 等 API,那就没办法了,必须借助第三个参数 view 来操作。

2.3 实战案例:修复回车按键

​ 由于我们定义的 schema 结构,在 paragraph 外层统一增加了一个 block_tile,block_tile 中只支持单个 paragraph,这就导致 enter 的默认换行失效了,我们需要换行时候,直接增加一个 block_tile > paragraph 嵌套结构。由于我们还没有仔细探索 transform 模块,因此直接很完善地修改 Enter 的行为还存在很大的困难,因此,我们就以 toolbar 中插入 添加段落 的实现方案,将其改造为命令。

ts 复制代码
export const insertParagraphCommand: Command = (state, dispatch) => {
  const { tr, schema } = state;
  const { block_tile, paragraph } = schema.nodes;

  const newLine = block_tile.create({}, paragraph.create())

  if (dispatch) {
    // 使用我们的新 Node 替换选区,如果是使用之前用过的 insert 或 replaceWith,插入之后,光标不在新行中,
    // 还需要我们修改选区,移动光标位置,直接使用下面的 api 插入后,光标就在新行中。
    tr.replaceSelectionWith(newLine)
    
    // 插入后如果不在可视区,滚动到可视区
    tr.scrollIntoView();
    dispatch(tr)
    return true;
  }
  return false;
}

const editorState = EditorState.create({
  schema,
  plugins: [
    keymap({
      ...baseKeymap,
      // 将 Enter 键,映射到插入行的命令上 insertParagraphCommand
      Enter: insertParagraphCommand
    }),
    // 接入 history 插件,提供输入历史栈功能
    history(),
    // 将组合按键 ctrl/cmd + z, ctrl/cmd + y 分别绑定到 undo, redo 功能上
    keymap({"Mod-z": undo, "Mod-y": redo}),
  ]
})

2.4 实战案例:为我们之前的 mark 操作绑定快捷键

​ 除此之外,还可以将我们之前案例中的加粗映射到 ctrl + Bcmd + B 上,在 keymap 包中,Mod 会分平台处理,在 mac 下被替换为 cmd ,在其他平台被替换为 ctrl,所以我们可以将加粗的命令绑定到 Mod-b

ts 复制代码
export const toggleBoldCmd:Command = (state, dispatch, view) => {
  if (view) {
    return toggleMark(view, 'bold')
  }
  return false;
}

const editorState = EditorState.create({
  schema,
  plugins: [
    keymap({
      ...baseKeymap,
      Enter: insertParagraphCommand
    }),
    // 接入 history 插件,提供输入历史栈功能
    history(),
    // 将组合按键 ctrl/cmd + z, ctrl/cmd + y 分别绑定到 undo, redo 功能上
    keymap({
      "Mod-z": undo,
      "Mod-y": redo,
      "Mod-b": toggleBoldCmd
    }),
  ]
})

​ 可以发现,之前添加的换行以及加粗快捷键都生效了。当然这里使用的 toggleMark 还是沿用的我们之前的封装的函数,之前这些函数的参数都是接收一个 view,其实可以参考 Command 改为接收 state + dispatch。

3. 通过 Pugin 增加视图

​ 在通过 Command 的讲解之后,大家对 Plugin 插件可能就有了一些基本认知,其实插件中大多数 api 都是绑定事件,拦截事件的,所以差不多就算是掌握一半了。除此之外,插件还能支持修改 Prosemirror 的视图,不过要分为两种情况,一种就是类似我们之前的 Toolbar,它的 dom 跟编辑器本身无关,还有一种就是 dom 中的一些装饰,比如选中一些文本,给他们增加一个 tooltip 弹窗可以快捷设置样式的 dom 结构,关于后一种,我们会在探索到 Decoration 的时候详细了解。这里我们就关注第一种情况,并将我们之前的 toolbar 转为用插件实现。

​ 除了上面介绍的 props 属性,plugin 还支持传入 **view**⁠ 属性,它是个函数,会在编辑器每次更新后被调用,并且会将 editorView 传递进来,以下是它的类型定义:

ts 复制代码
view?: (view: EditorView) => PluginView;

declare type PluginView = {
    /**
    Called whenever the view's state is updated.
    */
    update?: (view: EditorView, prevState: EditorState) => void;
    /**
    Called when the view is destroyed or receives a state
    with different plugins.
    */
    destroy?: () => void;
};

view 会方法会被传入 EditorView,并且需要返回一个 PluginView, PluginView 中包含一个 update 与 destroy,方法,update 会在每次 tr 之后都会调用它,还有个事 destroy,在 view 被销毁之后,或者收到一个 state,里面带了不同于之前 state 中的插件,都会触发它的销毁。

上篇构建强大编辑器:深入剖析 Prosemirror Mark 及选区与光标系统的奥秘探索 Mark 时我们重构了 toolbar,我们再回顾下代码:

ts 复制代码
export class Toolbar {
  constructor(private view: EditorView, private spec: ToolbarSpec) {
    // 定义一个 toolbar dom
    const toolbarDom = crel('div', { spec: this.spec.class })
    toolbarDom.classList.add('toolbar');

    // 将 dom 保存在 Toolbar 实例属性中
    this.dom = toolbarDom;

    // 批量创建 menuGroup
    this.groups = this.spec.groups.map(groupSpec => new MenuGroup(this.view, groupSpec))
    
    // 把 menuGroup 分别加入到 toolbar 中
    this.groups.forEach(group => {
      this.dom.appendChild(group.dom)
    })

    this.render();
  }

  // 这个 render 比较特殊,我们可以通过 view.dom 获取到 Prosemirror 编辑器挂载的 dom
  // 之后获取到它的父节点,将 toolbar 塞到 编辑器节点的前面去:这里先将 view.dom 替换成 toolbar 再把 view.dom append 上去
  // 你也可以直接用 insertBefore 之类的 api 
  render() {
    if (this.view.dom.parentNode) {
      const parentNode = this.view.dom.parentNode;
      const editorViewDom = parentNode.replaceChild(this.dom, this.view.dom);
      parentNode.appendChild(editorViewDom)
    }
  }

  groups: MenuGroup[]

  dom: HTMLElement;
  // 定义 update,主要用来批量更新 MenuGroup 中的 update
  update(view: EditorView, state: EditorState) {
    this.view = view;
    this.groups.forEach(group => {
      group.update(this.view, state);
    })
  }
}

​ 里面也定义了一个 update 方法,也接收的是 view 与 state,当时就说有个巧合,就是此处的巧合,其实都是水到渠成的事,view 就是 Prosemirror 中最顶层的对象了,有了它我们想获取到编辑器的什么信息都可以,传入 state 只是为了方便使用,不需要每次从都通过 view.state。我们之前是在 dispatchTransaction 中拦截了派发 tr 的操作,从而手动调用了 update,但在 plugin 的 view 中,是会自动调用 PluginView 的 update 的,我们只需要补充一下 destroy 即可,当然如果不需要也可以不加。

ts 复制代码
export class Toolbar implements PluginView {
  //...
  update(view: EditorView, state: EditorState) {
    this.view = view;
    // 如果 update 时,当前 toolbar 不在页面上了,就重新生成并挂载,因为有可能传入的 state 中 plugins 有变化,此时会调用 destroy,将 toolbar 移除,这里得加回来
    if (!this.dom.parentNode) {
      this.render()
    }
    this.groups.forEach(group => {
      group.update(this.view, state);
    })
  }
	// 销毁时,将 toolbar 移除
  destroy() {
    this.dom.remove()
  }
}

const editorState = EditorState.create({
  schema,
  plugins: [
    // ...
    // 通过插件使用 Toolbar
    new Plugin({
      key: new PluginKey('toolbar'),
      view: (view) => new Toolbar(view, {
        groups: [
          //...
        ]
      })
    })
  ]
})

此时刷新页面,toolbar 还是正常能够使用。

4. Plugin 中的 State 与其他属性

4.1 Plugin 中的 State

除了上面讲到的 props、view,plugin 的参数中还能传入 state,这个 state 区别于 editorView 的 state,仅仅保存 plugin 自己的数据。假如我们要统计文本被修改了多少次,可以创建一个插件,用 plugin 中的 state 来保存修改的次数,再用 view 展示,规则为,只有文档内容修改时,即t r.docChanged 为 true (如移动光标等情况产生的 tr docChanged 就是 false ),除此之外当我们正在输入拼音时,处于合成事件中,也不计数,editorView.composing 可以检测是否处于合成事件中。

ts 复制代码
import { EditorState, Plugin, PluginKey, PluginView } from 'prosemirror-state';
import { EditorView } from 'prosemirror-view';

const DOC_CHANGED_TIMES_KEY = new PluginKey('doc-changed-times')

export const docChangedTimesPlugin = (options?: {
  onlyContentChanged?: boolean,
}) => {
  const { onlyContentChanged = true } = options || {};
  return new Plugin({
    key: DOC_CHANGED_TIMES_KEY,
    state: {
      init() {
        return {
          times: 0,
        }
      },
      apply(tr, value, oldState, newState) {
        const ediorView: EditorView | undefined = newState.schema.cached.view;
        // tr.docChanged 表示文档内容有修改,如果只是修改 node 的 attrs 则它为 false
        // ediorView.composing 代表当前正在输入中文拼音,此时的输入不计数
        // 上面两种情况不增加修改次数
        if ((onlyContentChanged && !tr.docChanged) || (ediorView && ediorView.composing)) {
          return value
        }

        const times = value.times + 1;

        return {
          ...value,
          times
        }
      }
    },
    view: (view) => new DocChangedView(view)
  })
}

/**
 * 类似 Toolbar 的实现,添加一个 dom,用来展示当前文档修改了几次
 */
class DocChangedView implements PluginView{
  constructor(private view: EditorView) {
    const dom = document.createElement('div');
    dom.classList.add('editor-footer');
    this.dom = dom;

    const span = document.createElement('span');
    this.recordChangedTimesDom = span;
    this.updateTimesView(this.view.state)

    dom.appendChild(this.recordChangedTimesDom);
    this.render(view);
    
    // 将 view 添加到 schema 的 cached 中,方便后续在 apply 中使用,因为 apply 中无法获取 editorView
    // 这里巩固之前讲 schema 时候的知识
    if (!view.state.schema.cached.view) {
      view.state.schema.cached.view = view;
    }
  }

  private dom: HTMLElement;
  private recordChangedTimesDom: HTMLElement;

  private render(view:EditorView) {
     // 获取编辑器根元素的父元素,将 dom 添加在编辑器后面
    const viewParent = view.dom.parentNode;
    viewParent?.appendChild(this.dom);
  }

  // 通过 PluginKey.getState 可以获取到插件的 state,此时更新修改次数
  private updateTimesView(state: EditorState) {
    this.recordChangedTimesDom.innerText = `文档被修改了 ${DOC_CHANGED_TIMES_KEY.getState(state).times} 次`
  }

  update(view: EditorView, state: EditorState) {
    this.updateTimesView(view.state);
  }

  destroy() {
    this.dom.remove();
  }
}

你可以发现,输入拼音时,下面统计不会变化,输入拼音结束后才会增加统计,修改 datetime 中的时间也不会发生变化,因为没有触发 tr,后续讲到 NodeView 时会讲更高级的定制视图的方法,到时候就有办法在修改时间时,触发 tr 了。

可能有人会问,为什么不在 NodeView 中记录修改的次数,而是在 state 中?这不是多此一举?其实你可以发现,在 update 中通过view.state.tr.docChanged 获取到的永远是 false,这是因为调用 update 时,tr 已经被 dispatch 触发了,还记得我们之前手动通过dispatchTransaction 拦截 dispatch 吗?我们会先根据 tr 创建新的 state,然后更新 state 之后,再调用 toolbar 的 update,在 pluginView 中的 update 调用时机也是这样,更新完 state 后才会触发更新,此时是获取不到 tr 中的状态的。

4.2 Plugin 中的其他属性

除了上面讲到的 props,state,view,还有两个操作 transaction 的方法,filterTransaction 可以用来过滤 tr 是否可以被 dispatch,如果返回 false 则不行,appendTransaction 可以在提交一个 tr 后,根据需求决定是否再追加一个 tr,具体使用时可以查看文档,知道 plugin 有这个能力即可。

5. 小结

本文从 Prosemirror 中的 Command 开始,探索了 Prosemirror 中的插件的 props,主要是用来拦截 dom 事件,然后通过 Plugin 的 view 改造了之前的 toolbar,最后详细了解了 Plugin 中的私有 state,并通过实际案例统计文档修改次数插件巩固了 Plugin State 与 PluginView。当然,这并不是 Plugin 的全部,Plugin 还支持添加 nodeViews markViews 以及 Decorations,这些都是可以配合修改编辑器视图的,后面我们会单独拿出来详细研究。

​ See you next time!

相关推荐
编程零零七2 小时前
Python数据分析工具(三):pymssql的用法
开发语言·前端·数据库·python·oracle·数据分析·pymssql
北岛寒沫3 小时前
JavaScript(JS)学习笔记 1(简单介绍 注释和输入输出语句 变量 数据类型 运算符 流程控制 数组)
javascript·笔记·学习
everyStudy3 小时前
JavaScript如何判断输入的是空格
开发语言·javascript·ecmascript
(⊙o⊙)~哦4 小时前
JavaScript substring() 方法
前端
无心使然云中漫步4 小时前
GIS OGC之WMTS地图服务,通过Capabilities XML描述文档,获取matrixIds,origin,计算resolutions
前端·javascript
Bug缔造者5 小时前
Element-ui el-table 全局表格排序
前端·javascript·vue.js
xnian_5 小时前
解决ruoyi-vue-pro-master框架引入报错,启动报错问题
前端·javascript·vue.js
麒麟而非淇淋6 小时前
AJAX 入门 day1
前端·javascript·ajax
2401_858120536 小时前
深入理解MATLAB中的事件处理机制
前端·javascript·matlab
阿树梢6 小时前
【Vue】VueRouter路由
前端·javascript·vue.js