添加链接
link之家
链接快照平台
  • 输入网页链接,自动生成快照
  • 标签化管理网页链接

为什么写这篇文章

公司使用 tiptap 富文本编辑器,在 tiptap 的官网有这么一段话 Tiptap is a headless wrapper around [ProseMirror](https://prosemirror.net/) ,这里的 headless wrapper 意思是“无头编辑器”,指的是不提供任何 UI 样式,完全自由的定制任何想要的 UI ,特别适合二次开发。

tiptap 是对 prosemirror 的封装,在 prosemirror 的基础上提供了更友好的 API 、模块封装以及将 MVVM 的接入封装在框架内部,适用于各种 流行框架 ,使开发者更容易上手。

tiptap 提供大量官方 扩展 ,像本文介绍的 prosemirror-tabls ,但官方的毕竟是官方,一些样式或基本功能的改动,就必须要通过修改源码的方式实现。

PS:理解完概念再往下看,不然容易一脸懵

document

用于表示 ProseMirror 的整个文档,使用 editor.view.state.doc 引用, ProseMirror 定义自己的数据结构来存储 document 内容,通过输出可以看到 document 是一个 Node 类型,包含 content 元素,是一个 fragment 对象,而每个 fragment 又包含 0 个或多个字节点,组成了 document 解构,类似于 DOM

Schema

用于定义文档的结构和内容。它定义了一组节点类型和它们的属性,例如段落、标题、链接、图片等等。 Schema 是编辑器的模型层,可以通过其 API 创建、操作和验证文档中的节点。每个 document 都有一个与之相关的 schema ,用于描述存在于此 document 中的 nodes 类型

文档中的节点,节点是 Schema 中定义的类型之一,整个文档就是一个 Node 实例,它的每个子节点,例如一个段落、一个列表项、一张图片也是 Node 的实例。 Node 的修改遵循 Immutable 原则,更新时创建一个新的节点,而不是改变旧的节点,统一使用 dispatch 去触发更新。

const node = $cell.node(-1)
// 当前节点类型
node.type
// 节点的attributes
node.attrs
// 从指定node中获取符合条件的子节点
findChildren(tr.doc, (node) => node.type.name === 'table')

用于给节点添加样式、属性或其他信息的一种方式。Prosemirror 将行内文本视作扁平结构而非 DOM 类似的树状结构,这样是为了方便计数和操作。例如,一个文本节点可以添加加粗、斜体、下划线等样式,也可以添加标签、链接等属性。Mark 本身没有节点结构,只是对一个节点的文本内容进行修饰。Marks通过Schema创建,用于控制哪些marks存在于哪些节点以及用于哪些attributes

State

Prosemirror 的数据结构对象,相当于是 reactstate,有 viewstateplugin 的局部 state 之分。 如上面的 schema 就定义在其上: state.schemaProseMirror 使用一个单独的大对象来保持对编辑器所有 state 的引用(基本上来说,需要创建一个与当前编辑器相同的编辑器)

Transaction

继承自Transform,不仅能追踪对文档进行修改的一组操作,还能追踪state的其他变化,例如选区更新等。每次更新都会产生一个新的state.transactions(通过state.tr来创建一个transaction实例),描述当前state被应用的变化,这些变化用来应用当前state来创建一个更新之后的state,然后这个新的state被用来更新view

此处的state指的是EditorState,描述编辑器的状态,包含了文档的内容、选区、当前的节点和标记集合等信息。每次编辑器发生改变时,都会生成一个新的 EditorState

ProseMirror编辑器的视图层,负责渲染文档内容和处理用户的输入事件。View 接受来自 EditorState 的更新并将其渲染到屏幕上。同时,它也负责处理来自用户的输入事件,如键盘输入、鼠标点击等。其中state就是其上的一个属性:view.state

新建编辑器第一步就是new一个EditorVIew

Plugin

ProseMirror 中的插件,用于扩展编辑器的功能,例如点击/粘贴/撤销等。每个插件都是一个包含了一组方法的对象,这些方法可以监听编辑器的事件、修改事务、渲染视图等等。每个插件都包含一个key属性,如prosemirror-tables设置keytableColumnResizing,通过这个key就可以访问插件的配置和状态,而无需访问插件实例对象。

const pluginState = columnResizingPluginKey.getState(state)

Commands

表示Command函数集合,每个command函数定义一些触发事件来执行各种操作。

Decorations

表示节点的外观和行为的对象。它可以用于添加样式、标记、工具提示等效果,以及处理点击、悬停、拖拽等事件。Decoration 通常是在渲染视图时应用到节点上的,但也可以在其他情况下使用,如在协同编辑时标记其他用户的光标位置。

用于绘制document view,通过decorations属性的返回值来创建,包含三种类型

  • Node decorations:增加样式或其他 DOM 属性到单个nodeDOM 上,如选中表格时增加的类名
  • Widget decorations:在给定位置插入 DOM node,并不是实际文档的一部分,如表格拖拽时增加的基线
  • Inline decoration:在给定的 range 中的行内杨素插入样式或属性,类似于 Node decorations,仅针对行内元素
  • prosemirror 为了快速绘制这些类型,通过 decorationSet.create 静态方法来创建

    import { Plugin, PluginKey } from 'prosemirror-state'
    let purplePlugin = new Plugin({
      props: {
        decorations(state) {
          return DecorationSet.create(state.doc, [
            Decoration.inline(0, state.doc.content.size, {
              style: 'color: purple',
    

    ResolvedPos

    Prosemirror中通过Node.resolve解析位置信息返回的对象,包含了一些位置相关的信息。它会告诉我们当前position的父级node是什么,它在父级node中的偏移量(parentOffset)是多少以及其他信息。

    const $cell = doc.resolve(cell)
    // 从根节点开始,父级点的深度,如果直接指向根节点则为0,如果指定一个顶级节点,则为1
    $cell.deth
    // 该位置相对于父节点的偏移量
    $cell.parentOffset
    // 相当于$cell.parent() 获取父级节点,$cell.node(-2)获取父级的父级,以此类推
    $cell.node(-1)
    // 获取父节点的开始位置,相对于doc根节点的位置,一般用来定位
    $cell.start(-1)
    

    Selection

    表示当前选中内容,prosemirror中默认定义两种类型的选区对象:

  • TextSelection:文本选区,同时也可以表示正常的光标(即未选择任何文本时,此时anchor = head),包含$anchor选区固定的一侧,通常是左侧,$head选区移动的一侧,通常是右侧
  • NodeSelection:节点选区,表示一个节点被选择
  • 也可以通过继承Selection父类来实现自定义的选区类型,如CellSelection

    // 获取当前选区
    const sel = state.selection
    // 使用TextSelection创建文本选区
    const selection = new TextSelection($textAnchor, $textHead)
    // 使用NodeSelection创建节点选区
    const selection = new NodeSelection($pos)
    // 使用AllSelection创建覆盖整个文档的选区 可以作为cmd + a的操作
    const selection = new AllSelection(doc)
    // 用new之后的选区,更新当前 transaction 的选区
    state.tr.setSelection(selection)
    // 从指定选区获取符合条件的父节点
    findParentNode(
      (node) =>
        node.type.spec.tableRole && node.type.spec.tableRole.includes('cell')
    )(selection)
    

    Slice

  • slice of document称为文档片段,主要处理复制粘贴和拖拽之类的操作
  • 两个position之间的内容就是一个文档片段
  • ├── README.md
    ├── cellselection.ts
    ├── columnresizing.ts
    ├── commands.ts
    ├── copypaste.ts
    ├── fixtables.ts
    ├── index.html
    ├── index.ts
    ├── input.ts
    ├── schema.ts
    ├── tablemap.ts
    ├── tableview.ts
    └── util.ts
    

    cellselection.ts

    定义CellSelection选区对象,继承自Selection

  • drawCellSelection:用于当跨单元格选择时,绘制选区,会添加到tableEditingdecorations为每个选中节点增加classselectedCelltableEditing最后会注册为Editor的插件使用
  • columnresizing.ts

    定义columnResizing插件,用于实现列拖拽功能,大致思路如下:

    插件初始化时,通过以下代为插件添加nodeViews,通过实例化TableView为表格节点自定义一套渲染逻辑,在初始化的时候为DOM节点添加了colgroup,然后调用updateColumnWidth生成每列对应的col,有了col之后,我们在调整列宽的时候就可以通过改变colwidth属性实时的去改变列宽了。

    plugin.spec!.props!.nodeViews![tableNodeTypes(state.schema).table.name] = (
      node,
    ) => new View(node, cellMinWidth, view)
    

    通过设置插件的props传入attribute(控制何时添加类resize-cursor)、handleDOMEvents(定义mousemovemouseleavemousedown事件)和decorations(调用handleDecorations方法,在鼠标移动到列上时,通过Decoration.widget来绘制所需要的DOM

  • doc.resolve(cell): resolve解析文档中给定的位置,返回此位置的上下文信息
  • $cell.node(-1): 获取给定级别的祖先节点
  • $cell.start(-1): 获取给定级别节点到起点的(绝对)位置
  • TableMap.get(table): 获取当前表格数据,包含 width 列数、height 行数、mappospos 形成的数组
  • 循环 map.height,为当前列的每一个td上创建一个div
  • handleMouseMove当鼠标移动时,修改pluginState从而使得decorations重新绘制DOM

    handleMouseDown当鼠标按下时,获取当前位置信息和列宽,并记录在pluginState

    此方法中重新定义mouseupmousemove事件

    move:移动的同时从draggedWidth获取移动宽度,调用updateColumnsOnResize实时更新colgroup中的colwidth属性,从而改变每列宽度

    finish:当移动完成后调用updateColumnWidth方法重置当前列的attrs属性,并将pluginState置为初始状态

    // 用来改变给定 position node 的类型或者属性
    tr.setNodeMarkup(start + pos, null, { ...attrs, colwidth: colwidth })
    

    调用pastedCells,从文档片段中获取单元格的矩形区域,如果文档片段的外部节点不是表格单元格或行,则返回null,如果是的话会根据当前slice传入ensureRectangular去生成新的一组单元格

    // 判断是否为单元格或行,主要通过schema中定义的tableRole来判断
    first.type.spec.tableRole === 'row'
    // 单元格
    first.type.spec.tableRole === 'cell'
    first.type.spec.tableRole === 'header_cell'
    

    判断当前选区是否为CellSelection,即是否选中一个或多个单元格的情况,会调用clipCells方法根据生成的cells生成表格新的一组单元格,通过insertCells插入原表格指定位置

  • insertCells:将给定的一组单元格(由 pastedCells 返回)插入表格中 rect 指向的位置
  • growTable、isolateHorizontalisolateVertical主要是为了确保被插入的表格足够大,足够容得下插入的单元格
  • 如果当前选区不是CellSelection,但是pastedCells生成了新的cells,即复制的是表格单元格,则同样使用insertCells插入

    不满足上面两个条件时,返回false,即不用处理,按浏览器默认行为处理

    fixtables.ts

    定义了tiptap中的fixTables命令,用于检查文档中的所有表格并在必要时修复。通过代码可以看到fixTables就是遍历state.doc的所有子节点,如果是table的话就调用fixTable。而fixTable修复表格主要是根据表格是否存在TableMap.get(table).problems来做处理,problems包含四种类型

  • collision:直译为“碰撞”,我理解就是单元格相互挤压,处理方式是通过removeColSpan处理掉对应的单元格
  • missing:直译为”丢失“,处理方式是为丢失的单元格添加必要的单元格
  • overlong_rowspan:直译为“过长的 rowspan”,处理方式是修改对应单元格的rowspan
  • colwidth-mismatch:直译为“宽度不匹配”,处理方式是修改对应单元格的colwidth
  • 因为目前我没遇到过这些错误,所以对这些名词的理解还不是很清晰。

    index.ts

    定义插件tableEditing,用于处理单元格选择的绘制、以及创建和使用此类选择的基本用户交互。这个插件需要放在所有插件数组的末尾,因为它处理表格中的鼠标事件相当广泛。而其他插件,比如列宽拖动columnResizing插件,需要首先执行更具体的行为。 插件的props上定义了以下事件处理函数,这些事件处理函数如果返回true,说明它们处理了相应的事件,如果返回false则还是触发浏览器对应的事件

  • handleDOMEvents:优先级最高,会先于其他处理任何发生在可编辑DOM元素上的事件之前调用,这里注册了mousedown函数,调用input.js中的handleMouseDown事件,处理鼠标按下事件
  • handleTripleClick:三次单击编辑器时调用,这里会调用handleTripleClick函数,当三次单击的时候选中当前单元格
  • handleKeyDown:当编辑器收到 keydown 事件时调用,这里会调用handleKeyDown函数,绑定一些操作表格的快捷键
  • handlePaste:用于覆盖粘贴行为,slice是编辑器解析出来的粘贴内容,这里会调用handlePaste函数,上面已经说过,就不再重复
  • input.ts

    定义了一些功能函数,用于链接用户输入与table相关功能

    schema.ts

  • 定义tablesnode types,分别为tabletable_headertable_celltable_row节点
  • tableNodeTypes(schema)函数接受schema,返回上述定义的node types,可以用来判断传入的schema是否为table节点
  • tablemap.ts

    定义 TableMap 类,可以参考prosemirror-tables关于class TableMap的说明,或中文翻译。这里为了性能考虑,做了缓存处理。如果缓存中不存在对应表格的tableMap时,会通过computeMap重新获取tableMap,并放入缓存中。

    tableview.ts

  • 此处定义的TableView继承自NodeView,一般来说自定义nodeView都是为了更细粒度的控制节点在编辑器中的表现样式,如此处用于控制表格列拖拽时的样式和行为
  • 上面已经提到了,会提供给插件columnresizingNodeViews使用,所以要是不用实现列拖拽功能时,这个文件也就没什么用了
  • util.ts

    定义一些用于处理表格的各种辅助函数

    cellAround:根据传入的位置返回当前单元格的位置信息

    cellWrapping:根据传入的位置返回当前单元

    isInTable:传入 state`判断当前选区是否在表格中

    selectionCell:传入state返回当前选区的位置信息

    pointsAtCell:根据传入的位置判断是否在单元格内,返回truefalse

    moveCellForward:获取当前单元格的前一个单元格位置信息

    inSameTable:判断当前选区是否属于同一个表格

    findCell:找到给定位置的单元格的尺寸

    colCount:调用TableMapcolCount方法,返回当前单元格的列数

    nextCell:根据传入的位置,在给定方向上查找下一个单元格

    removeColSpan:为指定单元格删除colspan

    addColSpan:为指定单元格添加colspan,根据传入的n来设定

    columnIsHeader:判断当前单元格是否为header

    分类:
    前端