1. Prosemirror 中的一砖一瓦

在 Prosemirror 中,可以将 Schema 看做 Prosemirror 的基石,它定义了文档的结构,而其中的核心单元就是 Node,对于目前比较流行的块编辑器,例如飞书文档、语雀、Notion、WPS 智能文档等,他们的底层逻辑都是块,在 Prosemirror 中,实现对应的块就是靠 Node。除了 Node,Prosemirror 中还存在另外一个概念 Mark,对于文本的高亮、颜色、加粗、斜体等样式,可以用 Mark 来实现。它们之间的区别之前也说过,Mark 一般是附加在文本块上的一些附属信息,主要还是用来展示文本样式,一段文本可以存在多个 Mark,例如一段文本可以同时是 粗体和斜体,并且拥有特殊的文字颜色等,但一个节点,是不能同时既是 A 类型节点又是 B 类型节点的。

在定义 Node 与 Mark 的过程中,有一些特殊的属性需要注意,我们之前两篇文章中也已经多次接触过 Schema 的结构定义了,本文将详细探索 Node 与 Mark 的更多细节。

2. Prosemirror Node 探秘

2.1 理清 NodeType 与 Node 的概念

首先要说明一点,在讨论 Schema 或文档结构的时候,我们所说的 Node 其实指的是 Node 类型,对应到代码中则为 NodeType 类,它主要是用来定义一个 Node 的结构到底是如何的,最终在输入框中输入的实际内容,对应到数据上的是 Node 实例,即一个 node 数据。例如我们定义一个 Heading 节点,它代表 h1 - h6 的标题,定义的这个 Heading 就是 NodeType,本文也主要讲这个结构的定义;最终向文档中加入的 h1 标题,实际在 state 中对应的数据则是在 Heading 结构规定的情况下实例化出来的 node 节点。

这里是几个概念之间的关系,NodeSpec 就是之前在 Schema 中填写的 Node 相关的描述,NodeType 是 Schema 实例化过程中,根据传入的 nodeSpec 规格说明书创建的 NodeType 实例,可以认为是 node 数据的工厂,后续所有的 node 类型都需要遵循它的定义,它也能像帮我们创造出一个 node 数据;最后生成的 Node 实例则是文档中对应到 dom 的一个具体数据。这就是这几个概念之间的关系。

2.2 利用 Node 内容表达式定义文档结构

在创建 Schema 的时候,最主要的还是在创建文档结构,而 Node 是如何规定文档结构的?这还要看其的 content 的定义,不过要始终牢记一点,对于 Node 的类型,只有两种,即 blockinline,你可以认为非黑即白,不是 block 就是 inline,他们的概念与 HTML 中的 block 元素及 inline 元素是差不多的。

假如我们想要规定以下结构的文档,文档有个根元素叫做 doc,doc 有且只能有一个或多个 block_tile 元素(说 Node 是一砖一瓦不是白说的,我们以一片瓦 tile 来定义一个元素),block_tile 中可以是一盒 heading 元素(标题),也可以是一个 paragraph(段落),段落中填入的是 text(文本,0 个或多个):

有没有像我们使用的 React 或 Vue 组件的定义?没错,就是完全相同的概念,NodeType 的定义其实就是组件的定义,不过这场定义中还包含了内容包含关系的定义,再以树结构展示一下我们的节点关系:

代码实现

import { Schema } from "prosemirror-model";

export const schema = new Schema({
  nodes: {
    // 根节点
    doc: {
      content: 'block_tile+'
    },
    // 自定义节点
    block_tile: {
      inline: false,
      content: 'block',
      group: 'tile',
    },
    // 段落
    paragraph: {
      content: 'inline*',
      group: 'block'
    },
    // 标题
    heading: {
      content: 'inline*',
      group: 'block'
    },
    // 文本
    text: {
      group: 'inline'
    }
  },
  topNode: 'doc'
})


