從零實現富文本編輯器#4-瀏覽器選區模型的核心交互策略

先前我們提到了,數據模型的設計是編輯器的基礎模塊,其直接影響了選區模塊的表示。選區模塊的設計同樣是編輯器的基礎部分,編輯器應用變更時操作范圍的表達,就需要基于選區模型來實現,也就是說選區代表的意義是編輯器需要感知在什么范圍內執行變更命令。

  • 開源地址: https://github.com/WindRunnerMax/BlockKit
  • 在線編輯: https://windrunnermax.github.io/BlockKit/
  • 項目筆記: https://github.com/WindRunnerMax/BlockKit/blob/master/NOTE.md

從零實現富文本編輯器項目的相關文章:

  • 深感一無所長,準備試著從零開始寫個富文本編輯器
  • 從零實現富文本編輯器#2-基于MVC模式的編輯器架構設計
  • 從零實現富文本編輯器#3-基于Delta的線性數據結構模型
  • 從零實現富文本編輯器#4-瀏覽器選區模型的核心交互策略

瀏覽器選區

數據模型設計直接影響了編輯器選區模型的表達,例如下面的例子中QuillSlate編輯器的模型選區實現,其與本身維護的數據結構密切相關。然而無論是哪種編輯器設計的數據模型,都需要基于瀏覽器的選區來實現,因此在本文中我們先來實現瀏覽器選區模型的基本操作。

// Quill
{ index: 0, length: 3 }// Slate
{ anchor: { offset: 0, path: [0, 0] }, focus: { offset: 3, path: [0, 0] } 
} 

實際上選區這個概念還是比較抽象,但是我們應該是經常與其打交道的。例如在鼠標拖動文本部分內容時,這部分會攜帶淡藍色的背景色,這就是選區范圍的表達,同樣我們可能會將其稱為選中、拖藍、選中范圍等等,這便是瀏覽器提供的選區能力。

除了選中文本的藍色背景色外,閃爍的光標同樣是選區的一種表現形式。光標的選區范圍是一個點,或者可以稱為折疊的選區。光標的表達通常只出現在可編輯元素中,例如輸入框、文本域、ContentEditable元素等。若是在非可編輯元素中,光標處于雖然不可見狀態,但實際上仍然是存在的。

瀏覽器選區的操作主要基于RangeSelection對象,Range對象表示包含節點和部分文本節點的文檔片段,Selection對象表示用戶選擇的文本范圍或光標符號的當前位置。

Range

Range對象與數學上的區間概念類似,也就是說Range指的是一個連續的內容范圍。數學上的區間表示由兩個點即可表示,Range對象的表達同樣是由startContainer起始到endContainer結束,因此選區必須要連續才能夠正常表達。Range實例對象的屬性如下:

  • startContainer:表示選區的起始節點。
  • startOffset:表示選區的起始偏移量。
  • endContainer:表示選區的結束節點。
  • endOffset:表示選區的結束偏移量。
  • collapsed: 表示Range的起始位置和終止位置是否相同,即折疊狀態。
  • commonAncestorContainer:表示選區的共同祖先節點,即完整包含startContainerendContainer最深一級的節點。

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來獲取矩形位置,這對實現諸如劃詞高亮等效果非常有用。此外,需要關注的是這里的firstChildText節點,即值為Node.TEXT_NODE類型,這樣才可以計算文本片段。

既然可以設置文本節點,那么自然也存在非文本節點的狀態。在調用設置選區時,如果節點類型是TextCommentCDATASection之一,那么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指向用戶結束選擇的地方。anchorfocus的概念不能與選區的起始位置startContainer和終止位置endContainer混淆,Range對象永遠是start節點指向end節點。

  • 錨點anchor: 選區的錨指的是選區起始點,當我們使用鼠標框選一個區域的時候,錨點就是我們鼠標按下瞬間的那個點。在用戶拖動鼠標時,錨點是不會變的。
  • 焦點focus: 選區的焦點指的是選區的終點,當我們用鼠標框選一個選區的時候,焦點是我們的鼠標松開瞬間所記錄的那個點。隨著用戶拖動鼠標,焦點的位置會隨著改變。

