關於富文本編輯器,不少同窗沒用過也聽過了。是你們都不想去踩的坑。到底有多坑呢?javascript
我這裏摘了一部分一位大哥在知乎上的回答,若是有興趣,能夠去看看。 要讓一款編輯器達到商業級質量,從目前接觸到主要的例子來看,獨立開發時間太長:java
Quill
從 2012 年收到第一個 Issue 到 2016 年發佈 1.0 版本,已通過去了四年。Prosemirror
做者在 2015 年正式開源前籌款維護時已經開發了半年,而到發佈 1.0 版本時,已通過去了接近三年。上面這幾個單人主導的編輯器項目要達到穩定質量,時間是以年爲單位來計算的。考慮到目前互聯網「下週上線」的節奏,動輒幾年的時間是不划算的。因此在人力,時間合理性各方面的約束下,使用開源框架是最好的選擇。node
想要一款配置性強,模塊化的編輯器,這就決定了這不是一個開箱即用的應用,而Quill
集成了許多樣式和交互邏輯,已經算是一個應用了,有時一些制定需求不能徹底知足。Slate
是基於的React
視圖層的,咱們的技術棧是Vue
,就不作考慮了,之後有機會能夠研究一下,因此最後選擇了prosemirror
,但另外兩款依然是很強大值得去學習的編輯器框架。git
因爲prosemirror
目前使用搜索引擎能搜出來的中文資料幾乎沒有,遇到問題也只能去論壇
,issue
裏面搜,或者向做者提問。如下的內容是從官網,加上本身在使用過程當中對它的理解簡化出來的。但願看完後,能讓你對prosemirror
產生興趣,並從做者的設計思路中,學到東西,一塊兒分享。github
A toolkit for building rich-text editors on the webweb
prosemirror
的做者 Marijn 是 codemirror
編輯器和 acorn
解釋器的做者,前者已經在 Chrome
和 Firefox
自帶的調試工具裏使用了,後者則是 babel
的依賴。正則表達式
prosemirror
不是一個大而全的框架, 它是由無數個小的模塊組成,它就像樂高同樣是一個堆疊出來的編輯器。數組
它的核心庫有:瀏覽器
prosemirror-model
: 定義編輯器的文檔模型,用來描述編輯器內容的數據結構prosemirror-state
: 提供描述編輯器整個狀態的數據結構,包括selection
(選擇),以及從一個狀態到下一個狀態的transaction
(事務)prosemirror-view
: 實現一個在瀏覽器中將給定編輯器狀態顯示爲可編輯元素,而且處理用戶交互的用戶界面組件prosemirror-transform
: 包括以記錄和重放的方式修改文檔的功能,這是state
模塊中transaction
(事務)的基礎,而且它使得撤銷和協做編輯成爲可能。此外,prosemirror
還提供了許多的模塊,如prosemirror-commands
基本編輯命令,prosemirror-keymap
鍵綁定,prosemirror-history
歷史記錄,prosemirror-inputrules
輸入宏,prosemirror-collab
協做編輯,prosemirror-schema-basic
簡單文檔模式等。bash
如今你應該大概瞭解了它們各自的做用,它們是整個編輯器的基礎。
import { schema } from "prosemirror-schema-basic"
import { EditorState } from "prosemirror-state"
import { EditorView } from "prosemirror-view"
let state = EditorState.create({ schema })
let view = new EditorView(document.body, { state })
複製代碼
咱們來看看上面的代碼幹了什麼事,從第一行開始。prosemirror
要求指定一個文檔符合的模式。因此從prosemirror-schema-basic
引入了一個基本的schema
。那麼這個schema
是什麼呢?
由於prosemirror
定義了本身的數據結構來表示文檔內容。在prosemirror結構
與HTML的Dom結構
之間,須要一次解析與轉化,這二者間相互轉化的橋樑,就是咱們的schema
,因此要先了解一下prosemirror
的文檔結構。
prosemirror
的文檔是一個Node
,它包含零個或多個child Nodes
的Fragment(片斷)
。
有點相似瀏覽器DOM
的遞歸和樹形的結構。但它在存儲內聯內容方式上有所不同。
<p>This is <strong>strong text with <em>emphasis</em></strong></p>
複製代碼
在HTML
中,是這樣的樹結構:
p //"this is "
strong //"strong text with "
em //"emphasis"
複製代碼
在prosemirror
中,內聯內容被建模爲平面的序列,strong、em(Mark)
做爲paragraph(Node)
的附加數據:
"paragraph(Node)"
// "this is " | "strong text with" | "emphasis"
"strong(Mark)" "strong(Mark)", "em(Mark)"
複製代碼
prosemirror
的文檔的對象結構以下
Node:
type: NodeType //包含了Node的名字與屬性等
content: Fragment //包含多個Node
attrs: Object //自定義屬性,image能夠用來存儲src等。
marks: [Mark, Mark...] // 包含一組Mark實例的數組,例如em和strong
複製代碼
Mark:
type: MarkType //包含Mark的名字與屬性等
attrs: Object //自定義屬性
複製代碼
prosemirror
提供了兩種類型的索引
dom結構
類似,你能夠利用child
或者childCount
等方法直接訪問到子節點image
)也算一個標記例若有一個HTML
片斷爲
<p>One</p>
<blockquote><p>Two<img src="..."></p></blockquote>
複製代碼
則計數標記爲
0 1 2 3 4 5
<p> O n e </p>
5 6 7 8 9 10 11 12 13
<blockquote> <p> T w o <img> </p> </blockquote>
複製代碼
每一個節點都有一個nodeSize
屬性表示整個節點的大小。手動解析這些位置涉及到至關多的計數,prosemirror
爲咱們提供了Node.resolve
方法來解析這些位置,而且可以獲取關於這個位置更多的信息,例如父節點是什麼,與父節點的偏移量,父節點的祖先是什麼等一些其它信息。
瞭解了prosemirror
的數據結構,知道了schema
是兩種文檔間轉化的模式,回到剛纔的地方,咱們從prosemirror-schema-basic
中引入了一個基本的schema
,那麼這個基本的schema
長什麼樣呢?經過查看源碼最後一行
export const schema = new Schema({nodes, marks})
複製代碼
schema
是Schema
經過傳入的nodes
, marks
生成的實例。 而在實例以前的代碼,都是在定義nodes
和marks
,將代碼摺疊一下,發現nodes
是
{
doc: {...} // 頂級文檔
blockquote: {...} //<blockquote>
code_block: {...} //<pre>
hard_break: {...} //<br>
heading: {...} //<h1>..<h6>
horizontal_rule: {...} //<hr>
image: {...} //<img>
paragraph: {...} //<p>
text: {...} //文本
}
複製代碼
marks
是
{
em: {...} //<em>
link: {...} //<a>
strong: {...} //<strong>
code: {...} //<code>
}
複製代碼
它們表示編輯器中可能會出現的節點類型以及它們嵌套的方式。它們每一個都包含着一套規則,用來描述prosemirror文檔
和Dom文檔
之間的關聯,如何把Dom
轉化爲Node
或者Node
轉化爲Dom
。文檔中的每一個節點都有一個對應的類型。 從最上面開始doc
開始看:
doc: {
content: "block+"
}
複製代碼
每一個schema
必須定義一個頂層節點,即doc
。content
控制子節點的哪些序列對此節點類型有效。 例如"paragraph"
表示一個段落,"paragraph+"
表示一個或多個段落,"paragraph*"
表示零個或多個段落,你能夠在名稱後使用相似正則表達式的範圍。同時你也能夠用組合表達式例如"heading paragraph+"
,"{paragraph | blockquote}+"
。這裏"block+"
表示"(paragraph | blockquote)+"
。 接着看看em
:
em: {
parseDOM: [
{ tag: "i" },
{ tag: "em" },
{ style: "font-style=italic" }
],
toDOM: function() {
return ["em"]
}
}
複製代碼
parseDOM
與toDOM
表示文檔間的相互轉化,上面的代碼有三條解析規則:
<i>
標籤<em>
標籤font-style=italic
的樣式當匹配到一條規則時,就呈現爲HTML
的<em>
結構。
同理,咱們能夠實現一個下劃線的mark
:
underline: {
parseDOM: [
{ tag: 'u' },
{ style: 'text-decoration:underline' }
],
toDOM: function() {
return ['span', { style: 'text-decoration:underline' }]
}
}
複製代碼
Node
和Mark
均可以使用attrs
來存儲自定義屬性,好比image
,能夠在attrs
中存儲src
,alt
, title
。
回到剛纔
import { schema } from "prosemirror-schema-basic"
import { EditorState } from "prosemirror-state"
import { EditorView } from "prosemirror-view"
let state = EditorState.create({ schema })
let view = new EditorView(document.body, { state })
複製代碼
咱們使用EditorState.create
經過基礎規則schema
建立了編輯器的狀態state
。接着,爲狀態state
建立了編輯器的視圖,並附加到了document.body
。這會將咱們的狀態state
呈現爲可編輯的dom節點
,並在用戶鍵入時產生transaction
。
當用戶鍵入或者其餘方式與視圖交互時,都會產生transaction
。描述對state
所作的更改,而且能夠用來建立新的state
,而後更新視圖。
下圖是prosemirror
簡單的循環數據流data flow
:編輯器視圖顯示給定的state
,當發生某些event
時,它會建立一個transaction
並broadcast
它。而後,此transaction
一般用於建立新state
,該state
使用updateState
方法提供給視圖 。
DOM event
↗ ↘
EditorView Transaction
↖ ↙
new EditorState
複製代碼
默認狀況下,state
的更新都發生在底層,可是,你能夠編寫插件plugin
或者配置視圖來實現。例如咱們修改下上面建立視圖的代碼:
// (Imports omitted)
let state = EditorState.create({schema})
let view = new EditorView(document.body, {
state,
dispatchTransaction(transaction) {
console.log("create new transaction")
let newState = view.state.apply(transaction)
view.updateState(newState)
}
})
複製代碼
爲EditorView
添加了一個dispatchTransaction
的prop
,每次建立了一個transaction
,就會調用該函數。 這樣寫的話,每一個state
更新都必須手動調用updateState
。
prosemirror
的數據結構是immutable
的,不可變的,你不能直接去賦值它,你只能經過相應的API
去建立新的引用。可是在不一樣的引用之間,相同的部分是共享的。這就比如,有一顆基於immutable
的嵌套複雜很深的文檔樹,即便你只改變了某個地方的葉子節點,也會生成一棵新樹,但這棵新樹,除了剛纔更改的葉子節點外,其他部分和原有樹是共享的。有了immutable
,當每次鍵入編輯器都會產生新的state
,你在每種不一樣的state
之間來回切換,就能實現撤銷重作操做。同時,更新state
重繪文檔也變得更高效了。
是什麼構成了prosemirror
的state
呢?state
有三個主要組成部分:你的文檔doc
, 當前選擇selection
和當前存儲的mark
集storedMarks
。
初始化state
時,你能夠經過doc
屬性爲其提供要使用的初始文檔。這裏咱們可使用id
爲content
下的 dom結構
做爲編輯器的初始文檔。Dom解析器
將Dom結構
經過咱們的解析模式schema
將其轉化爲prosemirror結構
。
import { DOMParser } from "prosemirror-model"
import { EditorState } from "prosemirror-state"
import { schema } from "prosemirror-schema-basic"
let state = EditorState.create({
doc: DOMParser.fromSchema(schema).parse(document.querySelector("#content"))
})
複製代碼
prosemirror
支持多種類型的selection
(並容許第三方代碼定義新的選擇類型,注:任何一個新的類型都須要繼承自Selection
)。selection
與文檔和其餘與state
相關的值同樣,也是immutable
的 ,更改selection
,就要建立新的selection
和保持它的新state
。selection
至少具備from
和to
指向當前文檔的位置來表示選擇的範圍。最多見的選擇類型是TextSelection
,用於遊標或選定文本。prosemirror
還支持NodeSelection
,例如,當你按ctrl / cmd
單擊某個Node
時。會選擇範圍從節點以前的位置到其後的位置。 storedMarks
則表示須要應用於下一次輸入時的一組Mark
。
plugin
以各類方式擴展編輯器和編輯器state
。當建立一個新的state
,你能夠向其提供一系列的plugin
,這些將會保存在此state
和由此state
派生的任何state
中。而且能夠影響transaction
的應用方式以及基於此state
的編輯器的行爲方式。 建立plugin
時,會向其傳遞一個指定其行爲的對象。
let myPlugin = new Plugin({
props: {
handleKeyDown(view, event) {
//當收到keydown事件時調用
console.log("A key was pressed!")
return false // We did not handle this
}
}
})
let state = EditorState.create({schema, plugins: [myPlugin]})
複製代碼
當插件須要本身的plugin state
時,能夠經過state
屬性來定義。
let transactionCounter = new Plugin({
state: {
init() { return 0 },
apply(tr, value) { return value + 1 }
}
})
function getTransactionCount(state) {
return transactionCounter.getState(state)
}
複製代碼
上面這個插件定義了一個簡單的plugin state
,它對已經應用於state
的transaction
進行計數。 下面有個輔助函數,它調用了plugin
的getState
方法,從完整的編輯器的state
中獲取了plugin
的state
。
由於編輯器的state
是immutable
的,並且plugin state
是該state
的一部分,因此plugin state
也是immutable
的,即它們的apply
方法必須返回一個新值,而不是修改舊值。 plugin
一般能夠給transaction
添加一些額外信息metadata
。例如,在撤銷歷史操做時,會標記生成的transaction
,當plugin
看到時,他不會向普通的transaction
同樣處理它,它會特殊處理它:從撤銷堆棧頂部刪除,將該transaction
放入重作堆棧。
回到最初的例子,咱們能夠將command
綁定到鍵盤輸入的keymap plugin
,同時還有history plugin
,其經過觀察transaction
來實現撤銷和重作。
// (Omitted repeated imports)
import { undo, redo, history } from "prosemirror-history"
import { keymap } from "prosemirror-keymap"
let state = EditorState.create({
schema,
plugins: [
history(),
keymap({"Mod-z": undo, "Mod-y": redo})
]
})
let view = new EditorView(document.body, {state})
複製代碼
建立state
時會註冊plugin
,經過這個state
建立的視圖你將可以按Ctrl-Z
(或OS X
上的Cmd-Z
)來撤消上次更改。
上面的undo
, redo
是一種command
,大多數的編輯操做都被視爲command
。它能夠綁定到菜單或者鍵上,或者其餘方式暴露給用戶。在prosemirror
中,command
是實現編輯操做的功能,它們大可能是採用編輯器state
和dispatch
函數(EditorView.dispatch
或者一些其餘採用了transaction
的函數)完成的。下面是一個簡單的例子:
function deleteSelection(state, dispatch) {
if (state.selection.empty) return false
if (dispatch) dispatch(state.tr.deleteSelection())
return true
}
複製代碼
當command
不適用時,應該返回false
或者什麼也不作。若是適用,則須要dispatch
一個transaction
而後返回true
,爲了可以查詢command
是否適用於給定state
而不實際執行它,dispatch
參數是可選的,當沒有傳入dispatch
時,command
應該只返回true
,而不執行任何操做,這個能夠用來使你的菜單欄變灰來表示當前command
不可執行。 一些command
可能須要與dom
交互,你能夠爲他傳遞第三個參數view
,即整個編輯器的視圖。 prosemirror-commands
提供了許多的編輯command
,從簡單到複雜。還同時附帶一個基礎的keymap
, 可以給編輯器使用的鍵綁定來使編輯器可以執行輸入與刪除等操做,它將許多與schema
無關的command
綁定到一般用於它們的鍵。它還導出了許多command
的構造函數,例如toggleMark
,它傳入一個mark
類型和自定義屬性attrs
,返回一個command
函數,用於切換當前selection
上的該mark
類型。 要自定義編輯器,或容許用戶與Node
進行交互,你能夠編寫本身的command
。 例如一個簡單的清除樣式的格式刷command
:
function clear(state, dispatch) {
if (state.selection.empty) return false;
const { $from, $to } = state.selection;
if (dispatch) dispatch(state.tr.removeMark($from.pos, $to.pos, null));
return true
}
複製代碼
上述介紹能夠做爲對prosemirror
的一個簡單的認識,瞭解了它的運做原理,避免你第一次接觸它的時候,看到它的這麼多庫,不知道從哪上手。prosemirror
除了上面介紹的概念之外,還有Decorations
,NodeViews
等,它們使你能夠控制視圖繪製文檔的方式。若是你還想繼續深刻的瞭解prosemirror
,能夠前往它的官網和論壇,但願你能成爲它的貢獻者。