關(guān)于富文本編輯器,很多同學(xué)沒(méi)用過(guò)也聽(tīng)過(guò)了智嚷。是大家都不想去踩的坑卖丸。到底由多坑呢?
我這里摘了一部分一位大哥在知乎上的回答盏道,如果有興趣稍浆,可以去看看。
要讓一款編輯器達(dá)到商業(yè)級(jí)質(zhì)量摇天,從目前接觸到主要的例子來(lái)看粹湃,獨(dú)立開(kāi)發(fā)時(shí)間太長(zhǎng):
-
Quill編輯器,
Quill
從 2012 年收到第一個(gè) Issue 到 2016 年發(fā)布 1.0 版本泉坐,已經(jīng)過(guò)去了四年。 -
Prosemirror編輯器裳仆,
Prosemirror
作者在 2015 年正式開(kāi)源前籌款維護(hù)時(shí)已經(jīng)開(kāi)發(fā)了半年腕让,而到發(fā)布 1.0 版本時(shí),已經(jīng)過(guò)去了接近三年歧斟。 - Slate 從開(kāi)源到接近兩年時(shí)纯丸,仍然有一堆邊邊角角用起來(lái)莫名其妙的 bug 。
上面這幾個(gè)單人主導(dǎo)的編輯器項(xiàng)目要達(dá)到穩(wěn)定質(zhì)量静袖,時(shí)間是以年為單位來(lái)計(jì)算的觉鼻。考慮到目前互聯(lián)網(wǎng)“下周上線”的節(jié)奏队橙,動(dòng)輒幾年的時(shí)間是不劃算的坠陈。所以在人力,時(shí)間合理性各方面的約束下捐康,使用開(kāi)源框架是最好的選擇仇矾。
想要一款配置性強(qiáng),模塊化的編輯器解总,這就決定了這不是一個(gè)開(kāi)箱即用的應(yīng)用贮匕,而Quill
集成了許多樣式和交互邏輯,已經(jīng)算是一個(gè)應(yīng)用了花枫,有時(shí)一些制定需求不能完全滿足刻盐。Slate
是基于的React
視圖層的掏膏,我們的技術(shù)棧是Vue
,就不做考慮了敦锌,以后有機(jī)會(huì)可以研究一下壤追,所以最后選擇了prosemirror
,但另外兩款依然是很強(qiáng)大值得去學(xué)習(xí)的編輯器框架供屉。
由于prosemirror
目前使用搜索引擎能搜出來(lái)的中文資料幾乎沒(méi)有行冰,遇到問(wèn)題也只能去論壇
或者issue
里面搜,或者向作者提問(wèn)伶丐。以下的內(nèi)容是從官網(wǎng)悼做,加上自己在使用過(guò)程中對(duì)它的理解簡(jiǎn)化出來(lái)的。希望看完后哗魂,能讓你對(duì)prosemirror
產(chǎn)生興趣肛走,并從作者的設(shè)計(jì)思路中,學(xué)到東西录别,一起分享朽色。
ProseMirror簡(jiǎn)介
A toolkit for building rich-text editors on the web
prosemirror
的作者 Marijn 是 codemirror
編輯器和 acorn
解釋器的作者,前者已經(jīng)在 Chrome
和 Firefox
自帶的調(diào)試工具里使用了组题,后者則是 babel
的依賴葫男。
prosemirror
不是一個(gè)大而全的框架, 它是由無(wú)數(shù)個(gè)小的模塊組成,它就像樂(lè)高一樣是一個(gè)堆疊出來(lái)的編輯器崔列。
它的核心庫(kù)有:
-
prosemirror-model
: 定義編輯器的文檔模型梢褐,用來(lái)描述編輯器內(nèi)容的數(shù)據(jù)結(jié)構(gòu) -
prosemirror-state
: 提供描述編輯器整個(gè)狀態(tài)的數(shù)據(jù)結(jié)構(gòu),包括selection
(選擇)赵讯,以及從一個(gè)狀態(tài)到下一個(gè)狀態(tài)的transaction
(事務(wù)) -
prosemirror-view
: 實(shí)現(xiàn)一個(gè)在瀏覽器中將給定編輯器狀態(tài)顯示為可編輯元素盈咳,并且處理用戶交互的用戶界面組件 -
prosemirror-transform
: 包括以記錄和重放的方式修改文檔的功能,這是state
模塊中transaction
(事務(wù))的基礎(chǔ)边翼,并且它使得撤銷和協(xié)作編輯成為可能鱼响。
此外,prosemirror
還提供了許多的模塊组底,如prosemirror-commands
基本編輯命令丈积,prosemirror-keymap
鍵綁定,prosemirror-history
歷史記錄斤寇,prosemirror-inputrules
輸入宏桶癣,prosemirror-collab
協(xié)作編輯,prosemirror-schema-basic
簡(jiǎn)單文檔模式等娘锁。
現(xiàn)在你應(yīng)該大概了解了它們各自的作用牙寞,它們是整個(gè)編輯器的基礎(chǔ)。
實(shí)現(xiàn)一個(gè)編輯器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 })
我們來(lái)看看上面的代碼干了什么事,從第一行開(kāi)始间雀。prosemirror
要求指定一個(gè)文檔符合的模式悔详。所以從prosemirror-schema-basic
引入了一個(gè)基本的schema
。那么這個(gè)schema
是什么呢惹挟?
因?yàn)?code>prosemirror定義了自己的數(shù)據(jù)結(jié)構(gòu)來(lái)表示文檔內(nèi)容茄螃。在prosemirror結(jié)構(gòu)
與HTML的Dom結(jié)構(gòu)
之間,需要一次解析與轉(zhuǎn)化连锯,這兩者間相互轉(zhuǎn)化的橋梁归苍,就是我們的schema
,所以要先了解一下prosemirror
的文檔結(jié)構(gòu)运怖。
prosemirror文檔結(jié)構(gòu)
prosemirror
的文檔是一個(gè)Node
,它包含零個(gè)或多個(gè)child Nodes
的Fragment(片段)
拼弃。
有點(diǎn)類似瀏覽器DOM的遞歸和樹(shù)形的結(jié)構(gòu)。但它在存儲(chǔ)內(nèi)聯(lián)內(nèi)容方式上有所不一樣摇展。
<p>This is <strong>strong text with <em>emphasis</em></strong></p>
在HTML
中吻氧,是這樣的樹(shù)結(jié)構(gòu):
p //"this is "
strong //"strong text with "
em //"emphasis"
在prosemirror
中,內(nèi)聯(lián)內(nèi)容被建模為平面的序列咏连,strong盯孙、em(Mark)
作為paragraph(Node)
的附加數(shù)據(jù):
"paragraph(Node)"
// "this is " | "strong text with" | "emphasis"
"strong(Mark)" "strong(Mark)", "em(Mark)"
prosemirror
的文檔的對(duì)象結(jié)構(gòu)如下
Node:
type: NodeType //包含了Node的名字與屬性等
content: Fragment //包含多個(gè)Node
attrs: Object //自定義屬性,image可以用來(lái)存儲(chǔ)src等祟滴。
marks: [Mark, Mark...] // 包含一組Mark實(shí)例的數(shù)組振惰,例如em和strong
Mark:
type: MarkType //包含Mark的名字與屬性等
attrs: Object //自定義屬性
prosemirror
提供了兩種類型的索引
- 樹(shù)類型,這個(gè)和
dom結(jié)構(gòu)
相似踱启,你可以利用child
或者childCount
等方法直接訪問(wèn)到子節(jié)點(diǎn) - 平坦的標(biāo)記序列报账,它將標(biāo)記序列中的索引作為文檔的位置,它們是一種計(jì)數(shù)約定
- 在整個(gè)文檔開(kāi)頭埠偿,索引位置為0
- 進(jìn)入或離開(kāi)一個(gè)不是葉節(jié)點(diǎn)的節(jié)點(diǎn)記為一個(gè)標(biāo)記
- 文本節(jié)點(diǎn)中的每個(gè)節(jié)點(diǎn)都算一個(gè)標(biāo)記
- 沒(méi)有內(nèi)容的葉節(jié)點(diǎn)(例如
image
)也算一個(gè)標(biāo)記
例如有一個(gè)HTML片段為
<p>One</p>
<blockquote><p>Two<img src="..."></p></blockquote>
則計(jì)數(shù)標(biāo)記為
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>
每個(gè)節(jié)點(diǎn)都有一個(gè)nodeSize
屬性表示整個(gè)節(jié)點(diǎn)的大小。手動(dòng)解析這些位置涉及到相當(dāng)多的計(jì)數(shù)榜晦,prosemirror
為我們提供了Node.resolve
方法來(lái)解析這些位置冠蒋,并且能夠獲取關(guān)于這個(gè)位置更多的信息,例如父節(jié)點(diǎn)是什么乾胶,與父節(jié)點(diǎn)的偏移量抖剿,父節(jié)點(diǎn)的祖先是什么等一些其它信息。
了解了prosemirror
的數(shù)據(jù)結(jié)構(gòu)识窿,回到剛才的地方斩郎,我們從prosemirror-schema-basic
中引入了一個(gè)基本的schema
,這個(gè)schema
是什么呢喻频?通過(guò)查看源碼最后一行
export const schema = new Schema({nodes, marks})
schema
是Schema
通過(guò)傳入的nodes
, marks
生成的實(shí)例缩宜。
而在實(shí)例之前的代碼,都是在定義nodes
和marks
,將代碼折疊一下锻煌,發(fā)現(xiàn)nodes
是
{
doc: {...} // 頂級(jí)文檔
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>
}
它們表示編輯器中可能會(huì)出現(xiàn)的節(jié)點(diǎn)類型以及它們嵌套的方式妓布。它們每個(gè)都包含著一套規(guī)則,用來(lái)描述prosemirror文檔
和Dom文檔
之間的關(guān)聯(lián)宋梧,如何把Dom
轉(zhuǎn)化為Node
或者Node
轉(zhuǎn)化為Dom
匣沼。文檔中的每個(gè)節(jié)點(diǎn)都有一個(gè)對(duì)應(yīng)的類型。
從最上面開(kāi)始doc
開(kāi)始看:
doc: {
content: "block+"
}
每個(gè)schema
必須定義一個(gè)頂層節(jié)點(diǎn)捂龄,即doc
释涛。content
控制子節(jié)點(diǎn)的哪些序列對(duì)此節(jié)點(diǎn)類型有效。
例如"paragraph"
表示一個(gè)段落倦沧,"paragraph+"
表示一個(gè)或多個(gè)段落唇撬,"paragraph*"
表示零個(gè)或多個(gè)段落,你可以在名稱后使用類似正則表達(dá)式的范圍刀脏。同時(shí)你也可以用組合表達(dá)式例如"heading paragraph+"
局荚,"{paragraph | blockquote}+"
。這里"block+"
表示"(paragraph | blockquote)+"
愈污。
接著看看em
:
em: {
parseDOM: [
{ tag: "i" },
{ tag: "em" },
{ style: "font-style=italic" }
],
toDOM: function() {
return ["em"]
}
}
parseDOM
與toDOM
表示文檔間的相互轉(zhuǎn)化耀态,上面的代碼有三條解析規(guī)則:
-
<i>
標(biāo)簽 -
<em>
標(biāo)簽 -
font-style=italic
的樣式
當(dāng)匹配到一條規(guī)則時(shí),就呈現(xiàn)為HTML
的<em>
結(jié)構(gòu)暂雹。
同理首装,我們可以實(shí)現(xiàn)一個(gè)下劃線的mark
:
underline: {
parseDOM: [
{ tag: 'u' },
{ style: 'text-decoration:underline' }
],
toDOM: function() {
return ['span', { style: 'text-decoration:underline' }]
}
}
Node
和Mark
都可以使用attrs
來(lái)存儲(chǔ)自定義屬性,比如image
杭跪,可以在attrs
中存儲(chǔ)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
通過(guò)基礎(chǔ)規(guī)則schema
創(chuàng)建了編輯器的狀態(tài)state
系奉。接著,為狀態(tài)state
創(chuàng)建了編輯器的視圖姑廉,并附加到了document.body
缺亮。這會(huì)將我們的狀態(tài)state
呈現(xiàn)為可編輯的dom節(jié)點(diǎn)
,并在用戶鍵入時(shí)產(chǎn)生transaction
桥言。
Transaction
當(dāng)用戶鍵入或者其他方式與視圖交互時(shí)萌踱,都會(huì)產(chǎn)生transaction
。描述對(duì)state
所做的更改号阿,并且可以用來(lái)創(chuàng)建新的state
并鸵,然后更新視圖。
下圖是prosemirror
簡(jiǎn)單的循環(huán)數(shù)據(jù)流data flow
:編輯器視圖顯示給定的state
扔涧,當(dāng)發(fā)生某些event
時(shí)园担,它會(huì)創(chuàng)建一個(gè)transaction
并broadcast
它。然后,此transaction
通常用于創(chuàng)建新state
粉铐,該state
使用其updateState
方法提供給視圖 疼约。
DOM event
↗ ↘
EditorView Transaction
↖ ↙
new EditorState
默認(rèn)情況下,state
的更新都發(fā)生在底層蝙泼,但是程剥,你可以編寫插件plugin
或者配置視圖來(lái)實(shí)現(xiàn)。例如我們修改下上面創(chuàng)建視圖的代碼:
// (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
添加了一個(gè)dispatchTransaction
的prop
汤踏,每次創(chuàng)建了一個(gè)transaction
织鲸,就會(huì)調(diào)用該函數(shù)。
這樣寫的話溪胶,每個(gè)state
更新都必須手動(dòng)調(diào)用updateState
搂擦。
Immutable
prosemirror
的數(shù)據(jù)結(jié)構(gòu)是immutable
的,不可變的哗脖,你不能直接去賦值它瀑踢,你只能通過(guò)相應(yīng)的API
去創(chuàng)建新的引用。但是在不同的引用之間才避,相同的部分是共享的橱夭。這就好比,有一顆基于immutable
的嵌套復(fù)雜很深的文檔樹(shù)桑逝,即使你只改變了某個(gè)地方的葉子節(jié)點(diǎn)棘劣,也會(huì)生成一棵新樹(shù),但這棵新樹(shù)楞遏,除了剛才更改的葉子節(jié)點(diǎn)外茬暇,其余部分和原有樹(shù)是共享的。有了immutable
寡喝,當(dāng)每次鍵入編輯器都會(huì)產(chǎn)生新的state
糙俗,你在每種不同的state
之間來(lái)回切換,就能實(shí)現(xiàn)撤銷重做操作预鬓。同時(shí)臼节,更新state
重繪文檔也變得更高效了。
State
是什么構(gòu)成了prosemirror
的state
呢珊皿?state
有三個(gè)主要組成部分:你的文檔doc
, 當(dāng)前選擇selection
和當(dāng)前存儲(chǔ)的mark
集storedMarks
巨税。除此之外蟋定,還有
初始化state
時(shí),你可以通過(guò)doc
屬性為其提供要使用的初始文檔草添。這里我們可以使用id
為content
下的 dom結(jié)構(gòu)
作為編輯器的初始文檔驶兜。Dom解析器
將Dom結(jié)構(gòu)
通過(guò)我們的解析模式schema
將其轉(zhuǎn)化為prosemirror結(jié)構(gòu)
。
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
(并允許第三方代碼定義新的選擇類型,注:任何一個(gè)新的類型都需要繼承自Selection
)抄淑。selection
與文檔和其他與state
相關(guān)的值一樣屠凶,也是immutable
的 ,更改selection
肆资,就要?jiǎng)?chuàng)建新的selection
和保持它的新state
矗愧。selection
至少具有from
和to
指向當(dāng)前文檔的位置來(lái)表示選擇的范圍。最常見(jiàn)的選擇類型是TextSelection
郑原,用于游標(biāo)或選定文本唉韭。prosemirror
還支持NodeSelection
,例如犯犁,當(dāng)你按ctrl / cmd單擊某個(gè)Node
時(shí)属愤。會(huì)選擇范圍從節(jié)點(diǎn)之前的位置到其后的位置。
storedMarks
則表示需要應(yīng)用于下一次輸入時(shí)的一組Mark
酸役。
Plugins
plugin
以各種方式擴(kuò)展編輯器和編輯器狀態(tài)住诸。當(dāng)創(chuàng)建一個(gè)新的state
,你可以向其提供一系列的plugin
涣澡,這些將會(huì)保存在此state
和由此state
派生的任何state
中贱呐。并且可以影響transaction
的應(yīng)用方式以及基于此state
的編輯器的行為方式。
創(chuàng)建plugin
時(shí)暑塑,會(huì)向其傳遞一個(gè)指定其行為的對(duì)象吼句。
let myPlugin = new Plugin({
props: {
handleKeyDown(view, event) {
//當(dāng)收到keydown事件時(shí)調(diào)用
console.log("A key was pressed!")
return false // We did not handle this
}
}
})
let state = EditorState.create({schema, plugins: [myPlugin]})
當(dāng)插件需要自己的plugin state
時(shí),可以通過(guò)state
屬性來(lái)定義事格。
let transactionCounter = new Plugin({
state: {
init() { return 0 },
apply(tr, value) { return value + 1 }
}
})
function getTransactionCount(state) {
return transactionCounter.getState(state)
}
上面這個(gè)插件定義了一個(gè)簡(jiǎn)單的狀態(tài)惕艳,它對(duì)已經(jīng)應(yīng)用于state
的transaction
進(jìn)行計(jì)數(shù)。
下面有個(gè)輔助函數(shù)驹愚,它調(diào)用了plugin
的getState
方法远搪,從完整的編輯器的state
中獲取了plugin
的state
。
因?yàn)榫庉嬈鞯?code>state是immutable
的逢捺,而且plugin state
是該state
的一部分谁鳍,所以plugin state
也是immutable
的,即它們的apply
方法必須返回一個(gè)新值劫瞳,而不是修改舊值倘潜。
plugin
通常可以給transaction
添加一些額外信息metadata
志于。例如涮因,在撤銷歷史操作時(shí),會(huì)標(biāo)記生成的transaction
伺绽,當(dāng)plugin
看到時(shí)养泡,他不會(huì)向普通的transaction
一樣處理它嗜湃,它會(huì)特殊處理它:從撤銷堆棧頂部刪除,將該transaction
放入重做堆棧澜掩。
回到最初的例子购披,我們可以將command
綁定到鍵盤輸入的keymap plugin
,同時(shí)還有history plugin
肩榕,其通過(guò)觀察transaction
來(lái)實(shí)現(xiàn)撤銷和重做刚陡。
// (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})
創(chuàng)建state
時(shí)會(huì)注冊(cè)plugin
,通過(guò)這個(gè)state
創(chuàng)建的視圖你將能夠按Ctrl-Z
(或OS X
上的Cmd-Z
)來(lái)撤消上次更改点把。
Commands
上面的undo
, redo
是一種command
橘荠,大多數(shù)的編輯操作都被視為command
。它可以綁定到菜單或者鍵上郎逃,或者其他方式暴露給用戶哥童。在prosemirror
中,command
是實(shí)現(xiàn)編輯操作的功能褒翰,它們大多是采用編輯器state
和dispatch
函數(shù)(EditorView.dispatch
或者一些其他采用了transaction
的函數(shù))完成的贮懈。下面是一個(gè)簡(jiǎn)單的例子:
function deleteSelection(state, dispatch) {
if (state.selection.empty) return false
if (dispatch) dispatch(state.tr.deleteSelection())
return true
}
當(dāng)command
不適用時(shí),應(yīng)該返回false
或者什么也不做优训。如果適用朵你,則需要dispatch
一個(gè)transaction
然后返回true
,為了能夠查詢command
是否適用于給定state
而不實(shí)際執(zhí)行它揣非,dispatch
參數(shù)是可選的抡医,當(dāng)沒(méi)有傳入dispatch
時(shí),command
應(yīng)該只返回true
早敬,而不執(zhí)行任何操作忌傻,這個(gè)可以用來(lái)使你的菜單欄變灰來(lái)表示當(dāng)前command
不可執(zhí)行。
一些command
可能需要與dom
交互搞监,你可以為他傳遞第三個(gè)參數(shù)view
水孩,即整個(gè)編輯器的視圖。
prosemirror-commands
提供了許多的編輯command
琐驴,從簡(jiǎn)單到復(fù)雜俘种。還同時(shí)附帶一個(gè)基礎(chǔ)的keymap
, 能夠給編輯器使用的鍵綁定來(lái)使編輯器能夠執(zhí)行輸入與刪除等操作绝淡,它將許多與schema
無(wú)關(guān)的command
綁定到通常用于它們的鍵宙刘。它還導(dǎo)出了許多command
的構(gòu)造函數(shù),例如toggleMark
,它傳入一個(gè)mark
類型和自定義屬性attrs
牢酵,返回一個(gè)command
函數(shù)荐类,用于切換當(dāng)前selection
上的該mark
類型。
要自定義編輯器茁帽,或允許用戶與Node
進(jìn)行交互玉罐,你可以編寫自己的command
。
例如一個(gè)簡(jiǎn)單的清除樣式的格式刷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
}
總結(jié)
上述介紹可以作為對(duì)prosemirror
的一個(gè)簡(jiǎn)單的認(rèn)識(shí)潘拨,了解了它的運(yùn)作原理吊输,避免你第一次接觸它的時(shí)候,看到它的這么多庫(kù)铁追,不知道從哪上手季蚂。prosemirror
除了上面介紹的概念以外,還有Decorations
琅束,NodeViews
等扭屁,它們使你可以控制視圖繪制文檔的方式。如果你還想繼續(xù)深入的了解prosemirror
涩禀,可以前往它的官網(wǎng)和論壇料滥,希望你能成為它的貢獻(xiàn)者。