我們可以通過SelectionChange事件來監聽選區的變化,在這個選區的事件回調中,可以通過window.getSelection()來獲取當前的選區對象狀態。通過getSelection獲取的選區實例對象是個單例對象,即引用是同實例,內部的值是變化的。

當然W3C標準并未強制要求單例,但主流瀏覽器ChromeFirefoxSafari實現為同一實例。不過為了保證兼容性以及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來實現。正向的選區直接將startend方向的節點設置為baseextent即可,反向選區則是將startend方向的節點設置為extentbase,這樣就可以實現反向選區的效果,DOM節點參數與Range基本一致。

<span id="$1">123456</span>
<script>const selection = window.getSelection();selection.setBaseAndExtent($1.firstChild, 3, $1.firstChild, 1);
</script>

設置選區的部分可以通過上述API實現,而獲取選區的部分雖然可以通過focusanchor來獲取。但是在Selection對象上并未標記backward狀態,因此我們還需要通過getRangeAt方法來獲取選區內建的Range對象,因此來對比原始對象的狀態。

Selection對象上的rangeCount屬性表示選區中包含的Range對象的數量,通常我們只需要獲取第一個選區即可。這里的判斷條件需要特別關注,因為若是當前沒有選區值,也就是rangeCount0,此時直接獲取內建選區對象是會拋出異常的。

此外我們可能會好奇,通常進行選區操作的時候我們都是只能選單個連讀的選區的,為什么會出現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的數據結構,因為其無論是對于協同的支持還是diffops的表達都非常完善。

因此最開始想的是通過多個Quill Editor實例來實現嵌套Blocks,實際上這里邊的坑會有很多,需要禁用大量的編輯器默認行為并且重新實現。例如HistoryEnter換行操作、選區變換等等,可以預見這其中需要關注的點會有很多,但是相對于從零實現編輯器需要適配的各種瀏覽器兼容事件還有類似于輸入事件的處理等等,這種管理方式還算是可以接受的。

在這里需要關注一個問題,Blocks的編輯器在數據結構上必然需要以嵌套的數據結構來描述,當然初始化時可以設計的扁平化的Block,然后對每個Block都存儲了string[]Block節點信息來獲取引用。如果在設計編輯器時不希望有嵌套的結構,而是希望通過扁平的數據結構描述內容,此時在內容中如果引用了塊結構,那么就再并入Editor實例,這種思路會全部局限于富文本框架,擴展性會差一些。

Blocks的編輯器是完全由最外層的Block結構管理引用關系,也就是說引用是在children里的,而塊引用的編輯器則需要由編輯器本身來管理引用關系,也就是說引用是在ops里的。所以說對于數據結構的設計與實現非常依賴于編輯器整體的架構設計,當然我們也可以將塊引用的編輯器看作單入口的Blocks編輯器,這其中的Line表達全部交由Editor實例來處理,這就是不同設計中卻又相通的點。

在具體嘗試實現編輯器的過程中,發現瀏覽器中存在明確的選區策略,在下面例子State 1ContentEditable狀態下,無法做到從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作為嵌套結構的編輯器實例。在這種模型狀態下編輯器不會出現選區的偏移問題,我們的嵌套結構也可以借助QuillEmbed 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的部分文本時,會發現行12會被全部選中,基于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>&#8203;</span><span data-leaf><span contenteditable="false" style="margin: 0 0.1px;">@xxx</span></span><span data-leaf>&#8203;</span></div><div data-line-node="3"><span data-leaf>&#8203;<span contenteditable="false">@xxx</span>&#8203;</span></div>
</div>

那么除了通過零寬字符或者<br>標簽來放置光標外,自然也可以通過自繪選區來實現,因為此時不再需要ContentEditable屬性,那么自然就不會存在這些奇怪的行為。因此如果借助原生的選區實現,然后在此基礎上實現控制器層,就可以實現完全受控的編輯器。

但是這里存在一個很大的問題,就是內容的輸入,因為不啟用ContentEditable的話是無法出現光標的,自然也無法輸入內容。而如果我們想喚醒內容輸入,特別是需要喚醒IME輸入法的話,瀏覽器給予的常規API就是借助<input>來完成,因此我們就必須要實現隱藏的<input>來實現輸入,實際上很多代碼編輯器例如 CodeMirror 就是類似實現。

