為什麼選擇prosemirror
編輯器一向是前端領域的一個難點,一款成熟的編輯器,需要涉及許多方面的東西。
到底有多少東西...這個可以看看掘金上一位大哥在知乎上的回答
至於為什麼要踩這個天坑,是公司想要一個所見即所得的markdown編輯器,不需要markdown原始碼,要有用markdown語法一樣的輸入規則,最後還需要輸出markdown文件作為儲存,在次之上還需要一些制定的需求。這就要求這個選型應該是一個靈活,可配置模組化編輯器框架,而不是一個開箱即可用
的一個應用
。
在選型的時候,之前公司已經有人用prosemirror
進行一些特殊編輯器的開發(然而那位同事在我沒來之前就走了),同時考慮的還有slate.js
,上面那位大哥也有在掘金上釋出過一篇文章。那為什麼不選擇slate.js
呢(另外還有個Draft.js
沒有去了解過)。原因很簡單,就是因為我們的技術棧是Vue
而不是React
。slate.js
依賴於React
作為檢視層,作為一個Vue
應用,還是不想再專門引入一個React
來為slate.js
服務。
綜上的原因,就踩上了這個天坑。雖然我沒有用過slate.js
,但是根據熱度以及在github上的star也好,活躍度也好,我覺得應該不會比slate.js
小,但是它能產出的編輯器,不會比slate.js
差。
但正因為活躍度等原因,你在谷歌或者百度上搜尋,是沒有關於prosemirror
的任何中文資料
的,我一度認為這個框架在國內就沒人用,直到有一天在discuss看到了上面說的那位大佬的頭像,我才知道原來國內還是有人用的。理所當然的,也不會有對應的中文文件,踩了坑也只能上discuss或者issue
搜尋提問。但萬幸的是,作者非常熱心,幾乎每一個問題都會回答你,就算是非常入門級的問題,這一點在開發上幫了我很多忙。
以下的內容,幾乎是官網的文件,通過自己理解和簡化寫下來的,有興趣的可以去官網瞭解更加詳細的內容。
prosemirror簡介
如果你覺得prosemirror
很陌生,那你也許聽過大名鼎鼎的codemirror
。對,就是那個在瀏覽器上的程式碼編輯器,兩個是同個作者,一位非常有實力的德國人Marijn。上面說到的slate
也是有些核心的概念例如schema
是來自於prosemirror
的。
prosemirror
不是一個大而全的框架,甚至於你去npm上搜尋prosemirror
壓根沒有這個包。
prosemirror
由無數個小的模組組成,正如它官網上說的類似於樂高一樣堆疊成一個健壯編輯器
The core library is not an easy drop-in component—we are prioritizing modularity and customizeability over simplicity, with the hope that, in the future, people will distribute drop-in editors based on ProseMirror. As such, this is more of a lego set than a matchbox car.
它的核心庫有
-
prosemirror-model
:定義編輯器的文件模型,用來描述編輯器內容的資料結構 -
prosemirror-state
:提供描述編輯器整個狀態的資料結構,包括選擇,以及從一個狀態轉移到下一個狀態的事務處理系統。 -
prosemirror-view
:實現一個使用者介面元件,該元件在瀏覽器中將給定的編輯器狀態顯示為可編輯元素,並處理使用者與該元素的互動。 -
prosemirror-transform
:包含以可記錄和重放的方式修改文件的功能,這是state模組中事務的基礎,並使撤消歷史記錄和協作編輯成為可能。
看完這些描述是不是感覺很熟悉,一個非常像React
的一組核心庫。他們構成了整個編輯器的基礎。當然,除了核心庫,還需要各種各樣的庫來實現快捷鍵prosemirror-commands
、編輯歷史prosemirror-history
等等。
實現一個小編輯器
這是一個功能非常有限的,只有一些基本的按鍵(例如enter換行
、bacakspace刪除
)等,然後我們再加上一個ctrl-z
撤回和ctrl-y
重做。
一開始覺得是個小demo,就用了
parcel
打包,發現會報錯,第一次用parcel
,不知道是我問題還是parcel
問題。
import {EditorState} from "prosemirror-state"
import {EditorView} from "prosemirror-view"
// schama,校驗規則
import {schema} from "prosemirror-schema-basic"
// 歷史記錄以及撤回重做
import {undo, redo, history} from "prosemirror-history"
// 一個
import {keymap} from "prosemirror-keymap"
import {baseKeymap} from "prosemirror-commands"
//
let content = document.getElementById("content")
// 生成一個state
let state = EditorState.create({
doc: DOMParser.fromSchema(schema).parse(content),
schema,
plugins: [
history(),
keymap(baseKeymap),
keymap({"Mod-z": undo, "Mod-y": redo})
]
})
// 生成檢視
let view = new EditorView(document.getElementById('prosemirror'), {state})
複製程式碼
這段程式碼,把content的內容轉化為編輯器的初始文字,作為初始的編輯狀態。只能夠做簡單的編輯,例如刪除、撤回、換行等。
parser是什麼?
我們來看看上面那段程式碼做了什麼事情。首先,預定了一個conetent
id的內容,這個在最後展示是不可見的,為的是把已有的html文件
先存在dom裡。緊接著,通過DOMParse
解析順著schema
(下面會說這是什麼)這個html文字
,獲得一個Node
型別的物件,這個物件就可以傳入doc屬性
作為一個初始的文字資料渲染成編輯器的可編輯文字。
這裡的DOMParse
就是一個作為把DOM渲染成Node
物件的一個解析器。除了DOMParse
,還有一個解析器就是MarkdownParser
,專門把markdown文件轉化為Node
類資料。
那麼有解析器,就有對應的序列器,呼叫EditorState.JSON()
可以把當前狀態的doc
序列化成JSON格式,便於儲存。
schema是什麼?
schema
是一套描述文件和Dom之間的關聯的一套轉化規則,如何把DOm轉化為Node
或者說Node
轉化為Dom,這是個關鍵,下面是一個基本的標題的schema
// heading的schema
heading: {
// 可選的屬性
attrs: {level: {default: 1}},
// 節點內容的型別,是行還是塊
content: "inline*",
// 自身的型別,是行還是塊
group: "block",
// 解析Dom的規則以及屬性
parseDOM: [{tag: "h1", attrs: {level: 1}},
{tag: "h2", attrs: {level: 2}},
{tag: "h3", attrs: {level: 3}},
{tag: "h4", attrs: {level: 4}},
{tag: "h5", attrs: {level: 5}},
{tag: "h6", attrs: {level: 6}}],、
// 生成Dom的規則
toDOM(node) { return ["h" + node.attrs.level, 0] }
},
複製程式碼
這樣就是一個描述一個標題的文字規則,不過沒有這個文字規則,解析器或者序列器不知道如何去解析。任何一個在編輯器中出現的Dom以及任何一個需要轉化成Dom的節點型別,都需要有一個對應的schema
否則無法編譯。
schema
可以自行建立或者在現有的schema
上進行新增。一個健壯的schema
對每一個屬性的設定都有較高的要求,在這裡不舉例子了,免得帶偏,可以自行上官網學習。
Node是什麼?
Node
類構成了Prosemirror文件的節點樹,它的子節點也是Node
類。Node
類並不能直接被改變,是一個持久的資料結構,類似於React
中的state
,需要通過apply
一個transaction
類才能夠改變doc
的結構。而Node
的結構又非常像Virtual Dom
,都具有樹型和遞迴,通過例項解構來描述Dom,而且prosemirror
也有自己一套高效的更新演算法來轉化Node
和Dom
Node
的屬性非常多,比如在文件的位置、子節點的數量、節點大小、文字內容等等等等,在許多情況下,這些屬性都為實現某些特定的功能提供了非常大的幫助。
Transaction是什麼?
transaction
是一個描述編輯器狀態改變的一個資料型別。在Prosemirror
中,呼叫EditorView.updateState
可以更新整個編輯器的狀態,就算是敲打一個空格,都必須要通過state進行更新。那麼,如果每次都用DOMParse
建立新的Node
來形成新的state
,歷史記錄等東西必然不會保留,而且在Prosemirror
中,到真正呼叫EditorState.apply
的過程中,會經過很多的Plugins
(如果有的話)去加工這個transaction
,所以一定要經過EditorState.apply
去應用一個transaction
生成一個新的state
,接著呼叫,才可以真正改變整個編輯器的狀態,並儲存好整個的狀態,在編輯的時候也是如此。我們可以先看看一個例子
let view = new EditorView(document.body, {
state,
// 這是一個鉤子函式,最後應用transaction的函式
dispatchTransaction(transaction) {
console.log("Document size went from", transaction.before.content.size,
"to", transaction.doc.content.size)
// 應用transaction,並生成一個新的state
let newState = view.state.apply(transaction)
// 更新state
view.updateState(newState)
}
})
複製程式碼
dispatchTransaction
實際上是在呼叫EditorState.apply
前的最後一個方法,這裡也可以不呼叫dispatchTransaction
,預設進行了更新。在這裡的作用是,每次更新(不管是編輯還是插入刪除等操作)都會log一段文字,僅此而已。如果不進行apply和update的操作,將會報錯。可以通過Editor.tr
獲取實時的transaction
。
keymap、歷史記錄
keymap
是鍵盤輸入規則的外掛,history是歷史記錄的外掛,這個略過。
核心內容總結
到此為止,核心內容就已經介紹完畢,當然,核心內容只能作為對prosemirror
的一個淺顯認知,好讓我們在後續的編輯器開發的時候,不會不明白它到底是怎麼的一個運作原理。
現在缺少的有一些輸入規則,有這些輸入規則,才能像寫markdown一樣實現WYSIWYN編輯器,還有頂部的操作欄等等。這些都是編輯器的一部分,不過因為不是核心庫,這裡就不講了。官方有一個example-setup
一個設定樣例,官方同樣推薦通過這個樣例來改造成符合我們需求的設定
接下來,就讓我們偷懶地實現一個markdown的編輯器。例子同樣是來自於官網。
實現一個markdown編輯器
很簡單,只需要把parser換成defaultMarkdownParser
,plugins
用預設的設定就可以了,然後再用prosemirror-example-setup
的預設樣式,一個WYSIWYN編輯器就完成了。
class ProseMirrorView {
constructor(target, content) {
this.view = new EditorView(target, {
state: EditorState.create({
// 用預設的markdown parser解析markdown文件
doc: defaultMarkdownParser.parse(content),
// 設定樣例
plugins: exampleSetup({schema})
})
})
}
// 暴露兩個常用方法,便於呼叫
focus() { this.view.focus() }
destroy() { this.view.destroy() }
}
new ProseMirrorView(document.getElementById('prosemirror'), '# hello')
複製程式碼
當然這只是一個非常簡單的markdown編輯器,官方給出的defaultMarkdownParser
只是用的CommonMark
標準,很多的常用markdown語法都沒有。我們可以從中進行非常多的自定義。
defaultMarkdownParser的markdown解析器是用markdown-it的,原理是解析成token後,通過schema再進行轉化。所以如果想要擴充markdown,需要懂得markdown-it或者其他的markdown解析器。
總結
本篇文章簡略地介紹了prosemirror
的一些思想和核心內容,這只是涉及一些皮毛,並不是完全展現其魅力。在它的論壇上,有許多的開發者貢獻了許多令人拍案叫好的外掛或者成熟的編輯器,都非常值得去學習借鑑。希望能更加深入理解篇prosemirror
。