likes
comments
collection
share

轻松入门Prosemirror: 快速掌握其核心概念

作者站长头像
站长
· 阅读数 32

1. 计划:从第一个 prosemirror 案例开始认识它

说实话,在开始书写本文时,想了很久,不知道如何开这个头,才能将 prosemirror 逐步给讲解清楚,现有的官方文档实在是太生涩了,虽然有人翻译了中文版,但想要根据文档直接搞清楚 prosemirror,还是有很大挑战。并且社区资料也不多,大多数还是照搬文档,并没有太多有营养的内容。由于 prosemirror 的涉及到的内容太多,还是决定直接从一个最简单的 demo 入手,从它的使用开始逐步深入了解 prosemirror。

关于 prosemirror 的文章预计将会是一个系列的文章,保存在 富文本编辑器专栏,有需要的同学可以关注一下。

2. 计划实施:你的第一个 prosemirror 编辑器

在开始之前,可以简单介绍一下 prosemirror 的几个核心模块,prosemirror 将一个编辑器 Editor 的实现拆分为了 4 个核心模块,分别是:

  • prosemirror-model
  • prosemirror-state
  • prosemirror-view
  • prosemirror-transform

它们有点像传统开发的 MVC 模式,model 模块主要用来定义数据,state 是数据层,是 model 中定义数据模型实例化后的数据,view 即编辑器视图,transform 类似 controller 控制器,处理数据后,提交更新 view 视图层。可能比喻不太恰当,但可以帮助快速理解四个模块的用途,这 4 个模块是 prosemirror 中必要的的模块。一般不会显示使用 prosemirror-transform 这个模块,而是会使用在 state 内部的 tr,在 prosemirror-state 模块有个有个 Transaction,继承了 Transform,通常我们操作文档都会使用 state.tr 获取到 Transaction 实例对文档进行操作后,将 tr 提交触发视图更新。

以下对比 MVC 模式快速帮助大家理解 prosemirror 核心模块之间的关系:

轻松入门Prosemirror: 快速掌握其核心概念

2.1 项目初始化

创建项目并安装核心模块:根据上述描述,暂不安装 prosemirror-transform

# 创建一个 vite 项目,选择 vanilla (Typescript) 即可, 之后删除模板中的 counter demo
npm create vite@lates 
# 安装 四个核心模块
npm i prosemirror-model prosemirror-state prosemirror-view

2.2 重中之重: Schema 的定义

在开发 prosemirror 项目中,第一件最重要的事情是先定义自己的数据模型,即 Schema,它代表了你的编辑器内容的结构,其中你可以规定编辑器中能够存在哪些节点,以及不同节点之间的关系。例如一个定义段落节点,它渲染成 html 时候使用 p 标签,从别处复制过来带 p 标签的,给他序列化为段落节点,段落节点中可以包含 0 个或多个 文本节点。

// model.ts 文件命名暂时还是以 mvc 模式命名,方便理解,实际中 命名为 schema.ts 更好
import { Schema } from 'prosemirror-model';

