30分钟带你了解ProseMirror核心

2,546 阅读9分钟

大家好,我是右子。非常热爱WEB编程的一名程序员。

最近因为工作原因,又把ProseMirror这个编辑器拿出来用了,顺便整理一下使用心得和理解。 分享给初学者。

ProseMirror的工作原理

image.png

ProseMirror依赖库

基础库

prosemirror-view:负责将编辑器状态渲染到DOM中,并处理用户与编辑器的交互,如点击、键入等。它是连接编辑器状态和页面显示的桥梁。

prosemirror-state:管理编辑器的状态,包括文档的当前内容、选择区域、撤销/重做历史等。它允许开发者通过事务来修改编辑器的状态,并响应状态的变化。

prosemirror-model:这个包定义了ProseMirror编辑器的文档模型,包括节点(如段落、标题)和标记(如加粗、斜体)。它提供了创建、修改文档结构的API,是ProseMirror的基础。

辅助包

prosemirror-transform:提供操作文档结构的API,如插入、删除节点等。它用于实现文档的变化,是编辑器能够修改文档内容的基础。

prosemirror-schema-basic:提供了一个基础的文档模式(schema),包括常见的文本块类型如段落、标题和列表等。这是一个快速开始的好选择,特别是对于简单应用。

prosemirror-commands:包含一组常用的命令函数,如加粗、创建列表等。这些命令可以直接用于修改编辑器状态或文档结构。

根据你得需要进行安装

prosemirror-schema-list:扩展了prosemirror-schema-basic,添加了对有序列表(ol)和无序列表(ul)的支持。这个包特别关注于列表的实现。

prosemirror-example-setup:提供了一套默认的编辑器配置,包括基础的菜单栏、快捷键和输入规则等。这个包旨在帮助开发者快速启动一个功能齐全的编辑器实例,适合初学者或作为原型开发使用。

相关依赖可以去官网自行查看。

常用API

document

ProseMirror中,document代表整个文档模型。它通过editor.view.state.doc访问,并采用ProseMirror定义的特定数据结构来存储文档内容。在这个框架下,document是一个Node类型的实例,它包含名为content的元素,此元素是一个Fragment对象。Fragment对象可以包含零个或多个子节点,这些子节点共同构成了整个文档的结构,类似于传统的DOM树结构。

image.png

Schema

Schema定义了文档的结构和内容规范。它明确规定了一系列的节点类型及其属性,如段落、标题、链接、图片等。作为编辑器的模型层,Schema提供API来创建、操作和验证文档中的节点。每个document关联一个schema,用于描述该文档中的节点类型。

Node

文档中的每个元素都是一个节点,称为Node,它是Schema中定义的类型之一。从宏观上讲,整个文档本身就是一个Node实例,而文档中的具体内容,如段落、列表项或图片,都是该节点的子实例。在ProseMirror中,节点的修改采用不可变(Immutable)原则,即通过创建新的节点来更新文档,而不是直接修改旧节点。所有的更新操作都通过dispatch函数来触发。

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

Mark

在ProseMirror中,Mark用于给节点添加样式、属性或其他信息,而不创建节点结构。这使得对行内文本的处理更加灵活,例如可以为文本节点添加加粗、斜体、下划线等样式,以及标签和链接等属性。Marks通过Schema定义,指定它们应用于哪些节点和属性。

State

ProseMirror中的State类似于React的state,包括视图(view)的状态和插件(plugin)的局部状态。例如,state.schema定义了文档结构和规则。State对象作为编辑器状态的核心,存储了文档内容、选区等所有编辑器状态信息。

Transaction

Transaction继承自Transform,不仅追踪文档的修改操作,还跟踪state的其他变化,如选区更新。通过state.tr创建Transaction实例,描述并应用状态变化以生成新的state,进而更新视图。

View

ProseMirror的View负责渲染文档内容和处理用户输入。它根据EditorState的更新来渲染文档,并处理键盘输入、鼠标点击等用户事件。创建编辑器的第一步通常是实例化一个EditorView。

