一、了解prosemirror
prosemirorr本身并不是一個開箱即用的編輯器,而是通過一系列子產品配合搭建起來的一個富文本編輯器,核心的子產品主要有以下四個
- prosemirror-model :負責prosemirror的内容結構。定義了編輯器的文檔模型,用于描述編輯器内容的資料結構,并實作了對編輯器内容的一原子的操作。實作了一套索引系統,用于處理位置資訊。同時提供了從DOM -> ProsemirrNode的Parser以及反向的Serilizer。
- prosemirror-transfrom :負責對編輯内容的修改操作。文檔的改動由step實作。transform基于step封裝了一系列對内容進行操作的API。step執行了對文檔内容的操作,通過StepMap記錄了改動的資訊,可用于追溯位置的變化。
- prosemirror-state :負責描述整個編輯器的狀态。包括文檔内容,選區資訊,所有的節點類型以及并基于Transform實作了Transaction,Transaction主要增加了對選區的管理以及狀态記錄。同時提供了強大的插件系統,實作使用者對狀态更新流程幹預的可能性。
- prosemirror-view :負責視圖的渲染,實作了從state到視圖的渲染。監聽或者劫持使用者的操作并修正,并建立相應的對state的改動,最終比對dom與state的決定最終渲染結果。
這四個子產品實作了prosemirror的核心功能,但諸如什麼按鍵有什麼行為的規定,都需要由使用方自己實作,好在官方提供了prosemirror-commands、prosemirror-history、prosemirror-keymap等庫來幫助我們更友善地去實作一個編輯器。
二、節點定義
prosemirror中渲染出的節點必須在Schema中有相應的定義,而Scheme中的可以定義的節點分為兩種,
node
和
mark
,
Node
node即為正常意義上的節點,分為塊級或者是行内。我們可以定義一個節點在prosemirror中表現:
paragraph: {
group: "block",
content: "text*",
parseDOM: [{tag: 'p'}],
toDOM(node) { return ['p', 0] }
}
如上述定義了'paragraph'節點:group表示其為block組;content表示其可以包含任意文字;parseDOM表示其解析
<p>xxx</p>
;toDOM表示其渲染為
<p>xxx</p>
,子節點從0的位置開始插入,更多的配置可以檢視NodeSpec,content是一個類正則字元串,可以使用node的名稱或者是group名。
Mark
Mark是一種比較特殊的Schema類型,他的表現不同于Node,Mark主要作用于行内節點,用于給行内元素增加樣式或者附加其他資訊,他不像Node會占據文檔位置,更像是一種對文檔描述的補充。
bold: {
parseDOM: [{tag: 'strong'}],
toDOM(node) { return ['strong', 0]}
}
如上定義bold類型的Mark,解析以及渲染時候的DOM。更多定義參考:MarkSpec
Attributes
Node以及Mark都可以附加一些資訊,但需要在初始化是定義好支援的屬性
paragraph: {
group: "block",
content: "text*",
attrs: {
align: {
default: 'left'
}
}
}
如上定義了一個attrs中包含align的paragraph節點,可以用作單純的資訊存儲,也可以配合toDOM修改渲染輸出。
三、文檔結構
内容
prosemiror的内容被描述成一棵樹,他的特征屬性(isBlock、isInline等)由我們所定義的節點特征來确定

Node的content由一個Fragment表示,Fragment的content則是由Node組成的數組,prosemirror通過這樣嵌套形成的樹形結構來描述一個文檔。
索引
prosemirror實作了一套索引系統用于表示文檔中某個位置,主要分為兩種:
第一種是比較像是通路DOM,利用content的數組的特性去通路節點,把文檔當成一棵樹去周遊。
第二種是強大的索引系統,把文檔打平後的索引,prosemirror文檔中的任何位置,都可以用一個唯一的整數表示。
對于正常的DOM,它是樹形的結構
在prosemirror的索引系統中,把這棵樹打平了,規定:
- 整個文檔的第一個節點前的位置為零。
- 進入或離開不是葉節點(即可以包含其他内容)的節點視為一個token。是以,如果文檔以一個段落開頭,則該段落的内容開頭算作位置1。
- 文本節點中的每個内容都使做是一個token。
- 葉子節點(不能包含其他節點内容)也視作是一個token。
按照這個規則,想象我們有一個指針,從開頭0開始進入一個節點時索引加1,每越過一個文本内容加1,退出一個節點時也加1,通過這樣的形式,就可以描述文檔中的每個位置。
如上所示,nodeSize可以了解為我們的指針從進入到退出節點時走過的距離,是以對應
<p>
的nodeSize為5,
<blockquote>
的nodeSize為8,通過這樣的方式,我們就可以描述每個節點的位置以及大小。prosemirror中的很多操作都需要使用到這些資訊。
四、如何修改文檔
了解了上面的内容之後,我們對prosemirror的文檔結構有了一個大緻的認識,下一步我們來嘗試修改文檔的資料。我們來把官網的内容替換成Hello Prosemirror!。
分析
根據上面的分析,我們要做的可以是修改doc的content屬性或者是直接修改文字内容亦或是删除内容後再插入,我們選擇第一種方式來實作。
prosemirror中的資料更新實際上都是對state的修改,通過state提供的updateState的API接受一個新的state來更新state,編輯器執行個體view中幫我封裝好了這一步操作,對外暴露出來的API是dispatch。
上面我們說到修改文檔的操作是由
prosemirror-transform來實作的,而
prosemirror-state中的
tr屬性繼承了transform,state又是作為
prosemirror-view執行個體的一個屬性。
是以更新操作都可以通過view來實作,翻閱API文檔,看到replaceRangeWith這個API符合我們的需求,
from和to就是上文介紹到的索引數字,代表替換的位置,node即為新的節點,對目前的操作來說,替換的起點from為起始位置0,替換結束的位置為内容終點即為doc的content的大小,node則可以通過schema建立。
實作
根據上面的分析,實作節點替換的操作為:
const { dispatch, state } = view;
const { schema, tr, doc } = state;
const { paragraph } = schema.nodes;
const textNode = schema.text('Hello Prosemirror!');
const newParagraph = paragraph.create(undefined, textNode);
dispatch(tr.replaceRangeWith(0, doc.content.size, newParagraph));
這樣就實作了對内容的替換!
原理
是以從prosemirror的角度看,replaceRangeWith做了什麼操作呢?
上面說到transform對文檔的操作都是通過step去實作,是以這一步實際上建立了一個ReplaceStep去修改文檔。
step對文檔的修改不一定是成功的,結果由StepResult表示,如果失敗了會抛出一個TransformError,如果成功了,則會傳回新的文檔的内容,transform會把舊文檔内容儲存在docs屬性中,新的應用到doc屬性中,并把step儲存在steps屬性中,可以實作撤銷的操作。最後通過distpach更新到state。
是以,我們可以把state.tr可以看成是一個事務,每一個step可以看作是一次原子操作,通過dispatch送出事務并應用到state上生效,實作了對文檔的修改。
五、總結
通過上文,簡單的介紹prosemirror的一些概念,API使用以及文檔更新的原理,可以看到prosemirror通過對資料的抽象,可以把文檔的結構描述得很清晰。把對文檔的操作封裝得很好,隐藏了很多細節的東西,并提供了各種友善使用的API。
本文目前還隻是停留在對prosemirror粗淺的介紹,諸如文檔具體如何更新,視圖是怎麼去渲染的,使用者行為是怎麼捕獲并分析……還有很多内容值得研究,後續會有一系列文章來介紹它們。
歡迎指出錯誤或提出問題,互相交流,共同發展