export const schema = new Schema({
  nodes: {
    // 整个文档
    doc: {
      // 文档内容规定必须是 block 类型的节点(block 与 HTML 中的 block 概念差不多) `+` 号代表可以有一个或多个(规则类似正则)
      content: 'block+'
    },
    // 文档段落
    paragraph: {
      // 段落内容规定必须是 inline 类型的节点(inline 与 HTML 中 inline 概念差不多), `*` 号代表可以有 0 个或多个(规则类似正则)
      content: 'inline*',
      // 分组:当前节点所在的分组为 block,意味着它是个 block 节点
      group: 'block',
      // 渲染为 html 时候,使用 p 标签渲染,第二个参数 0 念做 “洞”,类似 vue 中 slot 插槽的概念,
      // 证明它有子节点,以后子节点就填充在 p 标签中
      toDOM: () => {
        return ['p', 0]
      },
      // 从别处复制过来的富文本,如果包含 p 标签,将 p 标签序列化为当前的 p 节点后进行展示
      parseDOM: [{
        tag: 'p'
      }]
    },
    // 段落中的文本
    text: {
      // 当前处于 inline 分株,意味着它是个 inline 节点。代表输入的文本
      group: 'inline'
    },
    // 1-6 级标题
    heading: {
      // attrs 与 vue/react 组件中 props 的概念类似,代表定义当前节点有哪些属性,这里定义了 level 属性,默认值 1
      attrs: {
        level: {
          default: 1
        }
      },
      // 当前节点内容可以是 0 个或多个 inline 节点
      content: 'inline*',
      // 当前节点分组为 block 分组
      group: 'block',
      // defining: 特殊属性,为 true 代表如果在当前标签内(以 h1 为例),全选内容,直接粘贴新的内容后,这些内容还会被 h1 标签包裹
      // 如果为 false, 整个 h1 标签(包括内容与标签本身)将会被替换为其他内容,删除亦如此。
      // 还有其他的特殊属性,后续细说
      defining: true,
      // 转为 html 标签时,根据当前的 level 属性,生成对应的 h1 - h6 标签,节点的内容填充在 h 标签中(“洞”在)。
      toDOM(node) {
        const tag = `h${node.attrs.level}`
        return [tag, 0]
      },
      // 从别处复制进来的富文本内容,根据标签序列化为当前 heading 节点,并填充对应的 level 属性
      parseDOM: [
        {tag: "h1", attrs: {level: 1}},
        {tag: "h2", attrs: {level: 2}},
        {tag: "h3", attrs: {level: 3}},
        {tag: "h4", attrs: {level: 4}},
        {tag: "h5", attrs: {level: 5}},
        {tag: "h6", attrs: {level: 6}}
      ],
    }
  },
  // 除了上面定义 node 节点,一些富文本样式,可以通过 marks 定义
  marks: {
    // 文本加粗
    strong: {
      // 对于加粗的部分,使用 strong 标签包裹,加粗的内容位于 strong 标签内(这里定义的 0 与上面一致,也念做 “洞”,也类似 vue 中的 slot)
      toDOM() {
        return ['strong', 0]
      },
      // 从别的地方复制过来的富文本,如果有 strong 标签,则被解析为一个 strong mark
      parseDOM: [
        { tag: 'strong' },
      ],
    }
  }
})

这里在定义 schema 的时候,我们定义了两部分内容,nodesmarks,简单理解两者的关系,node 代表文档中的某种节点,主要用来渲染内容,mark 更多为一些附加样式的定义,如上面的文本加粗,以及文本颜色,背景色,是否斜体等,这些 marks 是可以附加在 node 上的。向一段文本,可以同时是 斜体,加粗,红色,蓝色背景(即同时有 4 种 mark),但一个节点,它不可能同时既是一个段落节点,又是一个标题节点,不过节点之间可以嵌套,你可以定义在一个段落节点中,内容可以是 heading,但此时也只是包含关系,不是“既是A也是B”的关系。这就是他们的区别,后续到 Schema 会详细说明。

2.3 Prosemirror 中的数据与视图

还是对应到 mvc 的概念,有了 Model,要想在视图上展示一些内容,还需要有数据与视图

// view.ts
import { EditorView } from 'prosemirror-view'
import { EditorState } from 'prosemirror-state'
import { schema } from './model'

export const setupEditor = (el: HTMLElement | null) => {
  if (!el) return;

  // 根据 schema 定义,创建 editorState 数据实例
  const editorState = EditorState.create({
    schema,
  })
  
  // 创建编辑器视图实例,并挂在到 el 上
  const editorView = new EditorView(el, {
    state: editorState
  })

  console.log('editorView', editorView)
}

// main.ts
document.querySelector<HTMLDivElement>('#app')!.innerHTML = /*html*/`
  <div>
    <h3>从第一个 prosemirror 案例开始认识它</h3>
    <div id="editorContainer"></div>
  </div>
`
// 在 main.ts 中,调用 stetupEditor,将编辑器 view 挂在在 editorContainer 中
setupEditor(document.querySelector('#editorContainer'))

/* style.css 稍微对编辑器增加点样式 */
#editorContainer {
  border: 1px solid #646cffaa;
  padding: 0px 12px;
}

#editorContainer .ProseMirror {
  outline: none;
  caret-color: #646cffaa;
}

在上述代码中,通过 EditorState.create 静态方法,创建了一个以 schema 为数据规范的 state 实例,state 中默认数据是空数据,但这些数据必须符合 shema 的规定。

通过创建 EditorView 实例,将数据绑定到视图上,并且将视图挂在到 el 元素上,视图会根据 schema 的规定以及 state 中的数据定义,将数据按 schema 的规定渲染为 html,最终挂在到 el 元素上。

轻松入门Prosemirror: 快速掌握其核心概念

轻松入门Prosemirror: 快速掌握其核心概念

如果复制寄哪里的内容带图片,我们会发现,这里图片也没了,被转为了一个空段落,这也是因为我们没有定义图片对应的 node,所以编辑器不知道如何解析它。