Plugin

ProseMirror的Plugin用于扩展编辑器功能,如撤销、粘贴处理等。插件是一个包含一组方法的对象,可监听编辑器事件、修改事务和渲染视图。插件通过key属性进行访问和配置。

const pluginState = columnResizingPluginKey.getState(state);

Commands

Commands集合包含定义了一系列操作的函数,用于触发编辑器中的不同行为和交互。

Decorations

Decorations用于定义节点的外观和行为,如添加样式、工具提示,处理事件等。ProseMirror通过DecorationSet来管理decorations,支持对节点、文档中的位置或行内元素添加修饰。

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

ResolvedPos对象通过Node.resolve方法获取,包含位置相关的详细信息,如在文档树中的深度、父节点的偏移量等,常用于分析文档结构和位置信息。

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

Selection

Selection代表编辑器中的选区,可以是文本选区(TextSelection)、节点选区(NodeSelection)等。通过Selection,可以获取、修改当前的选区状态,支持自定义选区类型的扩展。

// 获取当前选区
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代表文档的一个片段,用于处理复制、粘贴和拖拽等操作。它表示文档中两个位置之间的内容,通常用于操作文档的子部分。

源码目录

目录结构

├── 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:当跨单元格选择时,此函数用于绘制选区。它会将每个选中的单元格节点增加 selectedCell 类,这一过程最终由 tableEditing 的装饰器(decorations)处理,tableEditing 最后会注册为编辑器的插件使用。

columnresizing.ts

该文件定义了 columnResizing 插件,用于实现列拖拽功能。实现思路如下:

  • 在插件初始化时,通过设置 nodeViews,利用 TableView 类为表格节点提供自定义渲染逻辑。初始化时会为 DOM 节点添加 colgroup,然后调用 updateColumnWidth 方法为每列生成相应的 col 元素。这样,在调整列宽时,可以通过修改 colwidth 属性来实时调整列宽。

    plugin.spec!.props!.nodeViews![tableNodeTypes(state.schema).table.name] = (
      node,
      view,
    ) => new TableView(node, cellMinWidth, view);
    
  • 插件的 props 设置了 attribute(控制何时添加 resize-cursor 类)、handleDOMEvents(定义 mousemovemouseleavemousedown 事件)以及 decorations(通过 handleDecorations 方法,在鼠标移动到列上时绘制所需的 DOM)。

  • handleMouseMove 在鼠标移动时触发,更新 pluginState 以重新绘制 DOM。

  • handleMouseDown 在鼠标按下时触发,记录当前位置信息和列宽至 pluginState

    在此方法中,定义了 mouseupmousemove 事件处理:

    • move:移动时,根据 draggedWidth 获取移动宽度,调用 updateColumnsOnResize 实时更新 colgroup 中的 col 宽度。
    • finish:移动完成后,调用 updateColumnWidth 方法重置列的宽度属性,并将 pluginState 置为初始状态。
  • handleMouseLeave 在鼠标离开时触发,恢复 pluginState 为初始状态,完成列拖拽功能。

commands.ts

定义了一系列操作表格的命令方法。

  • selectedRect:获取表格选区,并返回选区信息、表格起始偏移量、表格信息(由 TableMap.get(table) 提供)和当前表格节点。此方法非常有用,可以获取当前表格的所有相关信息。
  • 其他方法如 addColumnaddRow 等,实现了对表格进行操作的功能。

copypaste.ts

处理单元格内容的复制粘贴逻辑。

  • 在单元格内使用 cmd + v 触发粘贴操作时:
    • 调用 input.ts 中的 handlePaste 方法,根据传入的文档片段进行处理。
    • pastedCells 从文档片段中提取单元格矩形区域。如果文档片段的最外层节点不是表格单元格或行,则返回 null。否则,会根据当前的 slice内容生成新的单元格集合。
// 判断是否为单元格或行,主要通过 schema 中定义的 tableRole 来判断
first.type.spec.tableRole === 'row'; // 行
first.type.spec.tableRole === 'cell'; // 单元格
first.type.spec.tableRole === 'header_cell'; // 表头单元格