以上便是我们依据设计好的结构创建出来的 Schema:

  • 其中 doc 我们说的特殊元素之一:根节点;默认就是 block 类型的,它的内容被规定为 有一个或多个 block_tile 节点(+表示一个或多个),
  • 紧接着定义自定义节点 block_tile,这里考虑是使用它作为统一的布局(可以类比到 React Vue 总的布局组件),其中规定可以只能有一个 block 元素,没错,在 Node content 内容的定义中,我们可以使用节点名称或者是节点分组来规定当前节点的子节点的内容结构,如果是 block,则在 block 分组中的所有内容都可以作为当前节点的子节点。
  • 在后面定义了 paragraphheading ,他们的内容都被规定为 inline** 表示可以有 0 个或多个 inline 分组内的节点作为子节点。
  • 最后是 text 节点,也是我们之前说的特殊节点,名字必须叫做 text,分组在 inline 中,它是 prosemirror 中最基础的文本节点的定义。

对于分组,我们上面看到,除了默认的 inline 与 block 分组,在 block_tile 中,它属于 tile 分组,这个分组名称使我们自定义的,后续假如有什么 flex_tile,column_tile 乱七八糟的布局都可以放到 tile 分组中。当然,如果不使用默认的分组,还需要通过 inline 属性规定当前节点的类型,设置为 false 即当前节点是 block 类型的节点。

对于内容表达式,除了上面我们看到的 + * 以及 单独输入一个节点名称或分组名称,还有一个 |没说到,它代表或,例如上面 block_tile 中规定内容为一个 block 分组的元素,还可以写成 paragraph|heading,但这样如果后续再加新的 block 节点类型,block_tile 中默认就不能支持了,除非显示声明进去,当然如果想在里面支持一个或多个 paragraph 与 heading,可以写成 (paragraph|heading)+block+。这就是 content 表达式全部规则了。

差点忘记, inline 元素与 block 元素不能混合,即 content 里面要么只能是 inline 类型的,要么只能是 block 类型的,不能混合排列,如 paragraph|text* 就会报错。

2.3 像定义 React 组件一样定义 Node 到 DOM 的转换规则

上面我们只是规定了几种 Node 之间的关系,但是 Node 最终展示在 HTML 中应该是 div 还是 p 还没有定义,所以还是不完整的,直接使用会报错,接下来就看看如何定义 Node 的结构。

export const schema = new Schema({
  nodes: {
    doc: {
      content: 'tile+'
    },
    block_tile: {
      content: 'block+',
      group: 'tile',
      inline: false,
      toDOM: () => {
        return ['div', { 'class': "block_tile" }, 0]
      },
    },
    paragraph: {
      content: 'inline*',
      group: 'block',
      toDOM: () => {
        return ['p', 0]
      }
    },
    heading: {
      attrs: {
        level: {
          default: 1
        }
      },
      content: 'inline*',
      group: 'block',
      toDOM: (node) => {
        const tag = 'h' + node.attrs.level
        return [tag, 0]
      }
    },
    text: {
      group: 'inline'
    }
  },
  topNode: 'doc'
})


在上面,我们给 block_tile paragraph heading 分别添加了一个 toDOM,里面规定了当前节点(可以认为是组件的概念)转换成 dom 时在页面中对应的结构。它使用与 React createElement 或者 Vue 中的 h 函数类似描述虚拟 dom 的语法来描述 dom 结构,语法为 [tag, attrs?, child?],child 的位置可以继续使用该语法嵌套描述,attrs 是对应 dom 的属性(可以不传),child 为 0 则代表占位(类似 Vue 中的 slot 默认插槽),在这里它念做 对应英文 hole,不念

除了上面这种数组的形式,还可以通过返回一个对象定义

new Schema({
  // ...
  block_tile: {
    toDOM: () => {
      const blockTile = document.createElement('div');
      blockTile.classList.add('block_tile');
      // 返回一个对象,dom 代表当前要转换成的 html
      // contentDOM 代表子节点应该填充在哪个 dom 元素里,这里就直接填充在 blockTile 中
      // 这种方法语义化清晰,但通过还是通过 上面数组的方式来定义
      // 如果当前节点不需要子节点填充,contentDOM 可以不填,此时也可以直接 return blockTile
      return {
        dom: blockTile,
        contentDOM: blockTile
      }
    }

	}
})


