從零實現富文本編輯器#7-基于組合事件的半受控輸入模式
在先前我們實現了編輯器選區和模型選區的雙向同步,來實現受控的選區操作,這是編輯器中非常重要的基礎能力。接下來我們需要在編輯器選區模塊的基礎上,通過瀏覽器的組合事件來實現半受控的輸入模式,在這里我們需要處理瀏覽器復雜DOM結構默認行為,還需要兼容IME輸入法的各種輸入場景。
- 開源地址: https://github.com/WindRunnerMax/BlockKit
- 在線編輯: https://windrunnermax.github.io/BlockKit/
- 項目筆記: https://github.com/WindRunnerMax/BlockKit/blob/master/NOTE.md
從零實現富文本編輯器項目的相關文章:
- 深感一無所長,準備試著從零開始寫個富文本編輯器
- 從零實現富文本編輯器#2-基于MVC模式的編輯器架構設計
- 從零實現富文本編輯器#3-基于Delta的線性數據結構模型
- 從零實現富文本編輯器#4-瀏覽器選區模型的核心交互策略
- 從零實現富文本編輯器#5-編輯器選區模型的狀態結構表達
- 從零實現富文本編輯器#6-瀏覽器選區與編輯器選區模型同步
- 從零實現富文本編輯器#7-基于組合事件的半受控輸入模式
編輯器輸入模式
Input模塊是處理輸入的模塊,輸入是編輯器的核心操作之一,我們需要處理輸入法、鍵盤、鼠標等輸入操作。輸入法的交互處理是需要非常多的兼容處理,例如輸入法還存在候選詞、聯想詞、快捷輸入、重音等等。甚至是移動端的輸入法兼容更麻煩,在draft中還單獨列出了移動端輸入法的兼容問題。
編輯器輸入模塊與選區模塊類似,都需要在瀏覽器DOM的基礎上處理其默認行為,特別是需要喚醒輸入法的輸入則需要更多模塊的聯動,因此還需要復雜的兼容性適配。而輸入模式本身則分為三種類型,即非受控輸入、半受控輸入和受控輸入,每種輸入模式都有其特定的使用場景和實現方式。
非受控輸入
非受控的方法,指的是完全依賴瀏覽器的默認行為來處理輸入操作,而不需要對輸入進行干預或修改,當DOM結構發生變化后需要收集變更,再應用到編輯器中。這種方式可以最大限度利用瀏覽器原生能力,包括選區、光標等,然而其最大的問題就是輸入不受控制,無法阻止默認行為,不夠穩定。
舉個目前比較常見的例子,ContentEditable無法真正阻止IME的輸入,這就導致了我們無法真正接管中文的輸入行為。在下面的這個例子中,輸入英文和數字是不會有響應的,但是中文卻是可以正常輸入的,這也是很多編輯器選擇自繪選區和受控輸入的原因之一,例如VSCode、釘釘文檔等。
<div contenteditable id="$1"></div>
<script>
const stop = (e) => {
e.preventDefault();
e.stopPropagation();
};
$1.addEventListener("beforeinput", stop);
$1.addEventListener("input", stop);
$1.addEventListener("keydown", stop);
$1.addEventListener("keypress", stop);
$1.addEventListener("keyup", stop);
$1.addEventListener("compositionstart", stop);
$1.addEventListener("compositionupdate", stop);
$1.addEventListener("compositionend", stop);
</script>
采用非受控方法輸入的時候,我們需要MutationObserver來確定當前正在輸入字符,之后通過解析DOM結構得到最新的Text Model。緊接著需要與原來的Text Model做diff,由此來得到變更的ops,這樣就可以應用到當前的Model中進行后續的工作了。
即使是非受控的輸入也存在多種實現的方案,例如可以在觸發Input事件后以行為基礎做文本diff,得到ops后就可以根據schema組合屬性。或者也可以完全依賴MutationObserver來得到節點級別的片段變更,在此基礎上再做diff,著名的quill編輯器就是如此實現的。
quill針對輸入的處理本身并不復雜,雖然涉及到非常多處的事件通信以及特殊case處理,但核心邏輯還是比較清晰的。但是我覺得有一點比較麻煩的是,quill封裝的視圖層parchment并不在核心包中,雖然繼承重寫了部分方法,但是諸如Text是直接導出的,很多地方還是很難調試。
整體來說,quill的非受控輸入分為兩種處理,如果普通的ASCII輸入則直接根據MutationRecord的oldValue與最新的newText文本進行對比,得到變更的ops。若是IME的輸入,例如中文輸入的內容,則會導致多次Mutation,此時就會進行全量delta的diff得到變更。
// https://github.com/slab/quill/blob/07b68c9/packages/quill/src/core/editor.ts#L273
const oldDelta = this.delta;
if (
mutations.length === 1 &&
mutations[0].type === 'characterData' &&
mutations[0].target.data.match(ASCII)
) {
const textBlot = this.scroll.find(mutations[0].target) as Blot;
const index = textBlot.offset(this.scroll);
const oldValue = mutations[0].oldValue.replace(CursorBlot.CONTENTS, '');
const oldText = new Delta().insert(oldValue);
const newText = new Delta().insert(textBlot.value());
const diffDelta = new Delta()
.retain(index)
.concat(oldText.diff(newText, relativeSelectionInfo));
} else {
this.delta = this.getDelta();
if (!change || !isEqual(oldDelta.compose(change), this.delta)) {
change = oldDelta.diff(this.delta, selectionInfo);
}
}
這里需要關注的問題是,為什么textBlot能夠得到最新的值,無論是在MutationRecord中還是在getDelta中,都是通過textBlot.value()來獲取最新的文本內容。getDelta部分是迭代了一遍所有的Bolt來重新得到最新的value,這部分按行存在緩存,否則性能容易出問題。
// https://github.com/slab/quill/blob/07b68c9/packages/quill/src/core/editor.ts#L162
this.scroll.lines().reduce((delta, line) => {
return delta.concat(line.delta());
}, new Delta());
// https://github.com/slab/quill/blob/07b68c9/packages/quill/src/blots/block.ts#L183
function blockDelta(blot: BlockBlot, filter = true) {
return blot
.descendants(LeafBlot)
.reduce((delta, leaf) => {
if (leaf.length() === 0) {
return delta;
}
return delta.insert(leaf.value(), bubbleFormats(leaf, {}, filter));
}, new Delta())
.insert('\n', bubbleFormats(blot));
}
TextBlot就是定義在parchment中的實現,因此這里調試起來就比較麻煩。首先需要關注的是更新到最新的文本,我們只關注于純文本內容更新即可,Blot中存在更新的方法,當DOM發生變化之后就會觸發該方法,這里需要注意更新是從靜態方法上得到的,而不是實例的.value。
// https://github.com/slab/parchment/blob/3d0b71c/src/blot/text.ts#L80
public update(mutations: MutationRecord[], _context: { [key: string]: any }): void {
if (
mutations.some((mutation) => {
return (mutation.type === 'characterData' && mutation.target === this.domNode);
})
) {
this.text = this.statics.value(this.domNode);
}
}
public static value(domNode: Text): string {
return domNode.data;
}
此外需要關注的是更新時機,也就是說調用時機必須要先更新Blot的內容,以此來得到最新的文本內容,最后再調度scroll的update來更新編輯器模型。我們主要關注輸入的變更,這里其實還有諸如format引起的DOM結構變更,屬于optimize方法處理MutationRecord部分。
// https://github.com/slab/parchment/blob/3d0b71c/src/blot/scroll.ts#L205
// handleCompositionEnd - batchEnd - scrollUpdate - blotUpdate - editorUpdate
mutations
.map((mutation: MutationRecord) => {
const blot = this.find(mutation.target, true);
// ...
})
.forEach((blot: Blot | null) => {
if (blot != null && blot !== this && mutationsMap.has(blot.domNode)) {
blot.update(mutationsMap.get(blot.domNode) || [], context);
}
});
這里還有個有趣的實現是在執行diff方法時的cursor參數,考慮到一個問題,文本若是從xxx變更到xx,那么就存在很多種可能。在這里可以是在任意一個位置刪除一個字符,也可以是在光標處向前forward刪除一個字符,甚至是刪除兩個x再插入一個x。
因此如果想比較精確地得到變更的ops,那就需要將光標位置傳入diff方法中,以此可以將字符串切為三段,前綴和后綴是相同的,中間就可以作為差異部分。輸入這部分是非常高頻的操作,這種方式就不需要實際參與到復雜的diff流程當中,以更高的性能來處理文本變更。
// https://github.com/jhchen/fast-diff/blob/da83236/diff.js#L1039
var newBefore = newText.slice(0, newCursor);
var newAfter = newText.slice(newCursor);
var prefixLength = Math.min(oldCursor, newCursor);
var oldPrefix = oldBefore.slice(0, prefixLength);
var newPrefix = newBefore.slice(0, prefixLength);
var oldMiddle = oldBefore.slice(prefixLength);
var newMiddle = newBefore.slice(prefixLength);
return remove_empty_tuples([
[DIFF_EQUAL, before],
[DIFF_DELETE, oldMiddle],
[DIFF_INSERT, newMiddle],
[DIFF_EQUAL, after],
]);
半受控輸入
半受控的方法,指的是通過BeforeInputEvent以及CompositionEvent分別處理英文輸入、內容刪除以及IME輸入,以及額外的KeyDown、Input事件來輔助完成這部分工作。通過這種方式就可以劫持用戶的輸入,由此構造變更來應用到當前的內容模型。
當然對于類似CompositionEvent需要一些額外的處理,因為先前我們也提到了IME的輸入是無法完全受控的,因此半受控也是當前主流的實現方法。當然由于瀏覽器的兼容性,通常會需要對BeforeInputEvent做兼容,例如借助React的合成事件或者onKeyDown來完成相關的兼容。
slate編輯器的輸入模式就是半受控的實現方式,主要是基于beforeinput事件以及composition相關事件來處理輸入和刪除操作。在slate剛開始實現的時候,beforeinput事件還沒有被廣泛支持,但是現在已經可以在大多數現代瀏覽器中使用了,composition事件則早已廣泛支持。
首先來看受控的部分,我們的受控特指可以阻止默認的輸入行為,而我們可以根據相關事件主動更新編輯器模型。在輸入這個場景我們主要關注insert相關的inputType即可,只不過輸入上還有大量的模式需要處理,此外slate還存在大量兼容性邏輯來處理各種瀏覽器的實現問題。
// https://github.com/ianstormtaylor/slate/blob/ef76eb4/packages/slate-react/src/components/editable.tsx#L550
switch (event.inputType) {
case 'insertFromComposition':
case 'insertFromDrop':
case 'insertFromPaste':
case 'insertFromYank':
case 'insertReplacementText':
case 'insertText': {
if (typeof data === 'string') {
Editor.insertText(editor, data)
}
}
}
從上面的示例中可以看出,inputType本身存在大量的操作類型分支需要處理,而本身除了輸入、刪除之外,還存在諸如格式化、歷史記錄等操作類型。不過在這里我們還是主要關注輸入、刪除相關的操作,下面是比較常見可能需要處理的inputType類型:
insertText: 插入文本,通常是通過鍵盤輸入。insertReplacementText: 替換當前選區或單詞的文本,例如通過拼寫校正或自動完成。insertLineBreak: 插入換行符,通常是按下回車鍵。insertParagraph: 插入一個段落分隔符,通常存在于ContentEditable元素中按回車鍵。insertFromDrop: 通過拖拽操作插入內容。insertFromPaste: 通過粘貼操作插入內容。insertTranspose: 調換兩個字符的位置,常見于MacOS的Ctrl+T操作。insertCompositionText: 插入輸入法IME中的組合文本。deleteWordBackward: 向后刪除一個單詞,例如Option+Backspace。deleteWordForward: 向前刪除一個單詞,例如Option+Delete。deleteSoftLineBackward: 向后刪除一行,當換行是自動換行時。deleteSoftLineForward: 向前刪除一行,當換行是自動換行時。deleteEntireSoftLine: 刪除當前所在的整個軟換行。deleteHardLineBackward: 向后刪除一行,當換行是硬回車時。deleteHardLineForward: 向前刪除一行,當換行是硬回車時。deleteByDrag: 通過拖拽的方式刪除內容。deleteByCut: 通過剪切操作刪除內容。deleteContent: 向前刪除內容,即Delete鍵。deleteContentBackward: 向后刪除內容,即Backspace鍵。
實際上這些事件我們很難全部關注到,特別是軟回車相關的內容在瀏覽器實現的編輯器中應用不多,因此這部分我們可以直接將其認為是硬回車來操作。在quill和slate中都是作為硬回車處理的,而TinyMCE、TipTap都有軟回車的實現,即Shift+Enter會插入<br>而非創建新段落。
事件中相關的信息傳遞需要關注,例如deleteWord是需要刪除詞級別的內容的,這部分數據范圍是通過getTargetRanges得到StaticRange數組傳遞。此外,諸如insertCompositionText、insertFromPaste也都是可以在Composition事件、Paste事件中來實際處理。
// [StaticRange]
[{
collapsed: false,
endContainer: text,
endOffset: 4,
startContainer: text,
startOffset: 2
}]
接下來我們可以關注slate中非受控部分,這也就是由于無法真正接管IME輸入而導致的必須要兼容的問題。slate中這部分兼容起來也有點復雜,在不同瀏覽器中的表現還不一致,例如在safari中存在insertFromComposition的類型,都是需要在類似的時機修正編輯器模型。
除了無法阻止默認行為外,非受控的表現還體現在對于DOM結構的修改,這部分甚至于可以說是最難以處理的,因為只要喚醒了IME就意味著必然會修改DOM。那么就相當于這部分DOM是處于未知狀態的,若是出現了不可預知的DOM內容,則意味著編輯器模型同步狀態被破壞,這就需要額外兼容。
// https://github.com/ianstormtaylor/slate/blob/ef76eb4/packages/slate-react/src/components/editable.tsx#L1299
// COMPAT: In Chrome, `beforeinput` events for compositions
// aren't correct and never fire the "insertFromComposition"
// type that we need. So instead, insert whenever a composition
// ends since it will already have been committed to the DOM.
if (
!IS_WEBKIT &&
!IS_FIREFOX_LEGACY &&
!IS_IOS &&
!IS_WECHATBROWSER &&
!IS_UC_MOBILE &&
event.data
) {
Editor.insertText(editor, event.data)
}
受控輸入
全受控的方法,指的是當執行任意內容輸入的時候,輸入的字符需要記錄,當輸入結束的時候將原來的內容刪除,并且構造為新的Model。全受控通常需要一個隱藏的輸入框甚至是iframe來完成,由于瀏覽器頁面上必須保持單一焦點,因此這種方式還需要伴隨著自繪選區的實現。
這其中也有很多細節需要處理,例如在CompositionEvent時需要繪制內容但不能觸發協同。此外如果需要實現與瀏覽器一致的輸入體驗,例如瀏覽器中喚醒輸入法時會有拼音狀態提示,這個提示不僅僅是用來展示的,若是按下左右按鍵是可以進行候選詞切換的,全受控模式下自然也需要模擬。
以受控模式實現的編輯器中,我們可以針對瀏覽器API的依賴程度來分為三類,瀏覽器依賴程度由高到低,也就意味著實現的難度由低到高。三種類型分別是依賴iframe焦點魔法以及Editable的類型、不依賴Editable而依賴DOM實現自繪選區的類型、完全基于Canvas繪制的類型。
這三種類型我們分別可以找到典型的編輯器實現,依賴iframe魔法的TextBus等,自繪選區的釘釘文檔、Zoom文檔等,以及完全基于Canvas繪制的騰訊文檔、Google Doc等。實際上開源的編輯器中比較少實現受控的輸入模式,因為本身實現起來比較復雜,且需要大量的兼容性處理。
接下來我們分別來看一下這三種類型,首先需要聊到的就是iframe魔法的實現方式,這里就不得不提到瀏覽器焦點問題。在瀏覽器中,文本內容的選中效果是會將焦點放在選中的文本上的,而此時若是鼠標點擊到其他輸入框就會導致焦點轉移,可以通過document.activeElement來查看當前焦點。
<div tabindex="-1">選中文本后,點擊 input 可以觀察焦點轉移</div>
<input />
<script>
document.onselectionchange = () => {
console.log("Focused Element", document.activeElement);
};
</script>
至于什么樣子的元素可以獲得焦點,這個同樣是存在一定的規范的,諸如可編輯元素、tabindex屬性、a標簽等等,我們就不過多敘述了。那么這里的問題就在于,若是我們放置獨立的input來接收輸入,而不是直接依賴Editable輸入的話,就會出現瀏覽器選區的轉移問題,導致無法選中文本。
因此通常來說,在選擇使用額外的input來處理輸入后,就必須要自行繪制那個選區的效果,也就是我們俗稱的拖藍。然而在iframe存在的情況下,瀏覽器并不是非常嚴格的保持單一的選區效果,這也就是我們所謂的魔法,即前面提到的TextBus非常特殊實現。
TextBus沒有使用ContentEditable這種常見的實現方案,也沒有像CodeMirror或者Monaco一樣自繪選區。從Playground的DOM節點上來看,其是維護了一個隱藏的iframe來實現的,這個iframe內存在一個textarea,以此來處理IME的輸入。
那么先來看一個簡單的例子,以iframe和文本選區的焦點搶占為例,可以發現在iframe不斷搶占的情況下,我們是無法拖拽文本選區的。這里值得一提的是,我們不能直接在onblur事件中進行focus,這個操作會被瀏覽器禁止,必須要以宏任務的異步時機觸發。
<span>123123</span>
<iframe id="$1"></iframe>
<script>
const win = $1.contentWindow;
win.addEventListener("blur", () => {
console.log("blur");
setTimeout(() => $1.focus(), 0);
});
win.addEventListener("focus", () => console.log("focus"));
win.focus();
</script>
注意我們的焦點聚焦調用是直接調用的$1.focus,假如此時是調用win.focus的話,就可以發現文本選區是可以拖拽的。通過這個表現其實可以看出來,框架內外的文檔的選區是完全獨立的,如果焦點在同個框架內則會相互搶占,如果不在同個框架內則是可以正常表達,也就是$1和win的區別。
另外可以注意到此時文本選區是灰色的,這個可以用::selection偽元素來處理樣式,而且各種事件都是可以正常觸發的,例如SelectionChange事件以及手動設置選區等。如果直接在iframe中放置textarea的話,同樣也可以正常的輸入內容,并且不會打斷IME的輸入法。
<span>123123</span>
<iframe id="$1"></iframe>
<script>
const win = $1.contentWindow;
const textarea = document.createElement("textarea");
$1.contentDocument.body.appendChild(textarea);
textarea.focus();
textarea.addEventListener("blur", () => {
setTimeout(() => textarea.focus(), 0);
});
win.addEventListener("blur", () => console.log("blur"));
win.addEventListener("focus", () => console.log("focus"));
win.focus();
</script>
最主要的是,這個Magic的表現在諸多瀏覽器都可以正常觸發。當然這里主要指的是PC端的瀏覽器,若是在移動端的瀏覽器中表現還不太一樣,其實在移動端的瀏覽器按鍵輸入的事件規范不統一就容易存在問題,例如draft.js在README中提到了移動端是Not Fully Supported。
而對于完全實現自繪選區的編輯器,目前我還沒有關注到有開源的實現,因為其本身做起來就比較復雜,特別是需要模擬整個瀏覽器的交互行為。瀏覽器確實是處理了相當多的選區交互細節,例如拖拽的時候即使不在文本上,選區也是可以向下延伸的,以及拖拽字符的中間是選中與否的分界線等。
不過富文本編輯器是沒有太關注到,但是代碼編輯器例如CodeMirror、VSCode(Monaco)都是自繪選區的實現,商業化的在線文檔產品例如釘釘文檔、Zoom文檔、有道云筆記也是自繪選區。由于選區的DOM通常都是不會響應任何鼠標事件的,所以可以直接使用DOM操作來查找調試。
document.querySelectorAll(`[style*="pointer-events: none;"]`);
[...document.querySelectorAll("*")].filter(node => node.style.pointerEvents === "none");
當然像是釘釘文檔這種將其作為web-component的實現方式,就需要我們稍微費點勁找一下了。此外,先前我們也提到過一種自繪選區的實現方式,即通過caretRangeFromPoint以及caretPositionFromPoint兩個API來計算選區位置,可以參考先前的瀏覽器選區模型的核心交互策略文章。
最后是完全采用Canvas進行繪制的編輯器實現,這種方式那就是相當麻煩了,無論是文本還是選區全部都要自己繪制。由于瀏覽器對于Canvas僅僅是提供了最基礎的API,這就是非常單純的空畫板,所有的想做的東西都需要自己去繪制,事件流也都需要自行模擬,非常麻煩。
目前比較典型的實現是Google Doc和騰訊文檔,這兩款商業的文檔編輯器都是完全基于Canvas進行繪制的。Google Doc作為最先用Canvas實現的編輯器,還專門寫了文章來介紹其舊版與新版的不同,主要是提了編輯界面以及布局引擎的更新,鏈接放在了最后的參考文章部分。
有趣的是,對比相對受控DOM輸入的編輯器,基于Canvas實現的富文本編輯器反而是有開源實現,canvas-editor就是目前做的比較好的使用Canvas繪制的開源富文本編輯器。不過除了非常需要類似word的分頁、排版、打印等明確需求場景,否則真靠Canvas實現成本還是相當高的。
這里的實現成本高主要體現在兩點,首先是編輯器圖文的排版引擎是需要自己實現的。例如在word中我們編寫的文字正好排滿了一行,假如在這里再加一個句號,那么前邊的字就會擠一擠,從而可以使這個句號不需要換行。而如果我們再敲一個字的話,這個字是會換行的。
<!-- word -->
文本文本文本文本文本文本文本文本文本文本文本文本文本文本。
<!-- 瀏覽器 -->
文本文本文本文本文本文本文本文本文本文本文本文本文本文本
。
而如上所示在瀏覽器中這個行為是不同的,所以假如需要突破瀏覽器的排版限制,就需要自己實現排版能力。每個字渲染的位置、渲染的折行換行策略等等都需要自行實現,需要考慮的邊界條件都會相當多。在先前我基于Canvas實現的簡歷編輯器中,就在富文本繪制的排版部分寫了很久。
再就是選區繪制的實現,在前邊我們提到了caretRangeFromPoint以及caretPositionFromPoint兩個API來計算選區位置,這是瀏覽器提供的選區計算能力。而使用Canvas繪制的文本是不會涉及到DOM的,因此這些內容都需要自行計算,不過諸如字寬等信息都會保存,算起來不是很復雜。
雖然Canvas可以實現脫離瀏覽器的排版限制,并且可以拋棄DOM復雜兼容性帶來的性能問題。但是使用Canvas本身的復雜性本身就是最大的問題,而且拋棄DOM就相當于拋棄了現在的相關生態,諸如SEO、A11Y等都不能夠直接使用,因此沒有絕對的需求場景還是需要慎重。
回到內容的輸入部分,由于在瀏覽器中我們不能直接與IME交互,因此還是只能與瀏覽器的輸入事件處理,也就是仍然只能使用input來做輸入,與DOM實現自繪選區的模式一致。基于Canvas實現富文本,相當于實現了一個瀏覽器的排版引擎,這個工作量還是非常大的。
這里再提一個關于輸入的協同問題,前面提到輸入法按下左右按鍵是可以進行候選詞切換的,那么在協同的情況下這段文本內容的變更自然不應該協同到其他客戶端。然而這段文本是會變更內容長度的,協同不能直接過濾local屬性,而且完全模擬瀏覽器行為也是帶格式的,不能直接脫離現有的渲染框架。
因此這部分協同的輸入需要額外處理,最簡單的方法是臨時關閉協同處理,將最終狀態是希望將狀態合并起來再協同出去。再有就是在AXY的調度模型的基礎上,實現擴展Z即本地隊列,但由于隊列內容已經本地應用,需要實現op在隊列前后移動的方法,臨時狀態協同這部分我們后續再細聊。
半受控輸入實現
基于上述的輸入模式概述,我們將重點放在半受控輸入模式的實現上,因為半受控輸入模式是目前大多數富文本編輯器的主流實現方式。通常來說,半受控模式下能夠在保證用戶輸入體驗的同時,提供相對較好的控制和靈活性。基于之前聊到過輸入的設計與抽象,我們可以比較簡單地設計整個流程:
- 通過選區映射到我們自行維護的
Range Model,包括選區變換時根據DOMRange映射到Model,這一步需要比較多的查找和遍歷,還需要借助我們之前聊的WeakMap對象來查找Model來計算位置。 - 通過鍵盤進行輸入,借助于瀏覽器的
BeforeInputEvent以及CompositionEvent分別處理輸入/刪除與IME輸入,基于輸入構造Delta Change應用到狀態結構上并且觸發ContentChange,視圖層由此進行更新。 - 當視圖層更新之后,需要根據瀏覽器的
DOM以及我們維護的Model刷新模型選區,這里的選區變更交互需要模擬瀏覽器行為,然后需要根據Model映射到DOMRange選區,再應用到瀏覽器的Selection對象中,這其中也涉及了很多邊界條件。
其實曾經我也想通過自繪選區和光標的形式來完成,因為我發現通過Editable來控制輸入太難控制了,特別是在IME中很容易影響到當前的DOM結構。由此還需要進行臟數據檢查,強行更新DOM結構,但是簡單了解了下聽說自繪的坑也不少,于是依然選用了大多數編輯器都在用的Editable。
實際上Editable的坑也很多,這其中有非常多的細節,我們很難把所有的邊界條件都處理完成,例如如何檢測DOM被破壞由此需要強制刷新。當我們將所有的邊界case都處理到位了,那么代碼復雜度就上來了,可能接下來就需要處理性能問題了,此時就可能有大量的計算,特別是對于大文檔來說。
在性能方面,除了上邊提到的WeakMap可以算作是一種優化方案之外,我們還有很多值得優化的地方。例如因為Delta數據結構的關系,我們在這里需要維護一個Range-RawRange選區的相互變換,而在這其中由于我們對LineState的start和size有所記錄,也就是空間換時間。
那么我們在變換查找的時候就可以考慮到用二分的方法,因為start必然是單向遞增的。此外,由于我們是完全可以推算出本次更新究竟是更新了什么內容,所以對于原本的狀態對象是可以通過計算來進行復用的,而不是每次更新都需要刷新所有的對象,實現immutable降低了維護的細節和難度。
還有一點,對于大文檔來說扁平化的數據結構應該是比較好的,扁平化意味著沒有那么復雜,例如現在的Delta就是扁平化的數據結構,但是隨機訪問的效率就稍微慢一點了。或許到了出現性能問題時,需要結合一些數據存儲的方案例如PieceTable,當然對于現在來說還是有點遠。
受控輸入模式
這里的受控輸入模式指的是不需要喚醒IME輸入法的部分,通常是指英文輸入、數字輸入等。在上述內容的基礎上,我們這里的實現就可以變得簡單了,只需要阻止所有默認行為,再受控處理原始的行為。我們以內容的輸入insertText和刪除deleteContentBackward為例,實現內容的輸入和刪除。
// packages/core/src/input/index.ts
onBeforeInput(event: InputEvent) {
event.preventDefault();
const { inputType, data = "" } = event;
const sel = this.editor.selection.get();
switch (inputType) {
case "deleteContent":
case "deleteContentBackward": {
this.editor.perform.deleteBackward(sel);
break;
}
case "insertText": {
data && this.editor.perform.insertText(sel, data);
break;
}
}
}
具體的變更實現則是封裝在perform類中,當插入文本內容時,需要先拿到當前選區所在的狀態節點,若是當前的節點是void節點,則避免內容的輸入。然后獲取當前準備好的折疊選區屬性值,或者在非折疊選區時的尾部屬性值,最后再構造變更的delta應用到編輯器即可。
// packages/core/src/perform/index.ts
const raw = RawRange.fromRange(this.editor, sel);
const point = sel.start;
const leaf = this.editor.lookup.getLeafAtPoint(point);
// 當前節點為 void 時, 不能插入文本
if (leaf && leaf.void) return void 0;
let attributes: AttributeMap | undefined = this.editor.lookup.marks;
if (!sel.isCollapsed) {
// 非折疊選區時, 需要以 start 起始判斷該節點的尾部 marks
const isLeafTail = isLeafOffsetTail(leaf, point);
attributes = this.editor.lookup.getLeafMarks(leaf, isLeafTail);
}
const delta = new Delta().retain(raw.start).delete(raw.len).insert(text, attributes);
this.editor.state.apply(delta, { range: raw });
在刪除內容時這里就變得復雜了起來,因為我們刪除的時候需要考慮到回車的狀態。這里的主要問題是,我們的行屬性是放置在回車節點上的,操作起來并不符合直覺。而EtherPad控制行格式的實現是放在行首,這里很多表現是與渲染相關的,因為無論是列表、引用等行格式的DOM渲染看起來都在行首。
這部分的交互策略會復雜不少,假如上一行是標題格式,當前行是引用格式,當前光標在行首,若是直接執行刪除,那么標題格式就會被刪除,引用格式也會合并到上一行。這個表現跟quill編輯器一致,因為本身還是由于數據結構引起的,若是需要需要更符合直覺,要么處理數據結構,要么處理內容變更。
而如果直接修改數據結構的話,也會導致很復雜的兼容實現,例如最基本的normalize就需要保證lmkr前不能直接連著文本,而是需要保證\n,包括塊結構等,此外還有文本的長度也需要判斷當前是否存在行首屬性等。因此,在這里我們盡可能保證數據結構文檔,而是通過處理變更的方式。
// packages/core/src/perform/index.ts
// 上一行為塊節點且處于當前行首時, 刪除則移動光標到該節點上
if (prevLine && isBlockLine(prevLine)) {
// 當前行為空時特殊處理, 先刪除掉該行
if (isEmptyLine(line)) {
const delta = new Delta().retain(line.start).delete(1);
this.editor.state.apply(delta, { autoCaret: false });
}
const firstLeaf = prevLine.getFirstLeaf();
const range = firstLeaf && firstLeaf.toRange();
range && this.editor.selection.set(range, true);
return void 0;
}
const attrsLength = Object.keys(line.attributes).length;
// 如果在當前行的行首, 且不存在其他行屬性, 則將當前行屬性移到下一行
if (prevLine && !attrsLength) {
const prevAttrs = { ...prevLine.attributes };
const delta = new Delta()
.retain(line.start - 1)
.delete(1)
.retain(line.length - 1)
.retain(1, prevAttrs);
this.editor.state.apply(delta);
return void 0;
}
而最基本的內容刪除處理則比較簡單,畢竟在這里只需要刪除長度為1的內容即可。當然,由于Emoji等內容的存在,其長度常常會大于1,還有按住alt+delete的話會以詞的角度刪除內容,這些長度不為1的內容長度我們后續會再處理。
// packages/core/src/perform/index.ts
const raw = RawRange.fromRange(this.editor, sel);
const start = raw.start - len;
const delta = new Delta().retain(start).delete(len);
this.editor.state.apply(delta, { range: raw });
看起來輸入的部分并沒有什么很復雜的點,但是這件事也并非這么容易。舉個例子,此時已經完成了通過選區映射到我們自行維護的Range Model,那么當進行輸入操作的時候,假設我們此時有兩個span,最開始當前的DOM結構是<span>DOM1</span><span>DO|M2</span>,|表示光標位置。
我們在第2個span的DO和M2字符之間插入內容x,此時無論是用代碼apply還是用戶輸入的方式,都會使得DOM2這個span由于apply造成ContentChange繼而DOM節點會刷新,也就是說就是第2個span已經不是原來的span而是創建了一個新對象。
那由于這個DOM變了導致瀏覽器光標找不到原本的DOM2這個span結構了,那么此時光標就變成了<span>DOM1|</span><span>DOxM2</span>。本身我們可能認為起碼在輸入的時候選區應該是會跟著變動的,然而實踐證明這個方法是不行的,這里的DOM節點并非一致的。
所以實際上在這里就是缺了一步根據我們的Range Model來更新DOM Range的操作,而且由于我們應該在DOM結構完成后盡早更新DOM Range。這個操作需要在useLayoutEffect中完成而不是useEffect中,也就對標了類組件的DidUpdate,更新DOM Range的操作應該是主動完成的。
// packages/react/src/model/block.tsx
/**
* 視圖更新需要重新設置選區 無依賴數組
*/
useLayoutEffect(() => {
const selection = editor.selection.get();
if (
!editor.state.get(EDITOR_STATE.COMPOSING) &&
editor.state.get(EDITOR_STATE.FOCUS) &&
selection
) {
// 更新瀏覽器選區
editor.logger.debug("UpdateDOMSelection");
editor.selection.updateDOMSelection(true);
}
});
非受控輸入模式
這里的非受控輸入模式指的是需要喚醒IME輸入法的部分,通常是指中文輸入、日文輸入等。既然是非受控的模式,就很容易導致一些問題,因為瀏覽器中輸入法的輸入會直接修改DOM,我們無法阻止這種行為,因此只能在DOM變更后再進行修正,這就是我們常說的臟DOM檢測。
舉個例子,最開始當前的DOM結構是<s>DOM1</s><b>DOM2</b>,此時我們在兩個DOM的最后輸入中文,也就是喚起了IME輸入。當我們輸入了"試試"這兩個字,需要注意的是這里不追加樣式,類似于inline-code,此時的DOM結構變成了<s>DOM1</s><b>DOM2試試</b><s>試試</s>。
很明顯在<b>標簽里邊的文字是異常的,我們此時的數據結構Delta內容上是沒問題的,因為我們定義的schema是不追加樣式的。然而也就是因為這樣造成了問題,我們關注到<b>這個節點,對我們當前節點而言其狀態以及delta沒有改變,但是DOM由于輸入法發生了變化。
那么由我們維護的Model映射到React維護的Fiber時,由于Model沒有變化那么React根據VDOM diff的結果發現沒有改變于是原地復用了這個DOM結構,而實際上這個DOM結構由于我們的IME輸入是已經被破壞了的,而我們無法受控地處理IME輸入就造成了問題。
由于英文輸入時我們阻止了默認行為,因此是不會去改變原本的DOM結構的,所以在這里我們需要進行臟數據檢查,并且將臟數據進行修正,確保最后的數據是正常的。目前采取的一個方案是對于最基本的Text組件進行處理,在ref回調中檢查當前的內容是否與op.insert一致,若不一致要清理掉除第一個節點外的所有節點,并且將第一個節點的內容回歸到原本的text內容上。
// packages/react/src/preset/text.tsx
/**
* 純文本節點的檢查更新
* @param dom DOM 節點
* @param text 目標文本
*/
export const updateDirtyText = (dom: HTMLElement, text: string) => {
if (text === dom.textContent) return false;
const nodes = dom.childNodes;
// 文本節點內部僅應該存在一個文本節點, 需要移除額外節點
for (let i = 1; i < nodes.length; ++i) {
const node = nodes[i];
node && node.remove();
}
// 如果文本內容不一致, 則是由于輸入的臟 DOM, 需要糾正內容
// Case1: [inline-code][caret][text] IME 會導致模型/文本差異
if (isDOMText(dom.firstChild)) {
dom.firstChild.nodeValue = text;
}
return true;
};
回到中文的輸入,我們這里需要關注到兩點,首先是喚醒IME輸入法時,我們需要避免編輯器的相關事件觸發,例如選區變換、輸入事件等。其次則是關注到輸入法結束的事件,這個事件是compositionend事件,在輸入法輸入結束后我們就可以在這里進行內容插入,以及上述的臟DOM檢查。
// packages/core/src/input/index.ts
onBeforeInput(event: InputEvent) {
if (this.editor.state.get(EDITOR_STATE.COMPOSING)) {
return void 0;
}
// ...
}
onCompositionEnd(event: CompositionEvent) {
event.preventDefault();
const data = event.data;
const sel = this.editor.selection.get();
data && sel && this.editor.perform.insertText(sel, data);
}
說到Composition事件的組合,其事件的執行順序為compositionstart、compositionupdate和compositionend三個事件,分別為輸入法的喚醒、更新、結束的事件。即使不實現編輯器,在input輸入內容也會用到,例如按下回車時如果不判斷是否喚醒了輸入法,會導致意外的執行。
<input id="$1" />
<script>
$1.addEventListener("compositionstart", (e) => console.log("Composition started", e));
$1.addEventListener("compositionupdate", (e) => console.log("Composition updated", e));
$1.addEventListener("compositionend", (e) => console.log("Composition ended", e));
$1.onkeydown = (e) => {
if (e.key === "Enter") {
if (e.isComposing) {
console.log("Enter key pressed during composition");
return;
}
console.log("Enter key pressed");
}
};
</script>
總結
在先前我們實現了編輯器的選區模塊,實現了受控的選區同步模式,這是我們在MVC分層架構提到的核心狀態同步模式之一。在這里我們在選區模塊的基礎上,通過瀏覽器的組合事件來實現半受控的輸入模式,這同樣是狀態同步的重要實現,這個輸入模式是目前大多數富文本編輯器的主流實現方式。
接下來我們要關注于處理瀏覽器復雜DOM結構默認行為,以及兼容IME輸入法的各種輸入場景,相當于我們來Case By Case地處理輸入法和瀏覽器兼容的行為。例如我們需要處理Emoji表情符號的長度問題、DOM結構輸入法操作問題、更加復雜的臟DOM檢查等等。
每日一題
參考
- https://developer.mozilla.org/zh-CN/docs/Web/API/CompositionEvent
- https://w3c.github.io/input-events/#interface-InputEvent-Attributes
- https://drive.googleblog.com/2010/05/whats-different-about-new-google-docs.html
- https://github.com/slab/quill/blob/539cbff/packages/quill/src/core/editor.ts#L299
- https://draftjs.org/docs/advanced-topics-issues-and-pitfalls/#mobile-not-yet-supported
- https://github.com/ianstormtaylor/slate/blob/ef76eb4/packages/slate-react/src/components/editable.tsx#L550

浙公網安備 33010602011771號