prosemirror-tables 源码解读

为什么写这篇文章

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

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插入原表格指定位置

    • insertCell:将给定的一组单元格(由 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
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,324评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,303评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,192评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,555评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,569评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,566评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,927评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,583评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,827评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,590评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,669评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,365评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,941评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,928评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,159评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,880评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,399评论 2 342

推荐阅读更多精彩内容