替换掉我们第一个 demo 中的 schema,输入内容,对应的 html 结构就出来了。

除此之外,上面 heading 中还定义了 attrs 属性,该属性对应 Vue React 组件中的 props 概念,我们定义了 level 属性,默认值是 1, toDOM 的时候,可以通过参数接收到对应的 props (这里给命名为是 node),通过 node.attrs 可以访问到实际传进来的参数。拼接为 h1h6 的标签,再组装为 [tag, 0]

2.4 通过代码向页面中插入我们定义的 Node

知道了如何定义节点(定义组件),那我们如何创建节点实例(类似 Vue React 使用组件),并将其插入到文档中呢?因为直接在文档中输入,目前我们只能输入文本,不能输入我们创建的什么 block_tile heading 之类的内容。以下代码详细介绍了如何插入,并且尽可能多地使用到了不同的 api 进行实现,也是为了方便大家对这些 api 混个眼熟,以后有些还会讲,不断反复遇到会强化记忆:

import { EditorView } from "prosemirror-view";
import { schema } from '../schema-learning/schema';

type Schema = typeof schema;

/**
 * 插入段落
 * @param editorView
 * @param content
 */
export function insertParagraph(editorView: EditorView, content: string) {
  const { state, dispatch } = editorView;
  const schema = state.schema as Schema;

  // 通过 schema.node 创建一个 paragraph 节点,这里用的是字符串,对应 paragraph 的名字(对应在创建 schema 时候的 key)
  // 第二个参数是 attrs,即对应到 Vue React 中的 props,如果没有需要传 空对象
  // 内容为 文本,文本内容为 content 对应字符串,schema.text 可以快速创建文本结点
  const paragraph = schema.node('paragraph', {}, schema.text(content));
  // 这里通过 schema.node 创建一个 block_tile 节点,这里通过直接拿到 block_tile 对应的 NodeType 来创建,内容为上面的段落
  const block_tile = schema.node('block_tile', {}, paragraph);

  // 这里通过 state.selection 可以获取到选区,通过 seletion.anchor 可以获取到选区开头的位置,我们在开头插入
  // 对于 选区没有概念的,可以翻看我对浏览器原生 Selection 与 Range 的讲解文章,这里的 anchor 差不多,但直接是个位置
  const pos = state.selection.anchor;

  // 通过 tr.insert 在 pos 位置将我们上面创建的节点插入到文档中
  const tr = state.tr.insert(pos, block_tile);

  // 派发更新
  dispatch(tr);
}

/**
 * 插入标题
 *
 * @param editorView
 * @param content
 * @param level
 */
export function insertHeading(editorView: EditorView, content: string, level = 1) {
  const { state, dispatch } = editorView;
  const schema = state.schema as Schema;

  // const heading = schema.node(schema.nodes.heading, { level }, schema.text(content))
  // 也可以这样定义: 直接通过 node(这里的 node 对应的是我们上面讲到的 NodeType) 工厂方法创建一个 heading 实例,
  const heading = schema.nodes.heading.create({ level }, schema.text(content))
  const block_tile = schema.node(schema.nodes.block_tile, {}, heading);

  // 将当前选区选中的内容都替换为 block_tile,比如输入的时候刚好选中一段文本,插入的时候,这段文本就应该被删掉,再插入一个 block_tile
  const tr = state.tr.replaceSelectionWith(block_tile);

  // prosemirror 触发更新都是通过派发一个 tr
  // (第一篇文章讲过,它是 Transaction 事务的实例,Transaction 是 prosemirror-transform 包中 Transform 的子类)
  // (在 MVC 模式中对应 controller 的角色,专门用来操作数据,上面就是根据 state.tr 上的方法替换了内容,之后返回一个新的 tr)
  // 通过 view.dispatch(tr) 可以将 tr 派发出去,实现视图的更新
  dispatch(tr);
}


