先前我們提到了,數據模型的設計是編輯器的基礎模塊,其直接影響了選區模塊的表示。選區模塊的設計同樣是編輯器的基礎部分,編輯器應用變更時操作范圍的表達,就需要基于選區模型來實現,也就是說選區代表的意義是編輯器需要感知在什么范圍內執行變更命令。
- 開源地址: https://github.com/WindRunnerMax/BlockKit
- 在線編輯: https://windrunnermax.github.io/BlockKit/
- 項目筆記: https://github.com/WindRunnerMax/BlockKit/blob/master/NOTE.md
從零實現富文本編輯器項目的相關文章:
- 深感一無所長,準備試著從零開始寫個富文本編輯器
- 從零實現富文本編輯器#2-基于MVC模式的編輯器架構設計
- 從零實現富文本編輯器#3-基于Delta的線性數據結構模型
- 從零實現富文本編輯器#4-瀏覽器選區模型的核心交互策略
瀏覽器選區
數據模型設計直接影響了編輯器選區模型的表達,例如下面的例子中Quill
與Slate
編輯器的模型選區實現,其與本身維護的數據結構密切相關。然而無論是哪種編輯器設計的數據模型,都需要基于瀏覽器的選區來實現,因此在本文中我們先來實現瀏覽器選區模型的基本操作。
// Quill
{ index: 0, length: 3 }// Slate
{ anchor: { offset: 0, path: [0, 0] }, focus: { offset: 3, path: [0, 0] }
}
實際上選區這個概念還是比較抽象,但是我們應該是經常與其打交道的。例如在鼠標拖動文本部分內容時,這部分會攜帶淡藍色的背景色,這就是選區范圍的表達,同樣我們可能會將其稱為選中、拖藍、選中范圍等等,這便是瀏覽器提供的選區能力。
除了選中文本的藍色背景色外,閃爍的光標同樣是選區的一種表現形式。光標的選區范圍是一個點,或者可以稱為折疊的選區。光標的表達通常只出現在可編輯元素中,例如輸入框、文本域、ContentEditable
元素等。若是在非可編輯元素中,光標處于雖然不可見狀態,但實際上仍然是存在的。
瀏覽器選區的操作主要基于Range
和Selection
對象,Range
對象表示包含節點和部分文本節點的文檔片段,Selection
對象表示用戶選擇的文本范圍或光標符號的當前位置。
Range
Range
對象與數學上的區間概念類似,也就是說Range
指的是一個連續的內容范圍。數學上的區間表示由兩個點即可表示,Range
對象的表達同樣是由startContainer
起始到endContainer
結束,因此選區必須要連續才能夠正常表達。Range
實例對象的屬性如下:
startContainer
:表示選區的起始節點。startOffset
:表示選區的起始偏移量。endContainer
:表示選區的結束節點。endOffset
:表示選區的結束偏移量。collapsed
: 表示Range
的起始位置和終止位置是否相同,即折疊狀態。commonAncestorContainer
:表示選區的共同祖先節點,即完整包含startContainer
和endContainer
最深一級的節點。
Range
對象還存在諸多方法,在我們的編輯器中常用的主要是設置設置選區起始位置setStart
、設置結束位置setEnd
、獲取選區矩形位置getBoundingClientRect
等。在下面的例子中,我們就可以獲取文本片段23
的位置:
<span id="$1">123456</span>
<script>const range = document.createRange();range.setStart($1.firstChild, 1); // $1range.setEnd($1.firstChild, 3);console.log(range.getBoundingClientRect());
</script>
獲取文本片段矩形位置是個非常重要的應用,這樣我們可以實現非完整DOM
元素的位置獲取,而不是必須通過HTML DOM
來獲取矩形位置,這對實現諸如劃詞高亮等效果非常有用。此外,需要關注的是這里的firstChild
是Text
節點,即值為Node.TEXT_NODE
類型,這樣才可以計算文本片段。
既然可以設置文本節點,那么自然也存在非文本節點的狀態。在調用設置選區時,如果節點類型是Text
、Comment
或CDATASection
之一,那么offset
指的是從結束節點算起字符的偏移量。對于其他Node
類型節點,offset
是指從結束結點開始算起子節點的偏移量。
在下面的例子中,我們就是將選區設置為非文本內容的節點。此時最外層的$1
節點作為父節點,存在2
個子節點,因此offset
可以設置的范圍是0-2
,若此時設置為3
則會直接拋出異常。這里基本與設置文本選區的offset
一致,差異是文本選區必須要文本節點,非文本則是父節點。
<span id="$1"><span>123</span><span>456</span></span>
<script>const range = document.createRange();range.setStart($1, 1);range.setEnd($1, 2);console.log(range.getBoundingClientRect());
</script>
構造Range
對象主要的目的便是獲取相關DOM
的位置來計算,還有個常見的需求是實現內容高亮,通常來說這需要我們主動計算位置來實現虛擬圖層。在較新的瀏覽器中實現了::highlight
偽元素,可以幫我們用瀏覽器的原生實現來實現高亮效果,下面的例子中23
文本片段就會出現背景色。
<span id="$1">123456</span>
<style>::highlight(highlight-bg) {color: white;background-color: blue;}
</style>
<script>const range = document.createRange();range.setStart($1.firstChild, 1);range.setEnd($1.firstChild, 3);const highlightBG = new Highlight();highlightBG.add(range);CSS.highlights.set("highlight-bg", highlightBG);
</script>
Selection
Selection
對象表示用戶選擇的文本范圍或光標符號的當前位置,其代表頁面中的文本選區,可能橫跨多個元素。文本選區通常由用戶拖拽鼠標經過文字而產生,實際上瀏覽器中的Selection
就是由Range
來組成的。Selection
對象的主要屬性如下:
anchorNode
:表示選區的起始節點。anchorOffset
:表示選區的起始偏移量。focusNode
:表示選區的結束節點。focusOffset
:表示選區的結束偏移量。isCollapsed
:表示選區的起始位置和終止位置是否相同,即折疊狀態。rangeCount
:表示選區中包含的Range
對象的數量。
用戶可能從左到右選擇文本或從右到左選擇文本,anchor
指向用戶開始選擇的地方,而focus
指向用戶結束選擇的地方。anchor
和focus
的概念不能與選區的起始位置startContainer
和終止位置endContainer
混淆,Range
對象永遠是start
節點指向end
節點。
- 錨點
anchor
: 選區的錨指的是選區起始點,當我們使用鼠標框選一個區域的時候,錨點就是我們鼠標按下瞬間的那個點。在用戶拖動鼠標時,錨點是不會變的。 - 焦點
focus
: 選區的焦點指的是選區的終點,當我們用鼠標框選一個選區的時候,焦點是我們的鼠標松開瞬間所記錄的那個點。隨著用戶拖動鼠標,焦點的位置會隨著改變。
我們可以通過SelectionChange
事件來監聽選區的變化,在這個選區的事件回調中,可以通過window.getSelection()
來獲取當前的選區對象狀態。通過getSelection
獲取的選區實例對象是個單例對象,即引用是同實例,內部的值是變化的。
當然W3C
標準并未強制要求單例,但主流瀏覽器Chrome
、Firefox
、Safari
實現為同一實例。不過為了保證兼容性以及null
狀態,我們實際要使用選區對象的時候,通常是實時通過getSelection
獲取選區狀態。此外Selection
對象的屬性是不可枚舉的,spread
操作符是無效的。
document.addEventListener("selectionchange", () => {const selection = window.getSelection();console.log({anchor: selection.anchorNode,anchorOffset: selection.anchorOffset,focus: selection.focusNode,focusOffset: selection.focusOffset,isCollapsed: selection.isCollapsed,});
});
編輯器中自然還需要設置選區的操作,這部分可以使用Selection
對象的addRange
方法來實現,通常在調用該方法之前需要使用removeAllRanges
方法來移除已有選區。需要注意的是,該方法無法處理反向的選區,即backward
狀態。
<span id="$1">123456</span>
<script>const range = document.createRange();range.setStart($1.firstChild, 1);range.setEnd($1.firstChild, 3);const selection = window.getSelection();selection.removeAllRanges();selection.addRange(range);
</script>
因此設置選區時,通常是需要使用setBaseAndExtent
來實現。正向的選區直接將start
、end
方向的節點設置為base
和extent
即可,反向選區則是將start
、end
方向的節點設置為extent
和base
,這樣就可以實現反向選區的效果,DOM
節點參數與Range
基本一致。
<span id="$1">123456</span>
<script>const selection = window.getSelection();selection.setBaseAndExtent($1.firstChild, 3, $1.firstChild, 1);
</script>
設置選區的部分可以通過上述API
實現,而獲取選區的部分雖然可以通過focus
和anchor
來獲取。但是在Selection
對象上并未標記backward
狀態,因此我們還需要通過getRangeAt
方法來獲取選區內建的Range
對象,因此來對比原始對象的狀態。
Selection
對象上的rangeCount
屬性表示選區中包含的Range
對象的數量,通常我們只需要獲取第一個選區即可。這里的判斷條件需要特別關注,因為若是當前沒有選區值,也就是rangeCount
為0
,此時直接獲取內建選區對象是會拋出異常的。
此外我們可能會好奇,通常進行選區操作的時候我們都是只能選單個連讀的選區的,為什么會出現rangeCount
屬性。這里是需要注意在Firefox
中是可以設置多個選區的,按住Ctrl
鍵可以實現多個選區的狀態,但是為了主要瀏覽器兼容性通常我們只需要處理首個選區。
<span id="$1">123456</span>
<script>const selection = window.getSelection();selection.setBaseAndExtent($1.firstChild, 3, $1.firstChild, 1);if (selection.rangeCount > 0) {const range = selection.getRangeAt(0);const rect = range.getBoundingClientRect();console.log(rect);}
</script>
選區這部分還有個比較有趣的控制效果,通常來說ContentEditable
元素是應該作為整體被選中的,此時內部的文本節點表現仍然是被選中的狀態。而若是真的想避免文本內容被選中,便可以使用user-select
屬性來完成,由于選區實際上靠邊緣節點就可以實現,就可以出現特殊的選區斷層效果。
此外,下面的例子中我們是使用childNodes
而不是children
來獲取子節點的狀態。childNodes
是獲取所有子節點的集合,包括文本節點、注釋節點等,而children
只會獲取元素節點的集合,因此在設置選區時需要特別注意。
<span id="$1">12<span style="user-select: none;">34</span>56</span>
<script>const selection = window.getSelection();selection.setBaseAndExtent($1.childNodes[0], 1, $1.childNodes[2], 1);
</script>
在Range
對象的最后,我們介紹了::highlight
偽元素,在這里我們同樣介紹一下::selection
偽元素。::selection
用于將央視應用于用戶選中的文本部分,即我們可以通過設置::selection
的樣式來實現選區的樣式控制,例如設置背景色、字體顏色等。
在這里還有個比較有趣的事情,在實現瀏覽器的擴展或者腳本時比較有用。如果想恢復::selection
偽元素的原始背景色,是不可以設置為transparent
的,因為這會導致選區的背景色消失,實際上默認的淺藍色背景是保留的關鍵字highlight
,當然使用#BEDAFF
也是可行的。
<span id="$1">123456</span>
<style>span::selection{background: blue;color: white;}
</style>
<script>const selection = window.getSelection();selection.setBaseAndExtent($1.firstChild, 1, $1.firstChild, 3);
</script>
可編輯元素
雖然選區本質上跟可編輯元素并沒有直接關系,但是我們實現的目標是富文本編輯器,自然是需要在ContentEditable
元素中處理選區狀態。不過在這之前,我們可以先簡單針對于input
元素實現選區操作。
<input id="$1" value="123456" />
<script>$1.focus();$1.setSelectionRange(1, 3);
</script>
緊接著來看ContentEditable
實現,本質上對于選區的操作是否是可編輯元素是沒有什么區別的。只不過在可編輯元素中會顯示地表現出光標,或者稱為插入符。而在非可編輯元素中,光標雖然不可見,但是選區的變換事件以及轉移實際上還是真實存在的。
<span id="$1" contenteditable>123456</span>
<script>const selection = window.getSelection();selection.setBaseAndExtent($1.firstChild, 3, $1.firstChild, 1);
</script>
其實在最開始的時候,我想實現的就是純Blocks
的編輯器,而實際上目前我并沒有找到比較好的編輯器實現來做參考,主要是類似的編輯器都設計的特別復雜,在沒有相關文章的情況很難理解。當時也還是比較傾向于quill-delta
的數據結構,因為其無論是對于協同的支持還是diff
、ops
的表達都非常完善。
因此最開始想的是通過多個Quill Editor
實例來實現嵌套Blocks
,實際上這里邊的坑會有很多,需要禁用大量的編輯器默認行為并且重新實現。例如History
、Enter
換行操作、選區變換等等,可以預見這其中需要關注的點會有很多,但是相對于從零實現編輯器需要適配的各種瀏覽器兼容事件還有類似于輸入事件的處理等等,這種管理方式還算是可以接受的。
在這里需要關注一個問題,Blocks
的編輯器在數據結構上必然需要以嵌套的數據結構來描述,當然初始化時可以設計的扁平化的Block
,然后對每個Block
都存儲了string[]
的Block
節點信息來獲取引用。如果在設計編輯器時不希望有嵌套的結構,而是希望通過扁平的數據結構描述內容,此時在內容中如果引用了塊結構,那么就再并入Editor
實例,這種思路會全部局限于富文本框架,擴展性會差一些。
Blocks
的編輯器是完全由最外層的Block
結構管理引用關系,也就是說引用是在children
里的,而塊引用的編輯器則需要由編輯器本身來管理引用關系,也就是說引用是在ops
里的。所以說對于數據結構的設計與實現非常依賴于編輯器整體的架構設計,當然我們也可以將塊引用的編輯器看作單入口的Blocks
編輯器,這其中的Line
表達全部交由Editor
實例來處理,這就是不同設計中卻又相通的點。
在具體嘗試實現編輯器的過程中,發現瀏覽器中存在明確的選區策略,在下面例子State 1
的ContentEditable
狀態下,無法做到從Selection Line 1
選擇到Selection Line 2
。這是瀏覽器默認行為,而這種選區的默認策略就定染導致無法基于這種模型實現Blocks
。
<p>State 1</p>
<div contenteditable="false" data-block><div contenteditable="true" data-line>Selection Line 1</div><div contenteditable="true" data-line>selection Line 2</div>
</div>
而如果是Stage 2
的模型狀態,是完全可以做到選區的正常操作的,在模型方面沒有什么問題,但是我們此時的Quill
選區又出現了問題。由于其在初始化時是會由<br/>
產生到div/p
狀態的突變,導致其選區的Range
發生異動,此時在瀏覽器中的光標是不正確的。
而我們此時沒有辦法入侵到Quill
中幫助其修正選區,且DOM
上沒有任何輔助我們修正選區的標記,所以這個方式也難以繼續下去。甚至于如果需要處理其中狀態變更的話,還需要侵入到parchment
的視圖層實現,這樣就需要更加復雜的處理。
<p>State 2</p>
<div contenteditable="true" data-block><div contenteditable="true" data-line>Selection Line 1</div><div contenteditable="true" data-line>selection Line 2</div>
</div>
因此在這種狀態下,我們可能只能選取Stage 3
策略的形式,并不實現完整的Blocks
,而是將Quill
作為嵌套結構的編輯器實例。在這種模型狀態下編輯器不會出現選區的偏移問題,我們的嵌套結構也可以借助Quill
的Embed Blot
來實現插件擴展嵌套Block
結構。
<p>State 3</p>
<div contenteditable="true" data-block><div data-line>Selection Line 1</div><div data-line>selection Line 2</div><div contenteditable="false" data-block><div contenteditable="true" data-line>Selection Line 1</div><div contenteditable="true" data-line>selection Line 2</div></div>
</div>
在這種情況下,如果想實現Blocks
編輯器的目標,通過Quill
來實現就只能依賴于Embed Blot
的模式來實現,而這又是完全依賴于Quill
維護的視圖層。如果需要處理邊界Case
就需要再處理parchment
的視圖層,這樣下來會非常麻煩,因此這也是從零實現編輯器的部分原因。
其實類似的問題在editor.js
中也有存在,在其在線DEMO
中可以發現我們無法從純文本行1
選擇到行2
。具體是在選中行1
的部分文本后,拖拽選擇到行2
的部分文本時,會發現行1
和2
會被全部選中,基于slate
實現的Yoopta-Editor
也存在類似的問題。
這個問題在飛書文檔上就并不存在了,在飛書文檔中的重點是選區必然處于同一個父級Block
下,具體的實現是當鼠標按下時拖選完全處于非受控狀態,此時若是碰撞到其他塊內,內部的選區樣式會被::selection
覆蓋掉,然后這個塊整體的樣式會被class
應用選中的樣式。
而此時若是抬起鼠標,此時會立即糾正選區狀態,此時的選區實現是會真正處于同一個父級塊中。處于同一個塊是為了簡化操作,無論是應用變更還是獲取選中片段的數據,在同一個父級塊中進行迭代就不需要基于渲染塊的順序來遞歸迭代查找,這樣在數據處理方面會更加簡單。
<div><div class="level1"><div class="level2.1"><div class="level3.1" id="$nodeA"></div></div><div class="level2.2"><div class="level3.1"><div class="level4.1" id="$nodeB"></div></div></div></div>
</div><script>const getNodeDepth = (node) => {let depth = 0;let current = node;while (current.parentNode) {depth++;current = current.parentNode;}return depth;};const liftNode = (node1, node2) => {const depth1 = getNodeDepth(node1);const depth2 = getNodeDepth(node2);let [deepNode, shallowNode] = depth1 > depth2 ? [node1, node2] : [node2, node1];for (let i = Math.abs(depth1 - depth2); i > 0; i--) {deepNode = deepNode.parentNode;}while (deepNode !== shallowNode) {const deepNodeParent = deepNode.parentNode;const shallowNodeParent = shallowNode.parentNode;if (deepNodeParent === shallowNodeParent) {return [deepNode, shallowNode, deepNodeParent];}deepNode = deepNodeParent;shallowNode = shallowNodeParent;}return [null, null, null];};console.log(liftNode($nodeA, $nodeB));
</script>
自繪選區
在我們的設計中是基于ContentEditable
實現,也就是說沒有準備實現自繪選區,只是最近思考了一下自繪選區的實現。通常來說在整體編輯器內的contenteditable=false
節點會存在特殊的表現,在類似于inline-block
節點中,例如Mention
節點中,當節點前后沒有任何內容時,我們就需要在其前后增加零寬字符,用以放置光標。
在下面的例子中,line-1
是無法將光標放置在@xxx
內容后的,雖然我們能夠將光標放置之前,但此時光標位置是在line node
上,是不符合我們預期的文本節點的。那么我們就必須要在其后加入零寬字符,在line-2/3
中我們就可以看到正確的光標放置效果。這里的0.1px
也是個為了兼容光標的放置的magic
,沒有這個hack
的話,非同級節點光標同樣無法放置在inline-block
節點后。
<div contenteditable style="outline: none"><div data-line-node="1"><span data-leaf><span contenteditable="false" style="margin: 0 0.1px;">@xxx</span></span></div><div data-line-node="2"><span data-leaf>​</span><span data-leaf><span contenteditable="false" style="margin: 0 0.1px;">@xxx</span></span><span data-leaf>​</span></div><div data-line-node="3"><span data-leaf>​<span contenteditable="false">@xxx</span>​</span></div>
</div>
那么除了通過零寬字符或者<br>
標簽來放置光標外,自然也可以通過自繪選區來實現,因為此時不再需要ContentEditable
屬性,那么自然就不會存在這些奇怪的行為。因此如果借助原生的選區實現,然后在此基礎上實現控制器層,就可以實現完全受控的編輯器。
但是這里存在一個很大的問題,就是內容的輸入,因為不啟用ContentEditable
的話是無法出現光標的,自然也無法輸入內容。而如果我們想喚醒內容輸入,特別是需要喚醒IME
輸入法的話,瀏覽器給予的常規API
就是借助<input>
來完成,因此我們就必須要實現隱藏的<input>
來實現輸入,實際上很多代碼編輯器例如 CodeMirror 就是類似實現。
但是使用隱藏的<input>
就會出現其他問題,因為焦點在input
上時,瀏覽器的文本就無法選中了。因為在同個頁面中,焦點只會存在一個位置,因此在這種情況下,我們就必須要自繪選區的實現了。例如釘釘文檔、有道云筆記就是自繪選區,開源的 Monaco 同樣是自繪選區,TextBus 則繪制了光標,選區則是借助了瀏覽器實現。
其實這里說起來TextBus
的實現倒是比較有趣,因為其自繪了光標焦點需要保持在外掛的textarea
上,但是本身的文本選區也是需要焦點的。因此考慮這里的實現應該是具有比較特殊的實現,特別是IME
的輸入中應該是有特殊處理,可能是重新觸發了事件。而且這里的IME
輸入除了本身的非折疊選區內容刪除外,還需要喚醒字符的輸入,此外還有輸入過程中暫態的字符處理,自繪選區復雜的地方就在輸入模塊上。
最后探究發現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
的輸入法,這個Magic
的表現在諸多瀏覽器都可以正常觸發。
<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>
那么除了特殊的TextBus
外,CodeMirror
、Monaco/VSCode
、釘釘文檔、有道云筆記的編輯器都是自繪選區的實現。那么自繪選區就需要考慮兩點內容,首先是如何計算當前光標在何處,其次就是如何繪制虛擬的選區圖層。選區圖層這部分我們之前的diff
和虛擬圖層實現中已經聊過了,我們還是采取相對簡單的三行繪制的形式實現,現在基本都是這么實現的,折行情況下的獨行繪制目前只在飛書文檔的搜索替換中看到過。
因此復雜的就是光標在何處的計算,我們的編輯器選區依然可以保持瀏覽器的模型來實現,主要是取得anchor
和focus
的位置即可。那么在瀏覽器中是存在API
可以實現光標的位置選區Range
的,目前我看只有VSCode
中使用了這個API
,而CodeMirror
和釘釘文檔則是自己實現了光標的位置計算。CodeMirror
中通過二分查找來不斷對比光標和字符位置,這其中折行的查找會導致復雜了不少。
說起來,VSCode
的包管理則是挺有趣的管理,VSC
是開源的應用,在其中提取了核心的monaco-editor-core
包。然后這個包會作為monaco-editor
的dev
依賴,在打包的時候會將其打包到monaco-editor
中,monaco-editor
則是重新包裝了core
來讓編輯器可以運行在瀏覽器web
容器內,這樣就可以實現web
版的VSCode
。
在這里我們可以使用瀏覽器相關的API
來實現光標的選區位置計算,配合起來相關的API
兼容性也是比較好的,當然如果在shadow DOM
中使用的話兼容性就比較差了。這是基于瀏覽器DOM
的實現,若是使用Canvas
繪制選區的話,就需要完全基于繪制的文本來計算了。這部分實現起來倒是也并不是很復雜,DOM
繪制的復雜是因為我們難以獲取文本位置及大小,而在Canvas
中這些信息我們通常都是會記錄的。
<div id="$container" style="width: 200px; word-break: break-word; user-select: none; position: relative;"><div id="$text" data-text>123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123123</div><div id="$layer"></div></div>
<style>body { margin: 0; padding: 0; }</style>
<script>const render = (startRect, endRect) => {const block = document.createElement("div");block.style.position = "absolute";block.style.width = "100%";block.style.height = "0";block.style.top = startRect.top + "px";block.style.pointerEvents = "none";const head = document.createElement("div");const body = document.createElement("div");const tail = document.createElement("div");$layer.innerHTML = "";$layer.appendChild(block);block.appendChild(head);block.appendChild(body);block.appendChild(tail);if (startRect.top === endRect.top) {// 單行(非折行)的情況 `head`head.style.marginLeft = startRect.left + "px";head.style.height = startRect.height + "px";head.style.width = endRect.right - startRect.left + "px";head.style.backgroundColor = "rgba(0, 0, 0, 0.2)";} else if (endRect.top - startRect.bottom < startRect.height) {// 兩行(折單次)的情況 `head + tail` `body`占位head.style.marginLeft = startRect.left + "px";head.style.height = startRect.height + "px";head.style.width = startRect.width - startRect.left + "px";head.style.backgroundColor = "rgba(0, 0, 0, 0.2)";body.style.height = endRect.top - startRect.bottom + "px";tail.style.width = endRect.right + "px";tail.style.height = endRect.height + "px";tail.style.backgroundColor = "rgba(0, 0, 0, 0.2)";} else {// 多行(折多次)的情況 `head + body + tail`head.style.marginLeft = startRect.left + "px";head.style.height = startRect.height + "px";head.style.width = startRect.width - startRect.left + "px";head.style.backgroundColor = "rgba(0, 0, 0, 0.2)";body.style.width = "100%";body.style.height = endRect.top - startRect.bottom + "px";body.style.backgroundColor = "rgba(0, 0, 0, 0.2)";tail.style.marginLeft = 0;tail.style.height = endRect.height + "px";tail.style.width = endRect.right + "px";tail.style.backgroundColor = "rgba(0, 0, 0, 0.2)";}}const getCaretFromPoint = (x, y) => {const element = document.elementFromPoint(x, y);if (!element || !element.closest("[data-text]")) {return null;}if (document.caretRangeFromPoint) {const range = document.caretRangeFromPoint(x, y)return { node: range.startContainer, offset: range.startOffset }} else if (document.caretPositionFromPoint) {const pos = document.caretPositionFromPoint(x, y);return { node: pos.offsetNode, offset: pos.offset }}return null;}const getRect = (node, offset) => {const range = document.createRange();range.setStart(node, offset);range.setEnd(node, offset);return range.getBoundingClientRect();}$text.onmousedown = (e) => {const start = getCaretFromPoint(e.clientX, e.clientY);if(!start) return;const startRect = getRect(start.node, start.offset);const onMouseMove = (e) => {const end = getCaretFromPoint(e.clientX, e.clientY);if (!end) return;const endRect = getRect(end.node, end.offset);const [rect1, rect2] = start.offset < end.offset ? [startRect, endRect] : [endRect, startRect];render(rect1, rect2);}const onMouseUp = (e) => {document.removeEventListener("mousemove", onMouseMove);document.removeEventListener("mouseup", onMouseUp);}document.addEventListener("mousemove", onMouseMove);document.addEventListener("mouseup", onMouseUp);}
</script>
總結
在這里我們總結了瀏覽器的相關API
,并且基于Range
對象與Selection
對象實現了基本的選區操作,并且舉了相關的應用具體場景和示例。此外,還介紹了先前針對Blocks
編輯器的選區遇到的問題,在最后我們調研了諸多自繪選區的編輯器實現,并且實現了簡單的自繪選區示例。
接下來我們會從數據模型出發,設計編輯器選區模型的表示,然后在瀏覽器選區相關的API
基礎上,實現編輯器選區模型與瀏覽器選區的同步。通過選區模型作為編輯器操作的范圍目標,來實現編輯器的基礎操作,例如插入、刪除、格式化等操作,以及諸多選區相關的邊界操作問題。
每日一題
- https://github.com/WindRunnerMax/EveryDay
參考
- https://www.w3.org/TR/selection-api/
- https://juejin.cn/post/7068232010304585741
- https://developer.mozilla.org/en-US/docs/Web/API/Range
- https://developer.mozilla.org/en-US/docs/Web/API/Selection
- https://developer.mozilla.org/en-US/docs/Web/API/Element/children
- https://developer.mozilla.org/en-US/docs/Web/API/Document/elementFromPoint
- https://developer.mozilla.org/en-US/docs/Web/API/Document/caretRangeFromPoint
- https://developer.mozilla.org/en-US/docs/Web/API/Document/caretPositionFromPoint