到这里,我们可以大致明白 prosemirror 中的几个核心概念,schema 是用来规定编辑器文档内容展示的一系列规则,它会影响到内容的渲染,state 是编辑器视图数据,它是受 schema 约束的,任何输入进来内容进来都会根据 schema 的约束,规范化为规定格式的数据。EditorView 可以根据 schema 的规定以及给定的 state 数据,将内容渲染为 html,展示给用户。

小疑问:有人可能会问,不会是所有的节点都要定义 schema 吧,节点的种类那么多。很遗憾地告诉你,没错,所有的内容都需要定义 schema 或 marks,但通常一些常见的,基础的 schema可以靠 npm 包来,如果是自定义的一些块,还是需要自己完全定义 schema。

2.3 神操作:prosemirror 的插件系统

除了上面的核心模块,prosemirror 中还存在一个超级无敌核心的机制,插件系统。有了插件系统,prosemirror 的功能才得以强化,我们可以编写不同的插件,增加编辑器的交互性。插件的详细内容后续会专门写文章来说明,此处我们需要了解有这么一个插件机制。

在上面实现的编辑器中,你可能会发现,按下回车,无法换行,从别的地方复制粘贴进来的多行内容,只能删除某行的文本,删除到开头时,这一行删不了,使用 ctrl/cmd + z , shift + ctrl/cmd + z,也无法进行 undo, redo 撤销和重做。这是因为 prosemirror 的核心模块只关心核心的 schema 定义,数据解析,简单的输入等,对于特殊按键,有什么功能,它完全不关关心,这些功能需要通过插件实现。实现机制以很简单,拦截用户按键,如果按下的是特殊的键,就执行不同的功能,如按下回车,就进行换行等。

# 安装 prosemirror-keymap, prosemirror-commands, prosemirror-history
npm i prosemirror-keymap prosemirror-commands prosemirror-history

修改 view.ts 代码

import { EditorView } from 'prosemirror-view'
import { EditorState } from 'prosemirror-state'
import { schema } from './model'
// 新增以下导入
import { keymap } from 'prosemirror-keymap'
// baseKeymap 定义了对于很多基础按键按下后的功能,例如回车换行,删除键等。
import { baseKeymap } from 'prosemirror-commands'
// history 是操作历史,提供了对保存操作历史以及恢复等功能,undo,redo 函数对应为进行 undo 操作与 redo 操作,恢复历史数据
import { history, undo, redo } from 'prosemirror-history'

export const setupEditor = (el: HTMLElement | null) => {
  if (!el) return;

  // 根据 schema 定义,创建 editorState 数据实例
  const editorState = EditorState.create({
    schema,
    // 新增 keymap 插件。
    plugins: [
      // 这里 keymap 是个函数,运行后,会生成一个插件,插件功能即将基础按键绑定到对应的功能上,例如回车换行,删除键等。
      keymap(baseKeymap),
      // 接入 history 插件,提供输入历史栈功能
      history(),
      // 将组合按键 ctrl/cmd + z, ctrl/cmd + y 分别绑定到 undo, redo 功能上
      keymap({"Mod-z": undo, "Mod-y": redo}),
    ]
  })
  
  // 创建编辑器视图实例,并挂在到 el 上
  const editorView = new EditorView(el, {
    state: editorState
  })

  console.log('editorView', editorView)
}

此时刷新页面,你会发现,能够正常换行了,也能够正常进行删除行了,对于操作历史 通过 ctrl/cmd + z, ctrl/cmd + y 也可以进行undo redo。这就是 prosemirror 的插件系统,通过插件,我们可以对 prosemirror 进行各种功能的扩展。假如你不想用 prosemirror-keymap,你也可以自己开发一个 keymap 的插件,这些都是完全可拔插的,任何功能都可以自行替换。

3. 小结

本文旨在通过从 prosemirror 的顶层设计入手,以一个小案例,拉通 prosemirror 的核心概念,并理清其中的关系,帮助大家快速认识和入门 prosemirror,对其中的核心概念未做深刻细致讲解,它的细节将在后续内容中不断给出。同时也是对之前使用的一些小总结,也方便自己深入理解 prosemirror。

关于评论区:没有足够时间进行评论查看与解答,如有问题,可查阅官方文档。

关于更新计划:没有固定时间,有空就会总结,按自己节奏来。篇幅会控制尽量不要太长,方便阅读。

转载自:https://juejin.cn/post/7286642599326548031
评论
请登录