上面我们严格按照之前我们定义的 block_tile > block > inline* 的结构插入节点。我们再在 html 中加两个 button 来绑定一下输入吧:

import { EditorView } from 'prosemirror-view'
import { EditorState } from 'prosemirror-state'
import { schema } from '../schema-learning/schema'
import { keymap } from 'prosemirror-keymap'
import { baseKeymap } from 'prosemirror-commands'
import { history, undo, redo } from 'prosemirror-history'
import { insertHeading, insertParagraph } from '../utils/insertContent'

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

  const editorRoot = document.createElement('div');
  editorRoot.id = 'editorRoot';

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

  // 创建编辑器视图实例,并挂在到 el 上
  const editorView = new EditorView(editorRoot, {
    state: editorState
  })

  // 添加两个 button 分别插入段落和标题
  const btnGroup = document.createElement('div');
  btnGroup.style.marginBottom = '12px';
  const addParagraphBtn = document.createElement('button');
  addParagraphBtn.innerText = '添加新段落';
  addParagraphBtn.addEventListener('click', () => insertParagraph(editorView, '新段落'))

  const addHeadingBtn = document.createElement('button');
  addHeadingBtn.innerText = '添加新一级标题';
  addHeadingBtn.addEventListener('click', () => insertHeading(editorView, '新一级标题'))

  btnGroup.appendChild(addParagraphBtn)
  btnGroup.appendChild(addHeadingBtn)

  const fragment = document.createDocumentFragment()
  fragment.appendChild(btnGroup)
  fragment.appendChild(editorRoot)

  el.appendChild(fragment)


  // @ts-ignore
  window.editorView = editorView
}


看看效果:

2.5 解析复制进来的 html

现在我们是可以输入输入内容了,但是有个问题,例如从掘金复制一些带标题和正文的文本,粘贴进来它只有普通文本:

这是因为我们在定义 node 的时候还缺 parseDOM 的部分,这部分允许我们为每个节点的定义提供解析规则,供 DOMParser 在将 dom 转为 node 时候使用该规则进行转换。规则详情 ParseRule

export const schema = new Schema({
  nodes: {
		//...
    block_tile: {
      //...
      parseDOM: [
        {
          // 将 class 为 block_tile 的 div 转为 block_tile
          // 注意,这里使用 tag 需要带上对应的 css 选择器
          // 假如复制的内容还有别的属性,都不用管,只要能根据当前 tag 匹配到,就会转为 block_tile
          tag: 'div.block_tile',
        }
      ]
    },
    paragraph: {
      //...
      // 将 p 标签对应的内容转为 paragraph 节点
      // 这里不用管复制进来的 p 到底带有什么属性,样式,只要有 p 标签就能匹配上,然后转为 paragraph 节点
      parseDOM: [{ tag: 'p' }]
    },
    heading: {
      //...
      parseDOM: [
        // 这里配置了 6 条规则,将对应 tag 能匹配到的元素,分别转为 heading 节点,并且填充对应的 attrs(类似 Vue React 的 props)
        { 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 } },
      ]
    },
    //...
  },
  topNode: 'doc'
})


这里的 parseRule 可以查看一下对应的官方文档,其他常用的内容会在后续实战或者源码解读中遇到的时候学习。如果有需要,可以自行查询。

2.6 Node 定义中的其他特殊属性

在定义 Node 的时候,除了有上面基础(上面基础中有个 marks 未用到,这个留到探索 marks 时候再看),还有一些特殊的属性,规定了 Node 的一些特殊行为,这些特殊属性特别重要,如果理解不深入,稍有不注意,就会引起不符合预期的输入或者 bug。

2.6.1 探秘 defining 的行为

默认的复制粘贴行为

例如段落与标题之间,复制一个段落中的文本,将标题内容全选后粘贴,你的预期是什么?是这部分内容应该将标题整体替换为一个新段落,还是仅仅替换标题中的文本,但仍保留标题这个块?我们来看看:

