為什么寫這篇文章
公司使用tiptap富文本編輯器击蹲,在tiptap
的官網(wǎng)有這么一段話Tiptap is a headless wrapper around [ProseMirror](https://prosemirror.net/)
槽奕,這里的headless wrapper
意思是“無頭編輯器”夸浅,指的是不提供任何UI
樣式,完全自由的定制任何想要的UI
溶弟,特別適合二次開發(fā)。
而tiptap
是對prosemirror的封裝,在prosemirror
的基礎上提供了更友好的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é)點類型和它們的屬性,例如段落柔逼、標題蒋譬、鏈接、圖片等等愉适。Schema
是編輯器的模型層犯助,可以通過其 API
創(chuàng)建、操作和驗證文檔中的節(jié)點维咸。每個document
都有一個與之相關的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);
// 當前節(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é)點可以添加加粗、斜體耸别、下劃線等樣式健芭,也可以添加標簽、鏈接等屬性秀姐。Mark
本身沒有節(jié)點結(jié)構(gòu)慈迈,只是對一個節(jié)點的文本內(nèi)容進行修飾。Marks
通過Schema
創(chuàng)建省有,用于控制哪些marks
存在于哪些節(jié)點以及用于哪些attributes
痒留。
State
Prosemirror
的數(shù)據(jù)結(jié)構(gòu)對象,相當于是 react
的 state
蠢沿,有 view
的 state
和 plugin
的局部 state
之分狭瞎。 如上面的 schema
就定義在其上: state.schema
。ProseMirror
使用一個單獨的大對象來保持對編輯器所有 state
的引用(基本上來說搏予,需要創(chuàng)建一個與當前編輯器相同的編輯器)
Transaction
繼承自Transform,不僅能追蹤對文檔進行修改的一組操作弧轧,還能追蹤state
的其他變化雪侥,例如選區(qū)更新等。每次更新都會產(chǎn)生一個新的state.transactions
(通過state.tr
來創(chuàng)建一個transaction
實例)精绎,描述當前state
被應用的變化速缨,這些變化用來應用當前state
來創(chuàng)建一個更新之后的state
,然后這個新的state
被用來更新view
代乃。
此處的
state
指的是EditorState
旬牲,描述編輯器的狀態(tài),包含了文檔的內(nèi)容搁吓、選區(qū)原茅、當前的節(jié)點和標記集合等信息。每次編輯器發(fā)生改變時堕仔,都會生成一個新的EditorState
擂橘。
View
ProseMirror
編輯器的視圖層,負責渲染文檔內(nèi)容和處理用戶的輸入事件摩骨。View
接受來自 EditorState
的更新并將其渲染到屏幕上通贞。同時朗若,它也負責處理來自用戶的輸入事件,如鍵盤輸入昌罩、鼠標點擊等哭懈。其中state
就是其上的一個屬性:view.state
新建編輯器第一步就是new
一個EditorVIew
Plugin
ProseMirror
中的插件,用于擴展編輯器的功能茎用,例如點擊/粘貼/撤銷等遣总。每個插件都是一個包含了一組方法的對象,這些方法可以監(jiān)聽編輯器的事件绘搞、修改事務彤避、渲染視圖等等。每個插件都包含一個key
屬性夯辖,如prosemirror-tables
設置key
為tableColumnResizing
琉预,通過這個key
就可以訪問插件的配置和狀態(tài),而無需訪問插件實例對象蒿褂。
const pluginState = columnResizingPluginKey.getState(state);
Commands
表示Command
函數(shù)集合圆米,每個command
函數(shù)定義一些觸發(fā)事件來執(zhí)行各種操作。
Decorations
表示節(jié)點的外觀和行為的對象啄栓。它可以用于添加樣式娄帖、標記、工具提示等效果昙楚,以及處理點擊近速、懸停、拖拽等事件堪旧。Decoration
通常是在渲染視圖時應用到節(jié)點上的削葱,但也可以在其他情況下使用,如在協(xié)同編輯時標記其他用戶的光標位置淳梦。
用于繪制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
解析位置信息返回的對象蜘醋,包含了一些位置相關的信息挂捅。它會告訴我們當前position
的父級node
是什么,它在父級node
中的偏移量(parentOffset
)是多少以及其他信息。
const $cell = doc.resolve(cell);
// 從根節(jié)點開始闲先,父級點的深度状土,如果直接指向根節(jié)點則為0,如果指定一個頂級節(jié)點伺糠,則為1
$cell.deth;
// 該位置相對于父節(jié)點的偏移量
$cell.parentOffset;
// 相當于$cell.parent() 獲取父級節(jié)點蒙谓,$cell.node(-2)獲取父級的父級,以此類推
$cell.node(-1);
// 獲取父節(jié)點的開始位置训桶,相對于doc根節(jié)點的位置累驮,一般用來定位
$cell.start(-1);
Selection
表示當前選中內(nèi)容,prosemirror
中默認定義兩種類型的選區(qū)對象:
- TextSelection:文本選區(qū)舵揭,同時也可以表示正常的光標(即未選擇任何文本時谤专,此時
anchor = head
),包含$anchor
選區(qū)固定的一側(cè)午绳,通常是左側(cè)置侍,$head
選區(qū)移動的一側(cè),通常是右側(cè) - NodeSelection:節(jié)點選區(qū)拦焚,表示一個節(jié)點被選擇
也可以通過繼承Selection
父類來實現(xiàn)自定義的選區(qū)類型蜡坊,如CellSelection
// 獲取當前選區(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ū),更新當前 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
稱為文檔片段
赎败,主要處理復制粘貼和拖拽之類的操作 - 兩個
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:用于當跨單元格選擇時,繪制選區(qū)僵刮,會添加到
tableEditing
的decorations
為每個選中節(jié)點增加class
類selectedCell
据忘,tableEditing
最后會注冊為Editor
的插件使用
columnresizing.ts
定義columnResizing
插件,用于實現(xiàn)列拖拽功能搞糕,大致思路如下:
-
插件初始化時勇吊,通過以下代為插件添加
nodeViews
,通過實例化TableView
為表格節(jié)點自定義一套渲染邏輯寞宫,在初始化的時候為DOM
節(jié)點添加了colgroup
,然后調(diào)用updateColumnWidth
生成每列對應的col拉鹃,有了col
之后辈赋,我們在調(diào)整列寬的時候就可以通過改變col
的width
屬性實時的去改變列寬了。plugin.spec!.props!.nodeViews![tableNodeTypes(state.schema).table.name] = ( node, view, ) => new View(node, cellMinWidth, view);
-
通過設置插件的
props
傳入attribute
(控制何時添加類resize-cursor
)膏燕、handleDOMEvents
(定義mousemove
钥屈、mouseleave
和mousedown
事件)和decorations
(調(diào)用handleDecorations
方法,在鼠標移動到列上時坝辫,通過Decoration.widget
來繪制所需要的DOM
)- doc.resolve(cell):
resolve
解析文檔中給定的位置篷就,返回此位置的上下文信息 - $cell.node(-1): 獲取給定級別的祖先節(jié)點
- $cell.start(-1): 獲取給定級別節(jié)點到起點的(絕對)位置
- TableMap.get(table): 獲取當前表格數(shù)據(jù),包含
width
列數(shù)近忙、height
行數(shù)竭业、map
行pos
列pos
形成的數(shù)組 - 循環(huán)
map.height
智润,為當前列的每一個td
上創(chuàng)建一個div
- doc.resolve(cell):
handleMouseMove
當鼠標移動時,修改pluginState
從而使得decorations
重新繪制DOM
-
handleMouseDown
當鼠標按下時未辆,獲取當前位置信息和列寬窟绷,并記錄在pluginState
此方法中重新定義
mouseup
和mousemove
事件move:移動的同時從
draggedWidth
獲取移動寬度,調(diào)用updateColumnsOnResize
實時更新colgroup
中的col
的width
屬性咐柜,從而改變每列寬度-
finish:當移動完成后調(diào)用
updateColumnWidth
方法重置當前列的attrs
屬性兼蜈,并將pluginState
置為初始狀態(tài)// 用來改變給定 position node 的類型或者屬性 tr.setNodeMarkup(start + pos, null, { ...attrs, colwidth: colwidth });
handleMouseLeave
當鼠標離開時,恢復pluginState
為初始狀態(tài)拙友,完成列拖拽
commands.ts
定義操作表格的一系列方法
-
selectedRect:獲取表格中的選區(qū)为狸,并返回選區(qū)信息、表格起始偏移量遗契、表格信息(
TableMap.get(table)
的值)和當前表格辐棒,這個方法很有用,能拿到當前表格中的所有信息table-info.jpg
- 剩下的方法都是需要用到的功能函數(shù)姊途,像
addColumn
涉瘾、addRow
等
copypaste.ts
用于處理將單元格內(nèi)容粘貼到表格中、或?qū)⑷魏蝺?nèi)容粘貼到單元格選擇中捷兰,如用選擇內(nèi)容替換單元格塊立叛。
當在單元格中cmd + v
觸發(fā)粘貼時,步驟為:
調(diào)用
input.ts
中的handlePaste
方法贡茅,根據(jù)傳入的文檔片段
去做相應處理-
調(diào)用
pastedCells
秘蛇,從文檔片段
中獲取單元格的矩形區(qū)域,如果文檔片段
的外部節(jié)點不是表格單元格或行顶考,則返回null
赁还,如果是的話會根據(jù)當前slice
傳入ensureRectangular
去生成新的一組單元格// 判斷是否為單元格或行,主要通過schema中定義的tableRole來判斷 // 行 first.type.spec.tableRole === 'row'; // 單元格 first.type.spec.tableRole === 'cell'; first.type.spec.tableRole === 'header_cell';
-
判斷當前選區(qū)是否為
CellSelection
驹沿,即是否選中一個或多個單元格的情況艘策,會調(diào)用clipCells
方法根據(jù)生成的cells
生成表格新的一組單元格,通過insertCells
插入原表格指定位置- insertCell:將給定的一組單元格(由
pastedCells
返回)插入表格中rect
指向的位置 - growTable:
isolateHorizontal
和isolateVertical
主要是為了確保被插入的表格足夠大渊季,足夠容得下插入的單元格
- insertCell:將給定的一組單元格(由
如果當前選區(qū)不是
CellSelection
朋蔫,但是pastedCells
生成了新的cells
,即復制的是表格單元格却汉,則同樣使用insertCells
插入不滿足上面兩個條件時驯妄,返回
false
,即不用處理合砂,按瀏覽器默認行為處理
fixtables.ts
定義了tiptap
中的fixTables
命令青扔,用于檢查文檔中的所有表格并在必要時修復。通過代碼可以看到fixTables
就是遍歷state.doc
的所有子節(jié)點,如果是table
的話就調(diào)用fixTable
微猖。而fixTable
修復表格主要是根據(jù)表格是否存在TableMap.get(table).problems
來做處理谈息,problems
包含四種類型
- collision:直譯為“碰撞”,我理解就是單元格相互擠壓励两,處理方式是通過
removeColSpan
處理掉對應的單元格 - missing:直譯為”丟失“黎茎,處理方式是為丟失的單元格添加必要的單元格
- overlong_rowspan:直譯為“過長的 rowspan”,處理方式是修改對應單元格的
rowspan
- colwidth mismatch:直譯為“寬度不匹配”当悔,處理方式是修改對應單元格的
colwidth
因為目前我沒遇到過這些錯誤傅瞻,所以對這些名詞的理解還不是很清晰。
index.ts
定義插件tableEditing
盲憎,用于處理單元格選擇的繪制嗅骄、以及創(chuàng)建和使用此類選擇的基本用戶交互。這個插件需要放在所有插件數(shù)組的末尾饼疙,因為它處理表格中的鼠標事件相當廣泛溺森。而其他插件,比如列寬拖動columnResizing
插件窑眯,需要首先執(zhí)行更具體的行為屏积。
插件的props
上定義了以下事件處理函數(shù),這些事件處理函數(shù)如果返回true
磅甩,說明它們處理了相應的事件炊林,如果返回false
則還是觸發(fā)瀏覽器對應的事件
- handleDOMEvents:優(yōu)先級最高,會先于其他處理任何發(fā)生在可編輯
DOM
元素上的事件之前調(diào)用卷要,這里注冊了mousedown
函數(shù)渣聚,調(diào)用input.js
中的handleMouseDown
事件,處理鼠標按下事件 - handleTripleClick:三次單擊編輯器時調(diào)用僧叉,這里會調(diào)用
handleTripleClick
函數(shù)奕枝,當三次單擊的時候選中當前單元格 - handleKeyDown:當編輯器收到
keydown
事件時調(diào)用,這里會調(diào)用handleKeyDown
函數(shù)瓶堕,綁定一些操作表格的快捷鍵 - handlePaste:用于覆蓋粘貼行為隘道,
slice
是編輯器解析出來的粘貼內(nèi)容,這里會調(diào)用handlePaste
函數(shù)郎笆,上面已經(jīng)說過谭梗,就不再重復
input.ts
定義了一些功能函數(shù),用于鏈接用戶輸入與table
相關功能
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關于class TableMap
的說明竞思,或中文翻譯表谊。這里為了性能考慮,做了緩存處理盖喷。如果緩存中不存在對應表格的tableMap
時爆办,會通過computeMap
重新獲取tableMap
,并放入緩存中课梳。
tableview.ts
- 此處定義的
TableView
繼承自NodeView距辆,一般來說自定義nodeView
都是為了更細粒度的控制節(jié)點在編輯器中的表現(xiàn)樣式,如此處用于控制表格列拖拽時的樣式和行為 -
上面已經(jīng)提到了暮刃,會提供給插件
columnresizing
的NodeViews
使用跨算,所以要是不用實現(xiàn)列拖拽功能時,這個文件也就沒什么用了
util.ts
定義一些用于處理表格的各種輔助函數(shù)
- cellAround:根據(jù)傳入的位置返回當前單元格的位置信息
- cellWrapping:根據(jù)傳入的位置返回當前單元
- isInTable:傳入
state
判斷當前選區(qū)是否在表格中 - selectionCell:傳入
state
返回當前選區(qū)的位置信息 - pointsAtCell:根據(jù)傳入的位置判斷是否在單元格內(nèi)椭懊,返回
true
或false
- moveCellForward:獲取當前單元格的前一個單元格位置信息
- inSameTable:判斷當前選區(qū)是否屬于同一個表格
- findCell:找到給定位置的單元格的尺寸
- colCount:調(diào)用
TableMap
的colCount
方法诸蚕,返回當前單元格的列數(shù) - nextCell:根據(jù)傳入的位置,在給定方向上查找下一個單元格
- removeColSpan:為指定單元格刪除
colspan
- addColSpan:為指定單元格添加
colspan
氧猬,根據(jù)傳入的n
來設定 - columnIsHeader:判斷當前單元格是否為
header