若当前选区是 CellSelection(即选中了一个或多个单元格),会调用 clipCells 方法根据生成的 cells 生成新的一组单元格,并通过 insertCells 将这些单元格插入到原表格的指定位置。

insertCell:在 rect 指定的位置将 pastedCells 返回的单元格数组插入到表格中。 growTable:使用 isolateHorizontal 和 isolateVertical 方法确保插入的表格足够大以容纳新单元格。 如果当前选区不是 CellSelection,但 pastedCells 生成了新的单元格,则同样使用 insertCells 方法进行插入。

如果上述条件都不满足,则返回 false,即按浏览器默认行为处理粘贴操作。

fixtables.ts

定义了 fixTables 命令,在 tiptap 中用于检查并修复文档中的所有表格。实现原理如下:

  • 遍历 state.doc 的所有子节点,对于表格节点,调用 fixTable 进行修复。
  • fixTable 主要根据 TableMap.get(table).problems 判断表格是否存在问题,并进行相应的处理,问题类型包括:
    • collision(碰撞):处理方式是通过 removeColSpan 方法移除有冲突的单元格。
    • missing(丢失):为缺失的单元格添加必要的单元格。
    • overlong_rowspan(过长的 rowspan):调整对应单元格的 rowspan 值。
    • colwidth mismatch(宽度不匹配):调整对应单元格的 colwidth

index.ts

定义 tableEditing 插件,处理单元格选择的绘制及基本用户交互。该插件应位于插件数组的末尾,因为它需要广泛处理表格中的鼠标事件。其他特定行为的插件(如列宽拖动 columnResizing)应先执行。

  • handleDOMEvents:具有最高优先级,在其他事件之前处理发生在可编辑 DOM 元素上的事件。例如,注册 mousedown 函数,调用 input.ts 中的 handleMouseDown 事件处理函数。
  • handleTripleClick:编辑器三次单击时触发,用于选中当前单元格。
  • handleKeyDown:编辑器接收到 keydown 事件时触发,绑定操作表格的快捷键。
  • handlePaste:覆盖粘贴行为,已在 copypaste.ts 中讨论。

input.ts

定义了一些功能函数,将用户输入与表格相关功能相链接。

schema.ts

定义表格的节点类型,包括 tabletable_headertable_celltable_row

  • tableNodeTypes(schema):接受 schema 参数,返回定义的节点类型,用于判断给定的 schema 是否为表格节点。

tablemap.ts

定义 TableMap 类,提供表格的结构映射。出于性能考虑进行了缓存处理:

  • 如果缓存中不存在对应表格的 TableMap,则通过 computeMap 方法重新获取,并存入缓存中。

tableview.ts

定义 TableView 类,继承自 NodeView。自定义 NodeView 通常用于更精细地控制节点在编辑器中的显示样式和行为,如本例中用于管理表格列拖拽的样式和行为。

  • 此类为 columnResizing 插件的 NodeViews 提供支持。如果不实现列拖拽功能,则此文件不必要。

util.ts

定义用于处理表格操作的辅助函数:

  • cellAround:返回给定位置的当前单元格位置信息。
  • cellWrapping:返回给定位置的当前单元格。
  • isInTable:判断当前选区是否在表格中。
  • selectionCell:返回当前选区的位置信息。
  • pointsAtCell:判断给定位置是否在单元格内,返回 truefalse
  • moveCellForward:获取当前单元格的下一个单元格位置信息。
  • inSameTable:判断当前选区是否在同一表格中。
  • findCell:找到给定位置的单元格尺寸。
  • colCount:返回当前单元格的列数。
  • nextCell:在给定方向上查找下一个单元格。
  • removeColSpan:删除指定单元格的 colspan
  • addColSpan:为指定单元格添加 colspan,根据传入的 n 设定宽度。
  • columnIsHeader:判断当前单元格是否为表头单元格。