為什么寫這篇文章
公司使用tiptap富文本編輯器,在tiptap的官網(wǎng)有這么一段話Tiptap is a headless wrapper around [ProseMirror](https://prosemirror.net/),這里的headless wrapper意思是“無頭編輯器”,指的是不提供任何UI樣式,完全自由的定制任何想要的UI,特別適合二次開發(fā)。
而tiptap是對prosemirror的封裝,在prosemirror的基礎(chǔ)上提供了更友好的API、模塊封裝以及將MVVM的接入封裝在框架內(nèi)部,適用于各種流行框架,使開發(fā)者更容易上手。
tiptap提供大量官方擴展,像本文介紹的prosemirror-tabls,但官方的畢竟是官方,一些樣式或基本功能的改動,就必須要通過修改源碼的方式實現(xiàn)。
名次解釋
PS:理解完概念再往下看,不然容易一臉懵
document
用于表示ProseMirror的整個文檔,使用editor.view.state.doc引用,ProseMirror定義自己的數(shù)據(jù)結(jié)構(gòu)來存儲document內(nèi)容,通過輸出可以看到document是一個Node類型,包含content元素,是一個fragment對象,而每個fragment又包含 0 個或多個字節(jié)點,組成了document解構(gòu),類似于DOM樹

Schema
用于定義文檔的結(jié)構(gòu)和內(nèi)容。它定義了一組節(jié)點類型和它們的屬性,例如段落、標(biāo)題、鏈接、圖片等等。Schema 是編輯器的模型層,可以通過其 API 創(chuàng)建、操作和驗證文檔中的節(jié)點。每個document都有一個與之相關(guān)的schema,用于描述存在于此document中的nodes類型
Node
文檔中的節(jié)點,節(jié)點是 Schema 中定義的類型之一,整個文檔就是一個Node實例,它的每個子節(jié)點,例如一個段落、一個列表項、一張圖片也是Node的實例。Node的修改遵循Immutable原則,更新時創(chuàng)建一個新的節(jié)點,而不是改變舊的節(jié)點,統(tǒng)一使用dispatch去觸發(fā)更新。
const node = $cell.node(-1);
// 當(dāng)前節(jié)點類型
node.type;
// 節(jié)點的attributes
node.attrs;
// 從指定node中獲取符合條件的子節(jié)點
findChildren(tr.doc, (node) => node.type.name === 'table');
Mark
用于給節(jié)點添加樣式、屬性或其他信息的一種方式。Prosemirror 將行內(nèi)文本視作扁平結(jié)構(gòu)而非 DOM 類似的樹狀結(jié)構(gòu),這樣是為了方便計數(shù)和操作。例如,一個文本節(jié)點可以添加加粗、斜體、下劃線等樣式,也可以添加標(biāo)簽、鏈接等屬性。Mark 本身沒有節(jié)點結(jié)構(gòu),只是對一個節(jié)點的文本內(nèi)容進行修飾。Marks通過Schema創(chuàng)建,用于控制哪些marks存在于哪些節(jié)點以及用于哪些attributes。
State
Prosemirror 的數(shù)據(jù)結(jié)構(gòu)對象,相當(dāng)于是 react 的 state,有 view 的 state 和 plugin 的局部 state 之分。 如上面的 schema 就定義在其上: state.schema。ProseMirror 使用一個單獨的大對象來保持對編輯器所有 state 的引用(基本上來說,需要創(chuàng)建一個與當(dāng)前編輯器相同的編輯器)

Transaction
繼承自Transform,不僅能追蹤對文檔進行修改的一組操作,還能追蹤state的其他變化,例如選區(qū)更新等。每次更新都會產(chǎn)生一個新的state.transactions(通過state.tr來創(chuàng)建一個transaction實例),描述當(dāng)前state被應(yīng)用的變化,這些變化用來應(yīng)用當(dāng)前state來創(chuàng)建一個更新之后的state,然后這個新的state被用來更新view。
此處的
state指的是EditorState,描述編輯器的狀態(tài),包含了文檔的內(nèi)容、選區(qū)、當(dāng)前的節(jié)點和標(biāo)記集合等信息。每次編輯器發(fā)生改變時,都會生成一個新的EditorState。
View
ProseMirror編輯器的視圖層,負(fù)責(zé)渲染文檔內(nèi)容和處理用戶的輸入事件。View 接受來自 EditorState 的更新并將其渲染到屏幕上。同時,它也負(fù)責(zé)處理來自用戶的輸入事件,如鍵盤輸入、鼠標(biāo)點擊等。其中state就是其上的一個屬性:view.state
新建編輯器第一步就是new一個EditorVIew
Plugin
ProseMirror 中的插件,用于擴展編輯器的功能,例如點擊/粘貼/撤銷等。每個插件都是一個包含了一組方法的對象,這些方法可以監(jiān)聽編輯器的事件、修改事務(wù)、渲染視圖等等。每個插件都包含一個key屬性,如prosemirror-tables設(shè)置key為tableColumnResizing,通過這個key就可以訪問插件的配置和狀態(tài),而無需訪問插件實例對象。
const pluginState = columnResizingPluginKey.getState(state);
Commands
表示Command函數(shù)集合,每個command函數(shù)定義一些觸發(fā)事件來執(zhí)行各種操作。
Decorations
表示節(jié)點的外觀和行為的對象。它可以用于添加樣式、標(biāo)記、工具提示等效果,以及處理點擊、懸停、拖拽等事件。Decoration 通常是在渲染視圖時應(yīng)用到節(jié)點上的,但也可以在其他情況下使用,如在協(xié)同編輯時標(biāo)記其他用戶的光標(biāo)位置。
用于繪制document view,通過decorations屬性的返回值來創(chuàng)建,包含三種類型
- Node decorations:增加樣式或其他
DOM屬性到單個node的DOM上,如選中表格時增加的類名 - Widget decorations:在給定位置插入
DOM node,并不是實際文檔的一部分,如表格拖拽時增加的基線 - Inline decoration:在給定的
range中的行內(nèi)楊素插入樣式或?qū)傩?,類似?Node decorations,僅針對行內(nèi)元素
prosemirror 為了快速繪制這些類型,通過 decorationSet.create 靜態(tài)方法來創(chuàng)建
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解析位置信息返回的對象,包含了一些位置相關(guān)的信息。它會告訴我們當(dāng)前position的父級node是什么,它在父級node中的偏移量(parentOffset)是多少以及其他信息。
const $cell = doc.resolve(cell);
// 從根節(jié)點開始,父級點的深度,如果直接指向根節(jié)點則為0,如果指定一個頂級節(jié)點,則為1
$cell.deth;
// 該位置相對于父節(jié)點的偏移量
$cell.parentOffset;
// 相當(dāng)于$cell.parent() 獲取父級節(jié)點,$cell.node(-2)獲取父級的父級,以此類推
$cell.node(-1);
// 獲取父節(jié)點的開始位置,相對于doc根節(jié)點的位置,一般用來定位
$cell.start(-1);
Selection
表示當(dāng)前選中內(nèi)容,prosemirror中默認(rèn)定義兩種類型的選區(qū)對象:
- TextSelection:文本選區(qū),同時也可以表示正常的光標(biāo)(即未選擇任何文本時,此時
anchor = head),包含$anchor選區(qū)固定的一側(cè),通常是左側(cè),$head選區(qū)移動的一側(cè),通常是右側(cè) - NodeSelection:節(jié)點選區(qū),表示一個節(jié)點被選擇
也可以通過繼承Selection父類來實現(xiàn)自定義的選區(qū)類型,如CellSelection
// 獲取當(dāng)前選區(qū)
const sel = state.selection;
// 使用TextSelection創(chuàng)建文本選區(qū)
const selection = new TextSelection($textAnchor, $textHead);
// 使用NodeSelection創(chuàng)建節(jié)點選區(qū)
const selection = new NodeSelection($pos);
// 使用AllSelection創(chuàng)建覆蓋整個文檔的選區(qū) 可以作為cmd + a的操作
const selection = new AllSelection(doc);
// 用new之后的選區(qū),更新當(dāng)前 transaction 的選區(qū)
state.tr.setSelection(selection);
// 從指定選區(qū)獲取符合條件的父節(jié)點
findParentNode(
(node) =>
node.type.spec.tableRole && node.type.spec.tableRole.includes('cell'),
)(selection);
Slice
-
slice of document稱為文檔片段,主要處理復(fù)制粘貼和拖拽之類的操作 - 兩個
position之間的內(nèi)容就是一個文檔片段
源碼目錄
├── 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選區(qū)對象,繼承自Selection
- drawCellSelection:用于當(dāng)跨單元格選擇時,繪制選區(qū),會添加到
tableEditing的decorations為每個選中節(jié)點增加class類selectedCell,tableEditing最后會注冊為Editor的插件使用
columnresizing.ts
定義columnResizing插件,用于實現(xiàn)列拖拽功能,大致思路如下:
-
插件初始化時,通過以下代為插件添加
nodeViews,通過實例化TableView為表格節(jié)點自定義一套渲染邏輯,在初始化的時候為DOM節(jié)點添加了colgroup,然后調(diào)用updateColumnWidth生成每列對應(yīng)的col,有了col之后,我們在調(diào)整列寬的時候就可以通過改變col的width屬性實時的去改變列寬了。plugin.spec!.props!.nodeViews![tableNodeTypes(state.schema).table.name] = ( node, view, ) => new View(node, cellMinWidth, view); -
通過設(shè)置插件的
props傳入attribute(控制何時添加類resize-cursor)、handleDOMEvents(定義mousemove、mouseleave和mousedown事件)和decorations(調(diào)用handleDecorations方法,在鼠標(biāo)移動到列上時,通過Decoration.widget來繪制所需要的DOM)- doc.resolve(cell):
resolve解析文檔中給定的位置,返回此位置的上下文信息 - $cell.node(-1): 獲取給定級別的祖先節(jié)點
- $cell.start(-1): 獲取給定級別節(jié)點到起點的(絕對)位置
- TableMap.get(table): 獲取當(dāng)前表格數(shù)據(jù),包含
width列數(shù)、height行數(shù)、map行pos列pos形成的數(shù)組 - 循環(huán)
map.height,為當(dāng)前列的每一個td上創(chuàng)建一個div
- doc.resolve(cell):
handleMouseMove當(dāng)鼠標(biāo)移動時,修改pluginState從而使得decorations重新繪制DOM-
handleMouseDown當(dāng)鼠標(biāo)按下時,獲取當(dāng)前位置信息和列寬,并記錄在pluginState此方法中重新定義
mouseup和mousemove事件move:移動的同時從
draggedWidth獲取移動寬度,調(diào)用updateColumnsOnResize實時更新colgroup中的col的width屬性,從而改變每列寬度-
finish:當(dāng)移動完成后調(diào)用
updateColumnWidth方法重置當(dāng)前列的attrs屬性,并將pluginState置為初始狀態(tài)// 用來改變給定 position node 的類型或者屬性 tr.setNodeMarkup(start + pos, null, { ...attrs, colwidth: colwidth });
handleMouseLeave當(dāng)鼠標(biāo)離開時,恢復(fù)pluginState為初始狀態(tài),完成列拖拽
commands.ts
定義操作表格的一系列方法
-
selectedRect:獲取表格中的選區(qū),并返回選區(qū)信息、表格起始偏移量、表格信息(
TableMap.get(table)的值)和當(dāng)前表格,這個方法很有用,能拿到當(dāng)前表格中的所有信息table-info.jpg
- 剩下的方法都是需要用到的功能函數(shù),像
addColumn、addRow等
copypaste.ts
用于處理將單元格內(nèi)容粘貼到表格中、或?qū)⑷魏蝺?nèi)容粘貼到單元格選擇中,如用選擇內(nèi)容替換單元格塊。
當(dāng)在單元格中cmd + v觸發(fā)粘貼時,步驟為:
調(diào)用
input.ts中的handlePaste方法,根據(jù)傳入的文檔片段去做相應(yīng)處理-
調(diào)用
pastedCells,從文檔片段中獲取單元格的矩形區(qū)域,如果文檔片段的外部節(jié)點不是表格單元格或行,則返回null,如果是的話會根據(jù)當(dāng)前slice傳入ensureRectangular去生成新的一組單元格// 判斷是否為單元格或行,主要通過schema中定義的tableRole來判斷 // 行 first.type.spec.tableRole === 'row'; // 單元格 first.type.spec.tableRole === 'cell'; first.type.spec.tableRole === 'header_cell'; -
判斷當(dāng)前選區(qū)是否為
CellSelection,即是否選中一個或多個單元格的情況,會調(diào)用clipCells方法根據(jù)生成的cells生成表格新的一組單元格,通過insertCells插入原表格指定位置- insertCell:將給定的一組單元格(由
pastedCells返回)插入表格中rect指向的位置 - growTable:
isolateHorizontal和isolateVertical主要是為了確保被插入的表格足夠大,足夠容得下插入的單元格
- insertCell:將給定的一組單元格(由
如果當(dāng)前選區(qū)不是
CellSelection,但是pastedCells生成了新的cells,即復(fù)制的是表格單元格,則同樣使用insertCells插入不滿足上面兩個條件時,返回
false,即不用處理,按瀏覽器默認(rèn)行為處理
fixtables.ts
定義了tiptap中的fixTables命令,用于檢查文檔中的所有表格并在必要時修復(fù)。通過代碼可以看到fixTables就是遍歷state.doc的所有子節(jié)點,如果是table的話就調(diào)用fixTable。而fixTable修復(fù)表格主要是根據(jù)表格是否存在TableMap.get(table).problems來做處理,problems包含四種類型
- collision:直譯為“碰撞”,我理解就是單元格相互擠壓,處理方式是通過
removeColSpan處理掉對應(yīng)的單元格 - missing:直譯為”丟失“,處理方式是為丟失的單元格添加必要的單元格
- overlong_rowspan:直譯為“過長的 rowspan”,處理方式是修改對應(yīng)單元格的
rowspan - colwidth mismatch:直譯為“寬度不匹配”,處理方式是修改對應(yīng)單元格的
colwidth
因為目前我沒遇到過這些錯誤,所以對這些名詞的理解還不是很清晰。
index.ts
定義插件tableEditing,用于處理單元格選擇的繪制、以及創(chuàng)建和使用此類選擇的基本用戶交互。這個插件需要放在所有插件數(shù)組的末尾,因為它處理表格中的鼠標(biāo)事件相當(dāng)廣泛。而其他插件,比如列寬拖動columnResizing插件,需要首先執(zhí)行更具體的行為。
插件的props上定義了以下事件處理函數(shù),這些事件處理函數(shù)如果返回true,說明它們處理了相應(yīng)的事件,如果返回false則還是觸發(fā)瀏覽器對應(yīng)的事件
- handleDOMEvents:優(yōu)先級最高,會先于其他處理任何發(fā)生在可編輯
DOM元素上的事件之前調(diào)用,這里注冊了mousedown函數(shù),調(diào)用input.js中的handleMouseDown事件,處理鼠標(biāo)按下事件 - handleTripleClick:三次單擊編輯器時調(diào)用,這里會調(diào)用
handleTripleClick函數(shù),當(dāng)三次單擊的時候選中當(dāng)前單元格 - handleKeyDown:當(dāng)編輯器收到
keydown事件時調(diào)用,這里會調(diào)用handleKeyDown函數(shù),綁定一些操作表格的快捷鍵 - handlePaste:用于覆蓋粘貼行為,
slice是編輯器解析出來的粘貼內(nèi)容,這里會調(diào)用handlePaste函數(shù),上面已經(jīng)說過,就不再重復(fù)
input.ts
定義了一些功能函數(shù),用于鏈接用戶輸入與table相關(guān)功能
schema.ts
- 定義
tables的node types,分別為table、table_header、table_cell和table_row節(jié)點 -
tableNodeTypes(schema)函數(shù)接受schema,返回上述定義的node types,可以用來判斷傳入的schema是否為table節(jié)點
tablemap.ts
定義 TableMap 類,可以參考prosemirror-tables關(guān)于class TableMap的說明,或中文翻譯。這里為了性能考慮,做了緩存處理。如果緩存中不存在對應(yīng)表格的tableMap時,會通過computeMap重新獲取tableMap,并放入緩存中。
tableview.ts
- 此處定義的
TableView繼承自NodeView,一般來說自定義nodeView都是為了更細粒度的控制節(jié)點在編輯器中的表現(xiàn)樣式,如此處用于控制表格列拖拽時的樣式和行為 -
上面已經(jīng)提到了,會提供給插件
columnresizing的NodeViews使用,所以要是不用實現(xiàn)列拖拽功能時,這個文件也就沒什么用了
util.ts
定義一些用于處理表格的各種輔助函數(shù)
- cellAround:根據(jù)傳入的位置返回當(dāng)前單元格的位置信息
- cellWrapping:根據(jù)傳入的位置返回當(dāng)前單元
- isInTable:傳入
state判斷當(dāng)前選區(qū)是否在表格中 - selectionCell:傳入
state返回當(dāng)前選區(qū)的位置信息 - pointsAtCell:根據(jù)傳入的位置判斷是否在單元格內(nèi),返回
true或false - moveCellForward:獲取當(dāng)前單元格的前一個單元格位置信息
- inSameTable:判斷當(dāng)前選區(qū)是否屬于同一個表格
- findCell:找到給定位置的單元格的尺寸
- colCount:調(diào)用
TableMap的colCount方法,返回當(dāng)前單元格的列數(shù) - nextCell:根據(jù)傳入的位置,在給定方向上查找下一個單元格
- removeColSpan:為指定單元格刪除
colspan - addColSpan:為指定單元格添加
colspan,根據(jù)傳入的n來設(shè)定 - columnIsHeader:判斷當(dāng)前單元格是否為
header