但是使用隱藏的<input>就會出現其他問題,因為焦點在input上時,瀏覽器的文本就無法選中了。因為在同個頁面中,焦點只會存在一個位置,因此在這種情況下,我們就必須要自繪選區的實現了。例如釘釘文檔、有道云筆記就是自繪選區,開源的 Monaco 同樣是自繪選區,TextBus 則繪制了光標,選區則是借助了瀏覽器實現。

其實這里說起來TextBus的實現倒是比較有趣,因為其自繪了光標焦點需要保持在外掛的textarea上,但是本身的文本選區也是需要焦點的。因此考慮這里的實現應該是具有比較特殊的實現,特別是IME的輸入中應該是有特殊處理,可能是重新觸發了事件。而且這里的IME輸入除了本身的非折疊選區內容刪除外,還需要喚醒字符的輸入,此外還有輸入過程中暫態的字符處理,自繪選區復雜的地方就在輸入模塊上。

最后探究發現TextBus既沒有使用ContentEditable這種常見的實現方案,也沒有像CodeMirror或者Monaco一樣自繪選區。從PlaygroundDOM節點上來看,其是維護了一個隱藏的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的話,那么就可以發現文本選區是可以拖拽的。通過這個表現其實可以看出來,主從框架的文檔的選區是完全獨立的,如果焦點在同一個框架內則會相互搶占,如果不在同一個框架內則是可以正常表達,也就是$1win的區別。

其實可以注意到此時文本選區是灰色的,這個可以用::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外,CodeMirrorMonaco/VSCode、釘釘文檔、有道云筆記的編輯器都是自繪選區的實現。那么自繪選區就需要考慮兩點內容,首先是如何計算當前光標在何處,其次就是如何繪制虛擬的選區圖層。選區圖層這部分我們之前的diff和虛擬圖層實現中已經聊過了,我們還是采取相對簡單的三行繪制的形式實現,現在基本都是這么實現的,折行情況下的獨行繪制目前只在飛書文檔的搜索替換中看到過。

因此復雜的就是光標在何處的計算,我們的編輯器選區依然可以保持瀏覽器的模型來實現,主要是取得anchorfocus的位置即可。那么在瀏覽器中是存在API可以實現光標的位置選區Range的,目前我看只有VSCode中使用了這個API,而CodeMirror和釘釘文檔則是自己實現了光標的位置計算。CodeMirror中通過二分查找來不斷對比光標和字符位置,這其中折行的查找會導致復雜了不少。

說起來,VSCode的包管理則是挺有趣的管理,VSC是開源的應用,在其中提取了核心的monaco-editor-core包。然后這個包會作為monaco-editordev依賴,在打包的時候會將其打包到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

本文來自互聯網用戶投稿,該文觀點僅代表作者本人,不代表本站立場。本站僅提供信息存儲空間服務,不擁有所有權,不承擔相關法律責任。
如若轉載,請注明出處:http://www.pswp.cn/diannao/85396.shtml
繁體地址,請注明出處:http://hk.pswp.cn/diannao/85396.shtml
英文地址,請注明出處:http://en.pswp.cn/diannao/85396.shtml

如若內容造成侵權/違法違規/事實不符,請聯系多彩編程網進行投訴反饋email:809451989@qq.com,一經查實,立即刪除!

相關文章

數論——質數和合數及求質數

質數、合數和質數篩 質數和合數及求質數試除法判斷質數Eratosthenes篩選法&#xff08;埃氏篩&#xff09;線性篩&#xff08;歐拉篩&#xff09; 質數有關OJ列舉P1835 素數密度 - 洛谷簡單的哥赫巴德猜想和cin優化 質數和合數及求質數 一個大于 1 的自然數&#xff0c;除了 1…

多商戶系統源碼性能調優實戰:從瓶頸定位到高并發架構設計!

