關於富文字編輯器,很多同學沒用過也聽過了。是大家都不想去踩的坑。到底有多坑呢?
我這裡摘了一部分一位大哥在知乎上的回答,如果有興趣,可以去看看。 要讓一款編輯器達到商業級質量,從目前接觸到主要的例子來看,獨立開發時間太長:
- Quill編輯器,
Quill
從 2012 年收到第一個 Issue 到 2016 年釋出 1.0 版本,已經過去了四年。 - Prosemirror編輯器,
Prosemirror
作者在 2015 年正式開源前籌款維護時已經開發了半年,而到釋出 1.0 版本時,已經過去了接近三年。 - Slate 從開源到接近兩年時,仍然有一堆邊邊角角用起來莫名其妙的 bug 。
上面這幾個單人主導的編輯器專案要達到穩定質量,時間是以年為單位來計算的。考慮到目前網際網路“下週上線”的節奏,動輒幾年的時間是不划算的。所以在人力,時間合理性各方面的約束下,使用開源框架是最好的選擇。
想要一款配置性強,模組化的編輯器,這就決定了這不是一個開箱即用的應用,而Quill
整合了許多樣式和互動邏輯,已經算是一個應用了,有時一些制定需求不能完全滿足。Slate
是基於的React
檢視層的,我們的技術棧是Vue
,就不做考慮了,以後有機會可以研究一下,所以最後選擇了prosemirror
,但另外兩款依然是很強大值得去學習的編輯器框架。
由於prosemirror
目前使用搜尋引擎能搜出來的中文資料幾乎沒有,遇到問題也只能去論壇
,issue
裡面搜,或者向作者提問。以下的內容是從官網,加上自己在使用過程中對它的理解簡化出來的。希望看完後,能讓你對prosemirror
產生興趣,並從作者的設計思路中,學到東西,一起分享。
ProseMirror簡介
A toolkit for building rich-text editors on the web
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
簡單文件模式等。
現在你應該大概瞭解了它們各自的作用,它們是整個編輯器的基礎。
實現一個編輯器demo
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文件結構
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
等方法直接訪問到子節點 - 平坦的標記序列,它將標記序列中的索引作為文件的位置,它們是一種計數約定
- 在整個文件開頭,索引位置為0
- 進入或離開一個不是葉節點的節點記為一個標記
- 文字節點中的每個節點都算一個標記
- 沒有內容的葉節點(例如
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
當使用者鍵入或者其他方式與檢視互動時,都會產生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
。
Immutable
prosemirror
的資料結構是immutable
的,不可變的,你不能直接去賦值它,你只能通過相應的API
去建立新的引用。但是在不同的引用之間,相同的部分是共享的。這就好比,有一顆基於immutable
的巢狀複雜很深的文件樹,即使你只改變了某個地方的葉子節點,也會生成一棵新樹,但這棵新樹,除了剛才更改的葉子節點外,其餘部分和原有樹是共享的。有了immutable
,當每次鍵入編輯器都會產生新的state
,你在每種不同的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
。
Plugins
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
)來撤消上次更改。
Commands
上面的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
,可以前往它的官網和論壇,希望你能成為它的貢獻者。