從零實現(xiàn)富文本編輯器#3-基于Delta的線性數(shù)據(jù)結(jié)構(gòu)模型
數(shù)據(jù)模型的設計是編輯器的核心基礎,其直接影響了選區(qū)模型、DOM模型、狀態(tài)管理等模塊的設計。例如在quill中的選區(qū)模型是index + len的表達,而slate中則是anchor + focus的表達,這些都是基于數(shù)據(jù)模型的設計而來的。因此我們從零實現(xiàn)的富文本編輯器就需要從數(shù)據(jù)模型的設計開始,之后就可以逐步實現(xiàn)其他模塊。
- 開源地址: https://github.com/WindRunnerMax/BlockKit
- 在線編輯: https://windrunnermax.github.io/BlockKit/
- 項目筆記: https://github.com/WindRunnerMax/BlockKit/blob/master/NOTE.md
從零實現(xiàn)富文本編輯器項目的相關(guān)文章:
- 深感一無所長,準備試著從零開始寫個富文本編輯器
- 從零實現(xiàn)富文本編輯器#2-基于MVC模式的編輯器架構(gòu)設計
- 從零實現(xiàn)富文本編輯器#3-基于Delta的線性數(shù)據(jù)結(jié)構(gòu)模型
Delta
在先前的架構(gòu)設計中我們已經(jīng)提到了,我們實現(xiàn)的扁平數(shù)據(jù)結(jié)構(gòu)且獨立分包設計,無論是在編輯器中操作,還是在服務端數(shù)據(jù)解析,都可以更加方便地處理。相比較來說,嵌套的數(shù)據(jù)結(jié)構(gòu)能夠更好地對齊DOM表達,然而這樣對于數(shù)據(jù)的操作卻變得更加復雜。
因此在數(shù)據(jù)結(jié)構(gòu)的設計上,我們是基于quill的delta結(jié)構(gòu)進行了改造。最主要的部分是將其改造為immutable的實現(xiàn),在編輯器中實際是維護的狀態(tài)而不是本身的delta結(jié)構(gòu)。并且精簡了整個數(shù)據(jù)模型的表達,將復雜的insert與Attribute類型縮減,那么其操作邏輯的復雜度也會降低。
delta是一種簡潔而功能強大的格式,用于描述文檔內(nèi)容及其變化。該格式基于JSON,不僅便于閱讀,同時也易于機器解析。通過使用delta描述的內(nèi)容,可以準確地描述任何富文本文檔的內(nèi)容和格式信息,避免了HTML中常見的歧義和復雜性。
delta由一系列操作組成,這些操作描述了對文檔進行的更改。常見的操作包括insert、delete、retain。需要注意的是,這些操作不依賴于具體的索引位置,其總是描述當前索引位置的更改,并且可以通過retain來移動指針位置。
delta既可以表示整個文檔,也可以表示對文檔進行的更改。那么在這里我們將delta的主要類對象及相關(guān)操作邏輯進行描述,特別是在編輯器中實際應用場景,以及主要的改造和相關(guān)類型聲明。
insert
insert方法是將數(shù)據(jù)插入到delta的操作,這就是delta。當描述整個文檔內(nèi)容時,整個數(shù)據(jù)的內(nèi)容應該全部都是insert。首個參數(shù)是要插入的文本內(nèi)容,第二個參數(shù)是可選的屬性對象,用于描述文本的格式信息。
const delta = new Delta();
delta.insert("123").insert("567", { a: "1" });
// [{"insert":"123"},{"insert":"567","attributes":{"a":"1"}}]
原始的insert參數(shù)是可以對象類型的Embed結(jié)構(gòu),這種結(jié)構(gòu)可以表達Image、Video、Mention等非文本結(jié)構(gòu)的數(shù)據(jù),而屬性AttributeMap參數(shù)是Record<string, unknown>類型,這樣用來描述復雜屬性值。
在這里我們將其精簡了,insert參數(shù)僅支持string類型,而具體的schema則在編輯器的初始化時定義,格式信息則收歸于Attrs中描述。而AttributeMap則改為Record<string, string>類型,并且可以避免諸如CloneDeep、isEqual等對于復雜數(shù)據(jù)結(jié)構(gòu)的實現(xiàn)。
其實在EtherPad中就是將Attribute就是[string, string]類型,在這里我們也是使用了類似的設計。在這種基礎結(jié)構(gòu)設計下,我們更推薦將屬性值扁平地放置于attributes屬性中,而不是使用單個屬性值作為key,將所有屬性值嵌套地放置于value中。
export interface AttributeMap {
[key: string]: string;
}
delta整個名字通常會用于描述變更,那么除了描述整個文檔內(nèi)容外,當然還可以描述文檔內(nèi)容的變更。不過應用變更的內(nèi)容需要用到compose,這個方法的描述我們在后邊再看。
const delta1 = new Delta().insert("123");
const delta2 = new Delta().insert("456");
delta1.compose(delta2); // [{"insert":"456123"}]
delete
delete方法描述了刪除內(nèi)容的長度,由于上述的定義我們現(xiàn)在的內(nèi)容全部都是文本,在原始的數(shù)據(jù)定義中嵌入Embed的長度為1。
const delta = new Delta().insert("123");
delta.compose(new Delta().delete(1)); // [{"insert":"23"}]
其實這里是比較有趣的事情,通過delete來描述變更時,無法得知究竟刪除了哪些內(nèi)容。那么這種情況下,進行invert的時候就需要額外的數(shù)據(jù)來構(gòu)造insert操作。類似的場景在OT-JSON中,內(nèi)容描述是直接寫入op的,因此可以直接根據(jù)op來進行invert操作。
const delta = new Delta().insert("123");
const del = new Delta().delete(1);
const invert1 = del.invert(delta); // [{"insert":"1"}]
const delta2 = delta.compose(del); // [{"insert":"23"}]
delta2.compose(invert1); // [{"insert":"123"}]
retain
retain方法描述了保留內(nèi)容的長度,換句話說,這個操作可以用來移動指針。
const delta = new Delta().insert("123");
delta.compose(new Delta().retain(1).insert("a")); // [{"insert":"1a23"}]
同時retain操作也可以用于修改內(nèi)容的屬性值,此外如果想刪除某個屬性,只需要將value設置為""即可。
const delta = new Delta().insert("123");
const d2 = new Delta().retain(1).retain(1, { "a": "1" });
const d3 = delta.compose(d2); // [{"insert":"1"},{"insert":"2","attributes":{"a":"1"}},{"insert":"3"}]
d3.compose(new Delta().retain(1).retain(1, { "a": "" })); // [{"insert":"123"}]
push
push方法是上述的insert、delete、retain依賴的基礎方法,主要實現(xiàn)是將內(nèi)容推入到delta維護的數(shù)組中。這里的實現(xiàn)非常重要部分是op的合并,當屬性值相同時,則需要將其合并為單個op。
const delta = new Delta();
delta.push({ insert: "123" }).push({ insert: "456" }); // [{"insert": "123456"}]
當然這里不僅僅是insert操作會合并,對于delete、retain操作也是一樣的。這里的合并操作是基于op的attributes屬性值,如果attributes屬性值不同,則會被視為不同的op,不會自動合并。
const delta = new Delta();
delta.push({ delete: 1 }).push({ delete: 1 }); // [{"delete": 2}]
const delta2 = new Delta();
delta2.push({ retain: 1 }).push({ retain: 1 }); // [{"retain": 2}]
const delta3 = new Delta();
delta3.push({ retain: 1 }).push({ retain: 1, attributes: { a: "1"} }); // [{"retain": 1}, {"retain": 1, "attributes": {"a": "1"}}]
slice
slice方法是用于截取delta的操作,這個方法是基于op的length屬性值進行截取的。
const delta = new Delta().insert("123").insert("456", {a: "1"});
delta.slice(2, 4); // [{"insert":"3"},{"insert":"4","attributes":{"a":"1"}}]
eachLine
eachLine方法用于按行迭代整個delta,我們的整體數(shù)據(jù)結(jié)構(gòu)是線性的,但是編輯器DOM需要按行來劃分內(nèi)容,因此基于\n來劃分行就是比較常規(guī)的操作了。
這個方法對于編輯器的初始化非常重要,而當初始化完畢后,我們的變更就需要基于狀態(tài)來實現(xiàn),而不是每次都需要經(jīng)過該方法。在這里我們也對其進行了改造,原始的eachLine方法是不會攜帶\n節(jié)點。
const delta = new Delta().insert("123\n456\n789");
delta.eachLine((line, attributes) => {
console.log(line, attributes);
});
// [{insert:"123"},{insert:"\n"}] {}
// [{insert:"456"},{insert:"\n"}] {}
// [{insert:"789"},{insert:"\n"}] {}
diff
diff方法用于對比兩個delta之間的差異,這個方法實際上是基于純文本的myers diff來實現(xiàn)。通過將delta轉(zhuǎn)換為純文本,在diff過后不斷挑選較短的操作部分來實現(xiàn)delta之間的diff。
其實在我們的實現(xiàn)中完全可以將diff方法獨立出來,這里唯一引用了外部的fast-diff依賴。在quill中diff是必要的,因為其完全是非受控的輸入方式,文本的輸入依賴于對DOM文本的diff來實現(xiàn),而我們的輸入是依賴beforeinput事件的半受控輸入,因此并不強依賴diff。
const delta1 = new Delta().insert("123");
const delta2 = new Delta().insert("126");
delta1.diff(delta2); // [{"retain":2},{"insert":"6"},{"delete":1}]
chop
chop方法用于裁剪末尾的retain操作,當存在末尾的retain且沒有屬性操作時,其本身是沒有意義的,因此可以調(diào)用該方法檢查并移除。
const delta = new Delta().insert("123").retain(1);
delta.chop(); // [{"insert":"123"}]
compose
compose方法可以將兩個delta合并為一個delta,具體來說則是將B的delta操作應用到A的delta上,此時返回的是一個新的delta對象。當然在我們的實現(xiàn)中,繼承了原始Delta類重寫了compose方法,做到了immutable。
compose在編輯器中的應用場景非常多,例如輸入事件、內(nèi)容粘貼、歷史操作等場景中,類似于編輯器的apply方法,相當于應用內(nèi)容變更。
const delta1 = new Delta().insert("123");
const delta2 = new Delta().retain(1).delete(1);
delta1.compose(delta2); // [{"insert":"13"}]
invert
invert方法是將delta的操作進行反轉(zhuǎn),這個方法在歷史操作中非常重要,因為本身undo就是需要將當前的操作進行反轉(zhuǎn)。此外,在實現(xiàn)OT的local-cs中,invert也是非常重要的方法。
值得關(guān)注的是,上邊也提到了delete操作和retain操作在本身執(zhí)行時是不會記錄原始內(nèi)容的,因此在invert是需要原始的delta作為數(shù)據(jù)源來進行操作,注意這里的delta是最初的delta,而不是invert后的delta。
const delta = new Delta().insert("123");
const del = new Delta().delete(1);
const invert1 = del.invert(delta); // [{"insert":"1"}]
const delta2 = delta.compose(del); // [{"insert":"23"}]
delta2.compose(invert1); // [{"insert":"123"}]
concat
concat方法可以連接兩個delta到新的delta中。這個操作與compose不同,compose是將B的操作應用到A上,而concat則是將B的操作追加到A的操作后。
const delta1 = new Delta().insert("123");
const delta2 = new Delta().insert("456");
delta1.compose(delta2); // [{"insert":"456123"}]
delta1.concat(delta2); // [{"insert":"123456"}]
transform
transform方法是實現(xiàn)操作OT協(xié)同的基礎,即使不實現(xiàn)協(xié)同編輯,在編輯器中的歷史操作模塊中也會需要這部分實現(xiàn)。假設我們現(xiàn)在有用戶A[uid:1]和用戶B[uid:2],此時我們以uid定義優(yōu)先級,則A的操作優(yōu)先級高于B,且當前的文檔內(nèi)容為12。
如果是在協(xié)同中的話,b'=a.t(b)的意思是,假設a和b都是從相同的draft分支出來的,那么b'就是假設a已經(jīng)應用了,此時b需要在a的基礎上變換出b'才能直接應用,我們也可以理解為transform解決了a操作對b操作造成的影響。
那么我們假設A在12后的位置插入了A字符,B在12后的位置插入了B字符。如果進行協(xié)同操作,那么兩者相當于同時在同一個位置插入了字符,如果不進行操作變換而直接應用的話,兩者的數(shù)據(jù)就會出現(xiàn)沖突,A的數(shù)據(jù)是12BA,而B的數(shù)據(jù)是12AB,因此就需要先轉(zhuǎn)換再應用。
// User A
const base = new Delta().insert("12");
const delta = new Delta().retain(2).insert("A");
let current = base.compose(delta); // 12A
// Accept Remote B
const remote = new Delta().retain(2).insert("B");
// ob1=OT(oa, ob)
const remote2 = delta.transform(remote, true); // [{"retain":3},{"insert":"B"}]
current = current.compose(remote2); // 12AB
// User B
const base = new Delta().insert("12");
const delta = new Delta().retain(2).insert("B");
let current = base.compose(delta); // 12B
// Accept Remote A
const remote = new Delta().retain(2).insert("A");
// oa2=OT(ob, oa)
const remote2 = delta.transform(remote, false); // [{"retain":2},{"insert":"A"}]
current = current.compose(remote2); // 12AB
transformPosition
transformPosition方法用于將指定的位置進行轉(zhuǎn)換,這個方法的主要場景是編輯器中的選區(qū)/光標的位置變換,例如光標此時在1后面,構(gòu)造delta在1之前增加了內(nèi)容的話,那么光標就需要跟隨移動。
const delta = new Delta().retain(5).insert("a");
delta.transformPosition(4); // 4
delta.transformPosition(5); // 6
OpIterator
OpIterator類定義一個迭代器,用于迭代delta中的op操作。迭代器大量用于diff、compose、transform等方法中,需要注意的是該迭代器調(diào)用next時不會跨越op,即使傳遞的length大于當前op的長度。
const delta = new Delta()
.insert("Hello", { bold: "true" })
.insert(" World", { italic: "true" });
.retain(3);
iter.next(2); // { insert: "He", attributes: { bold: "true" } }
iter.next(10); // { insert: "llo", attributes: { bold: "true" } }
EtherPad
EtherPad同樣是非常優(yōu)秀的協(xié)同編輯器,其內(nèi)置實現(xiàn)的數(shù)據(jù)結(jié)構(gòu)同樣是線性的,文檔整體描述稱為ClientVars,數(shù)據(jù)結(jié)構(gòu)變更被稱為ChangeSet。協(xié)同算法的實現(xiàn)是EasySync,且其文檔中對如何進行服務端調(diào)度也有較為詳細的描述。
ClientVars/Changeset同樣是一種基于JSON的數(shù)據(jù)格式,用于描述文檔的內(nèi)容及其變更。但是其并沒有像Delta那么清晰的表達,JSON結(jié)構(gòu)主要是AttributePool內(nèi),而對于文本內(nèi)容的表達則是純文本的結(jié)構(gòu)。
文檔描述
文檔內(nèi)容是使用ClientVars的數(shù)據(jù)結(jié)構(gòu)表示的,其中包含了三個部分,apool文本屬性池、text文本內(nèi)容、attribs屬性描述。在下面的例子中,我們描述了標題、加粗、斜體、純文本的內(nèi)容,那么這其中的內(nèi)容如下所示。
({
initialAttributedText: {
text: "short description\n*Heading1\ntext\n*Heading2\nbold italic\nplain text\n\n",
attribs: "*0|1+i*0*1*2*3+1*0|2+e*0*4*2*3+1*0|1+9*0*5+4*0+1*0*6+6*0|2+c|1+1",
},
apool: {
numToAttrib: {
"0": ["author", "a.XYe86foM7oYgmpuu"],
"1": ["heading", "h1"],
"2": ["insertorder", "first"],
"3": ["lmkr", "1"],
"4": ["heading", "h2"],
"5": ["bold", "true"],
"6": ["italic", "true"],
},
nextNum: 7,
},
});
對于這個內(nèi)容直接看上去是比較復雜的,當然實際上也是比較復雜的。apool是一個屬性池,所有對于文本內(nèi)容的裝飾都是在這里存儲的,也就是其中的numToAttrib屬性存儲的[string, string]值,nextNum則是下個要放置的索引。text則是純文本的內(nèi)容,相當于此時文檔的純文本內(nèi)容。attribs則是根據(jù)text的純文本內(nèi)容,并且取得apool中的屬性值,相當于裝飾文本內(nèi)容的編碼。
因此attribs需要單獨拿出來解析,*n表示取第n個屬性應用到文本上,通常需要配合|n和+n屬性使用,|n表示影響n行,僅對于\n屬性需要使用該屬性,+n則表示取出n個字符數(shù),相當于retain操作,不僅可以移動指針,還可以用來持有屬性變更。特別需要注意的是|m不會單獨出現(xiàn),其總是與+n一同表達,表示這n個字符中存在m個換行,且最后一個應用的字符必然是\n。
此外,EasySync里面的數(shù)字大都是36進制的,因此這里的+i/+e等都不是特殊的符號,需要用0-9數(shù)字來表示0-9的字符,而10-35則是表示a-z,例如+i就是i - a = 8 => 8 + 10 = 18。
*0表示取出author的屬性,|1+i表示將其應用到了i長度即18,字符為short description\n,由于其包含\n則定義|1。*0*1*2*3表示取出前4個屬性,+1表示將其應用1個字符,即*字符,在EasySync中行首的該字符承載了行屬性,而非放置\n中。*0表示取出author的屬性,|2+e表示應用了e長度即14,字符為Heading1\ntext\n,其包含兩個\n則定義|2。*0*4*2*3表示取出相關(guān)屬性,+1表示將其應用1個字符,即*字符表示行屬性內(nèi)容。*0|1+9表示取出author的屬性,+9表示將其應用9個字符,即Heading2\n,末尾是\n則定義|1。*0*5+4表示取出加粗等屬性,應用4個字符,即bold。*0+1表示取出author的屬性,應用1個字符即空格。*0*6+6表示取出斜體等屬性,應用6個字符,即italic。*0|2+c表示取出相關(guān)屬性,應用12個字符即\nplain text\n,存在兩個\n則定義|2。|1+1表示末尾的\n屬性,在EasySync中行末的該字符需要單獨定義。
變更描述
OT操作變換的基準原子實現(xiàn)就是insert、delete、retain三種操作,那么ChangeSet的內(nèi)容描述自然也是類似,但是數(shù)據(jù)的變更描述并非像delta的結(jié)構(gòu)那么清晰,而是特別設計了一套數(shù)據(jù)結(jié)構(gòu)描述。
文檔在最開始創(chuàng)建或是導入的時候是初始的ClientVars,而此后每次對于文檔內(nèi)容的修改則是會生成ChangeSet。針對于上述的三種操作對應了三種符號,=表示retain、+表示insert、-表示delete,這三種符號的組合就可以描述文檔內(nèi)容的變更,除此之外還有額外的定義:
Z: 首字母為MagicNumber,表示為符號位。:N: 文檔原始內(nèi)容長度為N。>N: 最終文檔長度會比原始文檔長度長N。<N: 最終文檔長度會比原始文檔長度短N。+N: 實際執(zhí)行操作,表示插入了N個字符。-N: 實際實際操作,表示操作刪除了N個字符。=N: 實際執(zhí)行操作,表示操作保留了N個字符,移動指針或應用屬性。|N: 表示影響了N行,與上述文檔描述一致需要與+/-/=N使用,操作的長度包含N個\n,且末尾操作必須是\n。文檔最末尾的\n需要表示的話,則必須要用|1=1表示。*I: 表示應用屬性,I為apool中的索引值,在一個+、=或|之前可以有任意數(shù)量的*操作。$: 表示結(jié)束符號,用于標記Operation部分的結(jié)束。char bank: 用于存儲insert操作具體的字符內(nèi)容,在執(zhí)行插入操作時按序取用。
同樣是上述的例子,現(xiàn)在的文檔中已經(jīng)存在exist text\n\n的文本內(nèi)容,緊接著將上述的內(nèi)容粘貼到文檔中,那么發(fā)生的User ChangeSet的變更描述如下:
({
changeset:
"Z:c>1t|1=b*0|1+i*0*1*2*3+1*0|2+e*0*4*2*3+1*0|1+9*0+5*0*5+6*0|1+1*0+a$short description\n*Heading1\ntext\n*Heading2\nbold italic\nplain text",
apool: {
numToAttrib: {
"0": ["author", "a.XYe86foM7oYgmpuu"],
"1": ["heading", "h1"],
"2": ["insertorder", "first"],
"3": ["lmkr", "1"],
"4": ["heading", "h2"],
"5": ["italic", "true"],
},
nextNum: 6,
},
});
Z表示MagicNumber,即符號位。c表示文檔原始內(nèi)容長度為12,即exist text\n\n內(nèi)容長度。>1t表示最終文檔會比原始內(nèi)容長度多1t,36進制轉(zhuǎn)換1t為64,具體為char bank索引。|1=b表示移動指針長度為b,轉(zhuǎn)換長度為11,文本內(nèi)容為exist text\n,末尾為\n定義|1。*0|1+i表示從apool中取出0屬性,應用i轉(zhuǎn)換長度為18,文本內(nèi)容為short description\n,末尾為\n定義|1。*0*1*2*3+1表示取出4個屬性,應用為1,文本內(nèi)容為*,具體則是行屬性的起始標記。*0|2+e表示取出0屬性,應用e轉(zhuǎn)換長度為14,文本內(nèi)容為Heading1\ntext\n,末尾為\n且包含兩個\n定義|2。*0*4*2*3+1表示取出4個屬性,應用為1,文本內(nèi)容為*,同樣是行屬性的起始標記。*0|1+9表示取出0屬性,應用長度為9,文本內(nèi)容為Heading2\n,末尾為\n定義|1。*0+5表示取出0屬性,應用長度為5,文本內(nèi)容為bold。*0*5+6表示取出斜體等屬性,應用長度為6,文本內(nèi)容為italic。*0|1+1表示取出0屬性,應用長度為1,末尾為\n則定義|1,文本內(nèi)容為\n。*0+a表示取出0屬性,應用長度為a即10,文本內(nèi)容為plain text。$表示結(jié)束符號,后續(xù)的內(nèi)容符號則為char bank,最末尾的\n通常不需要表示,即使表示也需要|1=1單獨表示。
Slate
slate的數(shù)據(jù)結(jié)構(gòu)以及選區(qū)的設計幾乎完全對齊了DOM結(jié)構(gòu),且數(shù)據(jù)結(jié)構(gòu)設計并未獨立出來,同樣基于JSON的結(jié)構(gòu),非常類似于低代碼的結(jié)構(gòu)設計。操作變換是直接在slate的核心模塊Transform中實現(xiàn),且位置相關(guān)操作變換的實現(xiàn)分散在Point、Path對象中。
[
{
type: "paragraph",
children: [
{ text: "This is editable " },
{ text: "rich", bold: true },
{ text: " text." },
],
},
{ type: "block-quote", children: [{ text: "A wise quote." }] },
];
Operation
同樣是基于OT實現(xiàn)操作變換算法,線性的數(shù)據(jù)結(jié)構(gòu)僅需要insert、delete、retain三種基本操作即可實現(xiàn),而在slate中則實現(xiàn)了9種原子操作來描述變更,這其中包含了文本處理、節(jié)點處理、選區(qū)變換的操作等。
insert_node: 插入節(jié)點。insert_text: 插入文本。merge_node: 合并節(jié)點。move_node: 移動節(jié)點。remove_node: 移除節(jié)點。remove_text: 移除文本。set_node: 設置節(jié)點。set_selection: 設置選區(qū)。split_node: 分割節(jié)點。
實際上僅實現(xiàn)應用還好,其相對應的invert、transform則會更加復雜。在slate中的inverse相關(guān)操作在operation.ts中實現(xiàn),與位置相關(guān)的transform在path.ts、point.ts中有相關(guān)實現(xiàn)。
而實際上這些操作通常都不會在編輯器中直接調(diào)用,slate針對這些最基礎的操作進行了封裝,實現(xiàn)了Transforms模塊。在這個模塊中實現(xiàn)了諸多具體的操作,例如insertNodes、liftNodes、mergeNodes、moveNodes、removeNodes等等,這里的操作就遠不止9種類型了。
insertFragment: 在指定的位置插入節(jié)點的片段。insertNodes: 在指定的位置插入節(jié)點。removeNodes: 在文檔中指定的位置刪除節(jié)點。mergeNodes: 在某個節(jié)點與同級的前節(jié)點合并。splitNodes: 在某個節(jié)點中的指定位置分割節(jié)點。wrapNodes: 在某個節(jié)點中的指定位置包裹一層節(jié)點。unwrapNodes: 在某個節(jié)點中的指定位置解除一層包裹節(jié)點。setNodes: 在某個節(jié)點中的指定位置設置節(jié)點屬性。unsetNodes: 在某個節(jié)點中的指定位置取消節(jié)點屬性。liftNodes: 在某個節(jié)點中的指定位置提升一層節(jié)點。moveNodes: 在文檔中的指定位置移動節(jié)點。collapse: 將選區(qū)折疊為插入符。select: 主動設置選區(qū)位置。deselect: 取消選區(qū)位置。move: 移動選區(qū)位置。setPoint: 設置選區(qū)的單側(cè)位置。setSelection: 設置新選區(qū)位置。delete: 刪除選區(qū)內(nèi)容。insertText: 在選區(qū)位置插入文本。transform: 在編輯器上immutable地執(zhí)行op。
OT-JSON
類似的,在OT-JSON(json0)中實現(xiàn)了11種操作,富文本場景中SubType仍然需要擴展,那自然就需要更多的操作來描述變更。因此,實際上以JSON嵌套的數(shù)據(jù)格式來描述內(nèi)容變更,要比線形的操作復雜得多。
在slate中是自行封裝了編輯器的基礎op,如果其本身是在OT-JSON的基礎上封裝Transforms的話,對于實現(xiàn)OT的協(xié)同會更方便一些,ShareDB等協(xié)同框架都是要參考OTTypes的定義的。當然,基于CRDT實現(xiàn)的協(xié)同看起來更加容易處理。
{p:[path], na:x}: 在指定的路徑[path]值上加x數(shù)值。{p:[path,idx], li:obj}: 在列表[path]的索引idx前插入對象obj。{p:[path,idx], ld:obj}: 從列表[path]的索引idx中刪除對象obj。{p:[path,idx], ld:before, li:after}: 用對象after替換列表[path]中索引idx的對象before。{p:[path,idx1], lm:idx2}: 將列表[path]中索引idx1的對象移動到索引idx2處。{p:[path,key], oi:obj}: 向路徑[path]中的對象添加鍵key和對象obj。{p:[path,key], od:obj}: 從路徑[path]中的對象中刪除鍵key和值obj。{p:[path,key], od:before, oi:after}: 用對象after替換路徑[path]中鍵key的對象before。{p:[path], t:subtype, o:subtypeOp}: 對路徑[path]中的對象應用類型為t的子操作o,子類型操作。{p:[path,offset], si:s}: 在路徑[path]的字符串的偏移量offset處插入字符串s,內(nèi)部使用子類型。{p:[path,offset], sd:s}: 從路徑[path]的字符串的偏移量offset處刪除字符串s,內(nèi)部使用子類型。
總結(jié)
數(shù)據(jù)結(jié)構(gòu)的設計是非常重要的,對于編輯器來說,數(shù)據(jù)結(jié)構(gòu)的設計直接影響著選區(qū)模型、DOM模型、狀態(tài)管理等模塊的設計。在這里我們聊到了很多的數(shù)據(jù)結(jié)構(gòu)設計,Delta、Changeset的線性結(jié)構(gòu),Slate的嵌套結(jié)構(gòu),每種數(shù)據(jù)都有著各自的設計與考量。
那么在選定好了數(shù)據(jù)結(jié)構(gòu)后,就可以在此基礎上實現(xiàn)編輯器的各個模塊。我們接下來會從數(shù)據(jù)模型出發(fā),設計選區(qū)模型的表示,然后在此基礎上實現(xiàn)瀏覽器選區(qū)與編輯器選區(qū)模型的同步。通過選區(qū)模型作為操作的目標,來實現(xiàn)編輯器的基礎操作,例如插入、刪除、格式化等操作。
每日一題
參考
- https://github.com/slab/delta/blob/main/src/Delta.ts
- https://github.com/slab/delta/blob/main/src/AttributeMap.ts
- https://github.com/ether/etherpad-lite/tree/develop/doc/public/easysync
- https://github.com/ether/etherpad-lite/blob/develop/src/static/js/Changeset.ts
- https://github.com/ether/etherpad-lite/blob/develop/src/static/js/AttributePool.ts
- https://github.com/ianstormtaylor/slate/blob/main/packages/slate/src/interfaces/operation.ts
- https://github.com/ianstormtaylor/slate/blob/main/packages/slate/src/interfaces/transforms/general.ts

浙公網(wǎng)安備 33010602011771號