在電商業務爆發式增長的今天&#xff0c;多商戶系統作為支撐平臺方、入駐商家和終端消費者的核心樞紐&#xff0c;其性能表現直接決定了商業變現效率。當你的商城在促銷期間崩潰&#xff0c;損失的不僅是訂單&#xff0c;更是用戶信任。 本文將深入剖析多商戶系統源碼性能優化的…

JDBC連不上mysql:Unable to load authentication plugin ‘caching_sha2_password‘.

最近為一個spring-boot項目下了mysql-9.3.0&#xff0c;結果因為mysql版本太新一直報錯連不上。 錯誤如下&#xff1a; 2025-06-01 16:19:43.516 ERROR 22088 --- [http-nio-8080-exec-2] o.a.c.c.C.[.[.[/].[dispatcherServlet] : Servlet.service() for servlet [dispat…

超標量處理器設計6-指令解碼

1. 指令緩存 指令緩存本質上是一個FIFO, 它能夠將指令按照程序中指定的順序存儲起來&#xff0c;這樣指令在解碼的時候&#xff0c;仍然可以按照程序中指定的順序進行解碼。指令緩存是超標量處理器中必須的部件&#xff0c;其原因有兩個&#xff1a; 1. 每周期可以取指的個數大…

基于 HT for Web 輕量化 3D 數字孿生數據中心解決方案

一、技術架構&#xff1a;HT for Web 的核心能力 圖撲軟件自主研發的 HT for Web 是基于 HTML5 的 2D/3D 可視化引擎&#xff0c;核心技術特性包括&#xff1a; 跨平臺渲染&#xff1a;采用 WebGL 技術&#xff0c;支持 PC、移動端瀏覽器直接訪問&#xff0c;兼容主流操作系統…

【Linux】shell的條件判斷

目錄 一.使用邏輯運算符判定命令執行結果 二.條件判斷方法 三.判斷表達式 3.1文件判斷表達式 3.2字符串測試表達式 3.3整數測試表達式 3.4邏輯操作符 一.使用邏輯運算符判定命令執行結果 && 在命令執行后如果沒有任何報錯時會執行符號后面的動作|| 在命令執行后…

【Python辦公】Excel簡易透視辦公小工具

目錄 專欄導讀1. 背景介紹2. 功能介紹3. 庫的安裝4. 界面展示5. 使用方法6. 實際應用場景7. 優化方向完整代碼總結專欄導讀 ?? 歡迎來到Python辦公自動化專欄—Python處理辦公問題,解放您的雙手 ?????? 博客主頁:請點擊——> 一晌小貪歡的博客主頁求關注 ?? 該系…

HarmonyOS鴻蒙與React Native的融合開發模式以及能否增加對性能優化的具體案例

鴻蒙與React Native的融合開發模式 一、技術架構設計 底層適配層 通過HarmonyOS的NDK封裝原生能力&#xff08;如分布式軟總線、AI引擎&#xff09; 使用React Native的Native Modules橋接鴻蒙API&#xff08;需重寫Java/Objective-C部分為ArkTS&#xff09; 組件映射機制 …

LLaMA-Factory - 批量推理(inference)的腳本

scripts/vllm_infer.py 是 LLaMA-Factory 團隊用于批量推理&#xff08;inference&#xff09;的腳本&#xff0c;基于 vLLM 引擎&#xff0c;支持高效的并行推理。它可以對一個數據集批量生成模型輸出&#xff0c;并保存為 JSONL 文件&#xff0c;適合大規模評測和自動化測試。…

麥克風和電腦內播放聲音實時識別轉文字軟件FunASR整合包V5下載

我基于FunASR制作的實時語音識別轉文字軟件當前更新到V5版本。軟件可以實時識別麥克風聲音和電腦內播放聲音轉為文字。 FunASR軟件介紹 FunASR 是一款基礎語音識別工具包和開源 SOTA 預訓練模型&#xff0c;支持語音識別、語音活動檢測、文本后處理等。 我使用FunASR制作了一…

子串題解——和為 K 的子數組【LeetCode】

