prosemirror-tables 源码解读
正在加载今日诗词....
2023-03-06

为什么写这篇文章

公司使用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

.doc-node.jpg

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 的引用(基本上来说,需要创建一个与当前编辑器相同的编辑器)

prosemirror-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)的值)和当前表格,这个方法很有用,能拿到当前表格中的所有信息

    table-info.jpg

  • 剩下的方法都是需要用到的功能函数,像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'
    
  3. 判断当前选区是否为CellSelection,即是否选中一个或多个单元格的情况,会调用clipCells方法根据生成的cells生成表格新的一组单元格,通过insertCells插入原表格指定位置

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

  5. 不满足上面两个条件时,返回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

京ICP备2022027737号
Copyright © 2022 - present @wangxiang

  • ☀️
  • 🌑