Prosemirror Node 魔法解析:揭秘文档编辑器(类飞书文档、Notion、WPS智能文档)中”块”的秘密

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 节点。

image-20231011232726927

​ 这里是几个概念之间的关系,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个或多个):

image-20231011235415406

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

image-20231012001438038

代码实现

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');
      
      
      
      
      return {
        dom: blockTile,
        contentDOM: blockTile
      }
    }
				
	}
})

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

image-20231012011334704

​ 除此之外,上面 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;


export function insertParagraph(editorView: EditorView, content: string) {
  const { state, dispatch } = editorView;
  const schema = state.schema as Schema;

  
  
  
  const paragraph = schema.node('paragraph', {}, schema.text(content));
  
  const block_tile = schema.node('block_tile', {}, paragraph);

  
  
  const pos = state.selection.anchor;
  
  
  const tr = state.tr.insert(pos, block_tile);

  
  dispatch(tr);
}


export function insertHeading(editorView: EditorView, content: string, level = 1) {
  const { state, dispatch } = editorView;
  const schema = state.schema as Schema;

  
  
  const heading = schema.nodes.heading.create({ level }, schema.text(content))
  const block_tile = schema.node(schema.nodes.block_tile, {}, heading);

  
  const tr = state.tr.replaceSelectionWith(block_tile);

  
  
  
  
  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';

  
  const editorState = EditorState.create({
    schema,
    plugins: [
      keymap(baseKeymap),
      
      history(),
      
      keymap({"Mod-z": undo, "Mod-y": redo}),
    ]
  })
  
  
  const editorView = new EditorView(editorRoot, {
    state: editorState
  })

  
  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)
  

  
  window.editorView = editorView
}

看看效果:

Kapture 2023-10-12 at 02.14.20

2.5 解析复制进来的 html

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

image-20231012022212618

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

export const schema = new Schema({
  nodes: {
		
    block_tile: {
      
      parseDOM: [
        {
          
          
          
          tag: 'div.block_tile',
        }
      ]
    },
    paragraph: {
      
      
      
      parseDOM: [{ tag: 'p' }]
    },
    heading: {
      
      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 } },
      ]
    },
    
  },
  topNode: 'doc'
})

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

image-20231012024312815

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

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

2.6.1 探秘 defining 的行为

默认的复制粘贴行为

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

Kapture 2023-10-12 at 10.42.55

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

为标题加入 defining

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

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

Kapture 2023-10-12 at 11.06.55

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

Kapture 2023-10-12 at 10.59.06

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

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

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

export const schema = new Schema({
  nodes: {
    
    blockquote: {
      
      content: 'paragraph+',
      group: 'block',
      defining: true,
      toDOM: () => {
        return ['blockquote', 0]
      },
      parseDOM: [
        { tag: 'blockquote' }
      ]
    }
  },
  topNode: 'doc'
})


export function insertBlockquote(editorView: EditorView, value = '') {
  const { state, dispatch } = editorView
  const schema = state.schema as Schema;

  
  const jsonContent = {
    type: 'blockquote',
    content: [
      {
        type: 'paragraph',
        content: value ? [
          {
            type: 'text',
            text: value,
          }
        ] : []
      }
    ]
  }

  
  const node = schema.nodeFromJSON(jsonContent);

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

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

Kapture 2023-10-12 at 11.30.52

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

defining 行为总结

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

2.6.2 探秘 isolating 的行为

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


{
  blockquote: {
    defining: true,
    
    isolating: true,
  }
}

Kapture 2023-10-12 at 11.36.23

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

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

2.6.3 draggable 与 selectable

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

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

Kapture 2023-10-12 at 12.29.44

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

Kapture 2023-10-12 at 12.31.19

2.6.4 探秘 atom 的特性

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


{
  
  datetime: {
    group: 'inline',
    inline: true,
    atom: true,
    attrs: {
      timestamp: {
        default: null
      }
    },
    toDOM(node) {
      
      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)
			
      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
          }
        }
      }
    ]
  }
}




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);
}

Kapture 2023-10-12 at 13.25.02

image-20231012132643132

​ 可以看到 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!

阅读全文
下载说明:
1、本站所有资源均从互联网上收集整理而来,仅供学习交流之用,因此不包含技术服务请大家谅解!
2、本站不提供任何实质性的付费和支付资源,所有需要积分下载的资源均为网站运营赞助费用或者线下劳务费用!
3、本站所有资源仅用于学习及研究使用,您必须在下载后的24小时内删除所下载资源,切勿用于商业用途,否则由此引发的法律纠纷及连带责任本站和发布者概不承担!
4、本站站内提供的所有可下载资源,本站保证未做任何负面改动(不包含修复bug和完善功能等正面优化或二次开发),但本站不保证资源的准确性、安全性和完整性,用户下载后自行斟酌,我们以交流学习为目的,并不是所有的源码都100%无错或无bug!如有链接无法下载、失效或广告,请联系客服处理!
5、本站资源除标明原创外均来自网络整理,版权归原作者或本站特约原创作者所有,如侵犯到您的合法权益,请立即告知本站,本站将及时予与删除并致以最深的歉意!
6、如果您也有好的资源或教程,您可以投稿发布,成功分享后有站币奖励和额外收入!
7、如果您喜欢该资源,请支持官方正版资源,以得到更好的正版服务!
8、请您认真阅读上述内容,注册本站用户或下载本站资源即您同意上述内容!
原文链接:https://www.shuli.cc/?p=21159,转载请注明出处。
0

评论0

显示验证码
没有账号?注册  忘记密码?