謹記&#xff1a; 數組不是單調的話&#xff0c;不要用滑動窗口&#xff0c;考慮用前綴和 寫法一&#xff1a;兩次遍歷 代碼的核心思想是通過 前綴和 和 哈希表 來高效地統計符合條件的子數組個數。具體步驟如下&#xff1a; 計算前綴和數組 s&#xff1a; s[i] 表示 nums 的前…

硬件服務器基礎

1、硬件服務器基礎 2、服務器后面板 3、組件 3.1 CPU 3.2 內存 3.3 硬盤 3.4 風扇 4、服務器品牌 4.1 配置 4.2 CPU 架構 4.2.1 CPU 命名規則 4.2.2 服務器 CPU 和家用 CPU 的區別 4.2.3 CPU 在主板的位置 4.2.4 常見 CPU 安裝方式 4.3 內存中組件 4.3.1 內存的分類 4.3.1.1 …

OpenWebUI(1)源碼學習構建

1. 前言 通過docker鏡像拉取安裝就不介紹了&#xff0c;官方的命令很多。本節主要擼一擼源碼&#xff0c;所以&#xff0c;本地構建 2. 技術框架和啟動環境 后端python&#xff0c;前端svelte 環境要求&#xff1a;python > 3.11 &#xff0c;Node.js > 20.10 3. 源…

三方接口設計注意事項

前言 隨著業務系統間集成需求的增加&#xff0c;三方接口設計已成為現代軟件架構中的關鍵環節。一個設計良好的三方接口不僅能夠提供穩定可靠的服務&#xff0c;還能確保數據安全、提升系統性能并支持業務的持續發展。 一、設計原則 1. 統一接口原則 三方接口設計應遵循統一…

CSS篇-5

1. 內聯元素可以實現浮動嗎? 是的,內聯元素完全可以實現浮動。在 CSS 中,任何元素都可以被設置為浮動(float)。 當一個元素被設置了 float 屬性后,無論它本身是塊級元素還是內聯元素,它都會表現出類似于塊級元素的特性: 生成塊級框(Block-level box):浮動元素會生…

RocketMQ 學習

消息隊列 參考官方文檔&#xff1a;https://rocketmq.apache.org/zh/docs/ 基本概念 主題&#xff08;Topic&#xff09;&#xff1a;是消息傳輸和消息存儲的頂級容器&#xff0c;不是實際的消息容器&#xff0c;而是一個邏輯上的概念&#xff0c;用于區分不同業務消息的標識&…

Conda更換鏡像源教程:加速Python包下載

Conda更換鏡像源教程&#xff1a;加速Python包下載 為什么要更換conda鏡像源&#xff1f; Conda作為Python的包管理和環境管理工具&#xff0c;默認使用的是國外鏡像源&#xff0c;在國內下載速度往往較慢。通過更換為國內鏡像源&#xff0c;可以顯著提高包下載速度&#xff…

PCIe—TS1/TS2 之Polling.Active(一)

前文 訓練序列有序集用于比特對齊、符號對齊以及交換物理層參數。2.5GT/s和5GT/s速率時&#xff0c;訓練序列有序集不會加擾&#xff0c;只用8b/10b 編碼。但到8GT/s及以上速率時&#xff0c;采用128b/130b編碼&#xff0c;符號有可能加擾有可能不加擾&#xff0c;具體…

【HarmonyOS Next之旅】DevEco Studio使用指南(二十八) -> 開發云對象

目錄 1 -> 開發流程 2 -> 創建云對象 3 -> 開發云對象 4 -> 調試云對象 4.1 -> 前提條件 4.2 -> 通過本地調用方式調試云對象 4.3 -> 通過遠程調用方式調試云對象 5 -> 部署云對象 1 -> 開發流程 除去傳統的云函數&#xff0c;您還可在端云…

基于51單片機的音樂盒汽車喇叭調音量proteus仿真

地址&#xff1a; https://pan.baidu.com/s/1l3CSSMi4uMV5-XLefnKoSg 提取碼&#xff1a;1234 仿真圖&#xff1a; 芯片/模塊的特點&#xff1a; AT89C52/AT89C51簡介&#xff1a; AT89C51 是一款常用的 8 位單片機&#xff0c;由 Atmel 公司&#xff08;現已被 Microchip 收…