默认情况下,仅仅会替换文本,那如果复制了一个标题,全选一个段落,或者在一个空段落内粘贴,你期望是粘贴一个标题还是将内容替换为段落?其实大多数场景下是希望粘贴一个标题的,但目前粘贴都只是纯文本的替换,不会涉及到 block 块的替换。

为标题加入 defining

为了满足预期,即复制一个标题后,全选段落内容或者在一个空段落中粘贴,期望是粘贴整个标题块。我们为 heading 加入 defining 属性。

new Schema({
  nodes: {
    heading: {
      //...
      defining: true,
      //...
    }
  }
})


好了,加入 defining 之后,成功达成预期,此时我们再复制段落,同样的操作粘贴到标题中,我们先谈谈预期:是希望覆盖掉标题为一个段落还是仅仅替换标题里的文本?当然是仅仅替换文本更符合预期了。那我们来看看行为:

观察它的行为,符合预期,当我们复制 2 个段落粘贴的时候,会发现第一个段落转为标题,第二个段落仍然正常粘贴。这也是符合预期的,毕竟是两个段落,两个块,它与一个段落因为文本过长导致折行展示是不一样的概念。

两个定义了 defining 的块互相粘贴会打架吗

我们再加入一个 blockquote 块,并添加 defining 属性

export const schema = new Schema({
  nodes: {
    // ...
    blockquote: {
      // blockquote 中允许输入多个段落
      content: 'paragraph+',
      group: 'block',
      defining: true,
      toDOM: () => {
        return ['blockquote', 0]
      },
      parseDOM: [
        { tag: 'blockquote' }
      ]
    }
  },
  topNode: 'doc'
})
// insertContent.ts
/**
 * 插入 blockquote 的函数
 *
 * @param editorView
 * @param content
 */
export function insertBlockquote(editorView: EditorView, value = '') {
  const { state, dispatch } = editorView
  const schema = state.schema as Schema;

  /**
   * 使用 JSON 描述内容,通过 state.doc.toJSON 可以看到 node 数据转 json 后的样子,就是下方一个 type, 一个 content 这种样式
   * 同样,通过 json 也能反向生成对应的 node
   */
  const jsonContent = {
    type: 'blockquote',
    content: [
      {
        type: 'paragraph',
        content: value ? [
          {
            type: 'text',
            text: value,
          }
        ] : []
      }
    ]
  }

  // 通过 nodeFromJSON 实例化 node,相比之前使用 api,使用 json 描述就非常简单快捷
  const node = schema.nodeFromJSON(jsonContent);

  const tr = state.tr.replaceWith(state.selection.from, state.selection.to, node)
  dispatch(tr);
}


为了保证大家多认识 api,混个眼熟,我们会尽可能多地使用不同的 api 实现方法,上面的 blockquote 对应的样式就不在文中放出来了。

我们可以看到,他们会互相替换。

defining 行为总结

为一个块增加了 defining 定义后,复制这个块中的内容,粘贴进其他块(且该块内容全选或者是一个空的块)的时候,会将当前块转换为加了 defining 的块,然后将文本粘贴进去。当从其他块中复制了内容,要粘贴到 defining 中时候,仅仅会替换文本。

2.6.2 探秘 isolating 的行为

接下来看看 isolating 有什么特殊行为

//...
{
  blockquote: {
    defining: true,
    // 添加 isolating
    isolating: true,
  }
}
//...


正如其名,“绝缘的,隔离的”,添加了 isolating 后,在当前块内删除元素,删到头的时候,再按删除也无法删除当前块,并且复制之前的 defining 的标题,任你天王老子来了,也得乖乖听话,按照 blockquote 的内容规定渲染节点,如果 blockquote 中 contnet 允许了 heading,则可以正常展示,我们这里只允许了 paragraph ,所以粘贴进来会变为纯文本,丢失 h1 标签。

这个东西有什么用?在 table 中定义的单元格,就应该是这样的,否则单元格元素都会被删掉。其大家遇到其他具体场景可以按需添加这个属性(如果想分享自己遇到的场景,可在评论区讨论)。

