美文网首页
prosemirror-tables 源码解读

prosemirror-tables 源码解读

作者: 翔子丶 | 来源:发表于2023-02-22 09:36 被阅读0次

    为什么写这篇文章

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

    相关文章

      网友评论

          本文标题:prosemirror-tables 源码解读

          本文链接:https://www.haomeiwen.com/subject/oyqakdtx.html