我们公司的文档是基于tiptap二开的,而tiptap是对prosemirror的封装,在prosemirror的基础上提供了更友好的API、模块封装以及将MVVM的接入封装在框架内部,适用于各种流行框架,使开发者更容易上手。在tiptap的官网有这么一段话Tiptap is a headless wrapper around [ProseMirror](https://prosemirror.net/),这里的headless wrapper意思是“无头编辑器”,指的是不提供任何UI样式,完全自由的定制任何想要的UI,特别适合二次开发。

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类型

Node

文档中的节点,节点是 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')

Mark

用于给节点添加样式、属性或其他信息的一种方式。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

View

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,
  view
) => 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 })
    
  • handleMouseLeave当鼠标离开时,恢复pluginState为初始状态,完成列拖拽

commands.ts

定义操作表格的一系列方法

  • selectedRect:获取表格中的选区,并返回选区信息、表格起始偏移量、表格信息(TableMap.get(table)的值)和当前表格,这个方法很有用,能拿到当前表格中的所有信息

  • 剩下的方法都是需要用到的功能函数,像addColumnaddRow

copypaste.ts

用于处理将单元格内容粘贴到表格中、或将任何内容粘贴到单元格选择中,如用选择内容替换单元格块。

当在单元格中cmd + v触发粘贴时,步骤为:

  1. 调用input.ts中的handlePaste方法,根据传入的文档片段去做相应处理

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

// 判断是否为单元格或行,主要通过schema中定义的tableRole来判断// 行
first.type.spec.tableRole === 'row'// 单元格
first.type.spec.tableRole === 'cell'
first.type.spec.tableRole === 'header_cell'
  1. 判断当前选区是否为CellSelection,即是否选中一个或多个单元格的情况,会调用clipCells方法根据生成的cells生成表格新的一组单元格,通过insertCells插入原表格指定位置

    1. insertCells:将给定的一组单元格(由 pastedCells 返回)插入表格中 rect 指向的位置

    2. growTable、isolateHorizontalisolateVertical主要是为了确保被插入的表格足够大,足够容得下插入的单元格

  2. 如果当前选区不是CellSelection,但是pastedCells生成了新的cells,即复制的是表格单元格,则同样使用insertCells插入

  3. 不满足上面两个条件时,返回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,并放入缓存中。

TableMap 类用于描述表格的结构。它包含表格的宽度、高度、单元格位置映射,保存的位置是相对于表的起始位置,而不是文档的起始位置。

tableview.ts

参考

  • 此处定义的TableView继承自NodeView,一般来说自定义nodeView都是为了更细粒度的控制节点在编辑器中的表现样式,如此处用于控制表格列拖拽时的样式和行为

  • 上面已经提到了,会提供给插件columnresizingNodeViews使用,所以要是不用实现列拖拽功能时,这个文件也就没什么用了

/**
表格的视图类,用于渲染和管理表格的 DOM 结构,规则如下:
创建一个包含表格的 div 容器,并初始化表格的 DOM 结构(table、colgroup 和 tbody)。
提供更新表格列宽的逻辑。
忽略对表格和列组的属性变化的 DOM 突变事件。
@param {Node} node - 表格节点,包含表格的内容和属性。
@param {number} cellMinWidth - 单元格的最小宽度。
使用场景如下:
在表格编辑器中,渲染和管理表格的 DOM 结构。
*/
export class TableView {
  constructor(node, cellMinWidth) {
    this.node = node; // 表格节点
    this.cellMinWidth = cellMinWidth; // 单元格的最小宽度

    // 创建表格的 DOM 结构
    this.dom = document.createElement("div");
    this.dom.className = "tableWrapper"; // 设置容器的 className
    this.table = this.dom.appendChild(document.createElement("table")); // 创建表格元素
    this.colgroup = this.table.appendChild(document.createElement("colgroup")); // 创建列组元素
    this.contentDOM = this.table.appendChild(document.createElement("tbody")); // 创建内容区域(tbody)

    // 初始化列宽
    updateColumns(node, this.colgroup, this.table, cellMinWidth);
  }

  /**
更新表格视图,规则如下:
如果传入的节点类型与当前节点类型不同,则返回 false。
更新当前节点并重新计算列宽。
@param {Node} node - 新的表格节点。
@return {boolean} - 如果节点类型相同且更新成功,则返回 true;否则返回 false。
*/
update(node) {
// 如果节点类型不同,则返回 false
if (node.type != this.node.type) return false;

    this.node = node; // 更新当前节点
    updateColumns(node, this.colgroup, this.table, this.cellMinWidth); // 重新计算列宽
    return true; // 返回 true,表示更新成功
  }

  /**
忽略对表格和列组属性变化的 DOM 突变事件,规则如下:
如果突变的类型是属性变化(attributes),并且目标元素是表格或列组,则忽略该突变。
@param {MutationRecord} record - DOM 突变记录。
@return {boolean} - 如果忽略该突变,则返回 true;否则返回 false。
*/
ignoreMutation(record) {
return (
 record.type == "attributes" &&
 (record.target == this.table || this.colgroup.contains(record.target))
);
}
}

TableView 是用于渲染和管理表格 DOM 结构的视图类。它负责创建表格的 DOM 元素(如 table、colgroup 和 tbody),并提供更新和忽略 DOM 突变的逻辑。

  1. 属性

node: 表格节点,包含表格的内容和属性。

cellMinWidth: 单元格的最小宽度。

dom: 表格的容器元素(div)。

table: 表格元素(table)。

colgroup: 列组元素(colgroup)。

contentDOM: 表格内容区域(tbody)。

  1. 方法
  • constructor(node, cellMinWidth):

初始化表格的 DOM 结构,包括容器、表格、列组和内容区域。

调用 updateColumns 初始化列宽。

  • update(node):

更新表格视图。

如果传入的节点类型与当前节点类型不同,则返回 false。

更新当前节点并重新计算列宽。

  • ignoreMutation(record):

忽略对表格和列组属性变化的 DOM 突变事件。

如果突变的类型是属性变化(attributes),并且目标元素是表格或列组,则返回 true;否则返回 false。

  1. 使用场景

在表格编辑器中,渲染和管理表格的 DOM 结构。

处理表格更新和 DOM 突变事件。

示例

假设需要创建一个表格视图,代码可能会调用:

let node = { type: { name: "table" }, attrs: {} };
let view = new TableView(node, 100); // 创建表格视图
view.update(node); // 更新表格视图

updateColumns:在表格编辑器中,动态更新表格的列宽。

util.ts

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

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

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

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

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

cellNear:查找传入的位置附近的单元格位置信息。它会向前和向后查找,直到找到一个单元格或表头单元格

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

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

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

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

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

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

setAttr:给定的属性对象中增添一个新的属性值,并返回一个新的属性对象

removeColSpan:为指定单元格删除colspan

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

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