編輯器一貫是前端領域的一個難點,一款成熟的編輯器,須要涉及許多方面的東西。html
到底有多少東西...這個能夠看看掘金上一位大哥在知乎上的回答前端
至於爲何要踩這個天坑,是公司想要一個所見即所得的markdown編輯器,不須要markdown源碼,要有用markdown語法同樣的輸入規則,最後還須要輸出markdown文檔做爲存儲,在次之上還須要一些制定的需求。這就要求這個選型應該是一個靈活,可配置模塊化編輯器框架,而不是一個開箱便可用
的一個應用
。node
在選型的時候,以前公司已經有人用prosemirror
進行一些特殊編輯器的開發(然而那位同事在我沒來以前就走了),同時考慮的還有slate.js
,上面那位大哥也有在掘金上發佈過一篇文章。那爲何不選擇slate.js
呢(另外還有個Draft.js
沒有去了解過)。緣由很簡單,就是由於咱們的技術棧是Vue
而不是React
。slate.js
依賴於React
做爲視圖層,做爲一個Vue
應用,仍是不想再專門引入一個React
來爲slate.js
服務。git
綜上的緣由,就踩上了這個天坑。雖然我沒有用過slate.js
,可是根據熱度以及在github上的star也好,活躍度也好,我以爲應該不會比slate.js
小,可是它能產出的編輯器,不會比slate.js
差。github
但正由於活躍度等緣由,你在谷歌或者百度上搜索,是沒有關於prosemirror
的任何中文資料
的,我一度認爲這個框架在國內就沒人用,直到有一天在discuss看到了上面說的那位大佬的頭像,我才知道原來國內仍是有人用的。理所固然的,也不會有對應的中文文檔,踩了坑也只能上discuss或者issue
搜索提問。但萬幸的是,做者很是熱心,幾乎每個問題都會回答你,就算是很是入門級的問題,這一點在開發上幫了我不少忙。算法
如下的內容,幾乎是官網的文檔,經過本身理解和簡化寫下來的,有興趣的能夠去官網瞭解更加詳細的內容。npm
若是你以爲prosemirror
很陌生,那你也許聽過大名鼎鼎的codemirror
。對,就是那個在瀏覽器上的代碼編輯器,兩個是同個做者,一位很是有實力的德國人Marijn。上面說到的slate
也是有些核心的概念例如schema
是來自於prosemirror
的。瀏覽器
prosemirror
不是一個大而全的框架,甚至於你去npm上搜索prosemirror
壓根沒有這個包。markdown
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的內容轉化爲編輯器的初始文本,做爲初始的編輯狀態。只可以作簡單的編輯,例如刪除、撤回、換行等。
咱們來看看上面那段代碼作了什麼事情。首先,預約了一個conetent
id的內容,這個在最後展現是不可見的,爲的是把已有的html文檔
先存在dom裏。緊接着,經過DOMParse
解析順着schema
(下面會說這是什麼)這個html文本
,得到一個Node
類型的對象,這個對象就能夠傳入doc屬性
做爲一個初始的文本數據渲染成編輯器的可編輯文本。
這裏的DOMParse
就是一個做爲把DOM渲染成Node
對象的一個解析器。除了DOMParse
,還有一個解析器就是MarkdownParser
,專門把markdown文檔轉化爲Node
類數據。
那麼有解析器,就有對應的序列器,調用EditorState.JSON()
能夠把當前狀態的doc
序列化成JSON格式,便於存儲。
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
類構成了Prosemirror文檔的節點樹,它的子節點也是Node
類。Node
類並不能直接被改變,是一個持久的數據結構,相似於React
中的state
,須要經過apply
一個transaction
類纔可以改變doc
的結構。而Node
的結構又很是像Virtual Dom
,都具備樹型和遞歸,經過實例解構來描述Dom,並且prosemirror
也有本身一套高效的更新算法來轉化Node
和Dom
Node
的屬性很是多,好比在文檔的位置、子節點的數量、節點大小、文本內容等等等等,在許多狀況下,這些屬性都爲實現某些特定的功能提供了很是大的幫助。
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
是鍵盤輸入規則的插件,history是歷史記錄的插件,這個略過。
到此爲止,核心內容就已經介紹完畢,固然,核心內容只能做爲對prosemirror
的一個淺顯認知,好讓咱們在後續的編輯器開發的時候,不會不明白它究竟是怎麼的一個運做原理。
如今缺乏的有一些輸入規則,有這些輸入規則,才能像寫markdown同樣實現WYSIWYN編輯器,還有頂部的操做欄等等。這些都是編輯器的一部分,不過由於不是核心庫,這裏就不講了。官方有一個example-setup
一個設置樣例,官方一樣推薦經過這個樣例來改形成符合咱們需求的設置
接下來,就讓咱們偷懶地實現一個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
。