2.6.3 draggable 与 selectable

这俩属性也是见名知意,draggable 控制元素是否可以被拖拽(默认不行),selectable 控制元素是否可以被选中,但是要注意的是,如果一个元素不可以被选中,那它也是不能被拖拽的。

我们可以简单看看 demo,为 block_tile 增加 draggable 为 true,并且增加一点样式,来拖拽试试:

再同时添加 selectable: false 看看效果:是无法拖拽的

2.6.4 探秘 atom 的特性

也是见名知意,atom 为原子化,则代表其为一个最小单元,这种类型的节点,内部就不应该有可编辑的内容了,它的 content 属性也不需要再声明了,它的 nodeSize 大小始终都是 1,会被 prosemirror 作为独立的单元对待。此时我们增加一个 datetime 类型的节点:

//...
{
  //...
  datetime: {
    group: 'inline',
    inline: true,
    atom: true,
    attrs: {
      timestamp: {
        default: null
      }
    },
    toDOM(node) {
      // 自定义 dom 结构
      const dom = document.createElement('span');
      dom.classList.add('datetime')
      dom.dataset.timestamp = node.attrs.timestamp;
      console.log('node.attrs',node.attrs)

      let time = '';
      if (node.attrs.timestamp) {
        const date = new Date(node.attrs.timestamp)
        time = `${date.getFullYear()}-${(date.getMonth() + 1).toString().padStart(2, '0')}-${date.getDate().toString().padStart(2, '0')}`
      }

      const label = document.createElement('label');
      label.innerText = '请选择时间';

      const input = document.createElement('input');
      input.type="date";
      input.value = time;

      input.addEventListener('input', (event) => {
        dom.dataset.timestamp = new Date((event.target as HTMLInputElement).value).getTime().toString()
      })

      dom.appendChild(label)
      dom.appendChild(input)
			// 返回 dom
      return dom;
    },
    parseDOM: [
      {
        tag: 'span.datetime',
        getAttrs(htmlNode) {
          if (typeof htmlNode !== 'string') {
            const timestamp = htmlNode.dataset.timestamp;
            return {
              timestamp: timestamp ? Number(timestamp) : null
            }
          };
          return {
            timestamp: null
          }
        }
      }
    ]
  }
}
//...

// insertContent.ts
/**
 * 插入时间选择器
 *
 * @param editorView
 * @param datetime
 */
export function insertDatetime(editorView: EditorView, timestamp: number) {
  const { state, dispatch } = editorView
  const schema = state.schema as Schema;

  const jsonContent = {
    type: 'datetime',
    attrs: {
      timestamp: timestamp || Date.now()
    }
  }

  const node = schema.nodeFromJSON(jsonContent);
  console.log('jsonContent',jsonContent,node)
  const tr = state.tr.replaceWith(state.selection.from, state.selection.to, node)
  dispatch(tr);
}


可以看到 nodeSize 是 1,类似这种自定义 UI 的节点(例如各种业务组件),我们就可以通过添加 atom 来将其嵌入到富文本中,不过还是要注意,这类节点内部不能编辑。

2.6.5 最后一个 code

这个属性一般在我们实现内嵌代码时用到,如果标记为 code, 则编辑内容时一些命令会有特殊行为。这个后续实战实现 code 类型再详细了解,一般情况也不考虑。

3. 小结

本文详细探索了 Schema 中 Node 的定义,通过实际案例,详细了解了 Node 的特殊属性带来的特殊行为,这对于后续实际业务中实现特殊的场景非常有用。同时我们看到 Prosemirror 也是典型的块编辑器,目前主流的在线文档产品 飞书文档、Notion、语雀、wps 智能文档等产品,也都是块编辑器,我们通过 Prosemirror 也能够实现类似的产品。

当然 Node 的内容还没有彻底结束,关于 Node 内部 UI 的自定义(NodeView)后续还会再探索。本文提到了 Node 与 Mark,为了控制篇幅,Mark 的内容放到下次吧。

See you next time!