大家好,我是右子。非常热爱WEB编程的一名程序员。
最近因为工作原因,又把ProseMirror这个编辑器拿出来用了,顺便整理一下使用心得和理解。 分享给初学者。
ProseMirror的工作原理
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树结构。
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元素。这样,在调整列宽时,可以通过修改col的width属性来实时调整列宽。plugin.spec!.props!.nodeViews![tableNodeTypes(state.schema).table.name] = ( node, view, ) => new TableView(node, cellMinWidth, view); -
插件的
props设置了attribute(控制何时添加resize-cursor类)、handleDOMEvents(定义mousemove、mouseleave和mousedown事件)以及decorations(通过handleDecorations方法,在鼠标移动到列上时绘制所需的 DOM)。 -
handleMouseMove在鼠标移动时触发,更新pluginState以重新绘制 DOM。 -
handleMouseDown在鼠标按下时触发,记录当前位置信息和列宽至pluginState。在此方法中,定义了
mouseup和mousemove事件处理:move:移动时,根据draggedWidth获取移动宽度,调用updateColumnsOnResize实时更新colgroup中的col宽度。finish:移动完成后,调用updateColumnWidth方法重置列的宽度属性,并将pluginState置为初始状态。
-
handleMouseLeave在鼠标离开时触发,恢复pluginState为初始状态,完成列拖拽功能。
commands.ts
定义了一系列操作表格的命令方法。
selectedRect:获取表格选区,并返回选区信息、表格起始偏移量、表格信息(由TableMap.get(table)提供)和当前表格节点。此方法非常有用,可以获取当前表格的所有相关信息。- 其他方法如
addColumn、addRow等,实现了对表格进行操作的功能。
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
定义表格的节点类型,包括 table、table_header、table_cell 和 table_row。
tableNodeTypes(schema):接受schema参数,返回定义的节点类型,用于判断给定的schema是否为表格节点。
tablemap.ts
定义 TableMap 类,提供表格的结构映射。出于性能考虑进行了缓存处理:
- 如果缓存中不存在对应表格的
TableMap,则通过computeMap方法重新获取,并存入缓存中。
tableview.ts
定义 TableView 类,继承自 NodeView。自定义 NodeView 通常用于更精细地控制节点在编辑器中的显示样式和行为,如本例中用于管理表格列拖拽的样式和行为。
- 此类为
columnResizing插件的NodeViews提供支持。如果不实现列拖拽功能,则此文件不必要。
util.ts
定义用于处理表格操作的辅助函数:
cellAround:返回给定位置的当前单元格位置信息。cellWrapping:返回给定位置的当前单元格。isInTable:判断当前选区是否在表格中。selectionCell:返回当前选区的位置信息。pointsAtCell:判断给定位置是否在单元格内,返回true或false。moveCellForward:获取当前单元格的下一个单元格位置信息。inSameTable:判断当前选区是否在同一表格中。findCell:找到给定位置的单元格尺寸。colCount:返回当前单元格的列数。nextCell:在给定方向上查找下一个单元格。removeColSpan:删除指定单元格的colspan。addColSpan:为指定单元格添加 colspan,根据传入的 n 设定宽度。columnIsHeader:判断当前单元格是否为表头单元格。